1
0
mirror of https://github.com/containers/bootc.git synced 2026-02-05 06:45:13 +01:00

container: Add path-based compute-composefs-digest command

Add a new `bootc container compute-composefs-digest` command that computes
the bootable composefs digest directly from a filesystem directory path,
defaulting to `/target`. This enables computing digests in container
environments without requiring access to container storage or a booted
host system.

The existing container-storage-based behavior is preserved and renamed
to `compute-composefs-digest-from-storage` (hidden). The `hack/compute-composefs-digest`
script is updated to use the renamed command.

The core digest computation logic is extracted into a new
`bootc_composefs::digest` module with:
- `new_temp_composefs_repo()` helper for DRY temp repository creation
- `compute_composefs_digest()` function with "/" path rejection

Unit tests and an integration test verify the command works correctly,
producing valid SHA-512 hex digests with consistent results across
multiple invocations. Exact digest values are not asserted due to
environmental variations (SELinux labels, timestamps, etc.).

Closes: https://github.com/bootc-dev/bootc/issues/1862

Assisted-by: OpenCode (Claude Opus 4.5)
Signed-off-by: John Eckersberg <jeckersb@redhat.com>
This commit is contained in:
John Eckersberg
2025-12-17 12:05:40 -05:00
committed by Colin Walters
parent d90f0197c0
commit 72f1f2720d
5 changed files with 235 additions and 19 deletions

View File

@@ -0,0 +1,152 @@
//! Composefs digest computation utilities.
use std::fs::File;
use std::io::BufWriter;
use std::sync::Arc;
use anyhow::{Context, Result};
use camino::Utf8Path;
use cap_std_ext::cap_std;
use cap_std_ext::cap_std::fs::Dir;
use composefs::dumpfile;
use composefs::fsverity::FsVerityHashValue;
use composefs_boot::BootOps as _;
use tempfile::TempDir;
use crate::store::ComposefsRepository;
/// Creates a temporary composefs repository for computing digests.
///
/// Returns the TempDir guard (must be kept alive for the repo to remain valid)
/// and the repository wrapped in Arc.
pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc<ComposefsRepository>)> {
let td_guard = tempfile::tempdir_in("/var/tmp")?;
let td_path = td_guard.path();
let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?;
td_dir.create_dir("repo")?;
let repo_dir = td_dir.open_dir("repo")?;
let mut repo = ComposefsRepository::open_path(&repo_dir, ".").context("Init cfs repo")?;
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
repo.set_insecure(true);
Ok((td_guard, Arc::new(repo)))
}
/// Computes the bootable composefs digest for a filesystem at the given path.
///
/// This reads the filesystem from the specified path, transforms it for boot,
/// and computes the composefs image ID.
///
/// # Arguments
/// * `path` - Path to the filesystem root to compute digest for
/// * `write_dumpfile_to` - Optional path to write a dumpfile
///
/// # Returns
/// The computed digest as a 128-character hex string (SHA-512).
///
/// # Errors
/// Returns an error if:
/// * The path is "/" (cannot operate on active root filesystem)
/// * The filesystem cannot be read
/// * The transform or digest computation fails
pub(crate) fn compute_composefs_digest(
path: &Utf8Path,
write_dumpfile_to: Option<&Utf8Path>,
) -> Result<String> {
if path.as_str() == "/" {
anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead");
}
let (_td_guard, repo) = new_temp_composefs_repo()?;
// Read filesystem from path, transform for boot, compute digest
let mut fs =
composefs::fs::read_filesystem(rustix::fs::CWD, path.as_std_path(), Some(&repo), false)?;
fs.transform_for_boot(&repo).context("Preparing for boot")?;
let id = fs.compute_image_id();
let digest = id.to_hex();
if let Some(dumpfile_path) = write_dumpfile_to {
let mut w = File::create(dumpfile_path)
.with_context(|| format!("Opening {dumpfile_path}"))
.map(BufWriter::new)?;
dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
}
Ok(digest)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{self, Permissions};
use std::os::unix::fs::PermissionsExt;
/// Helper to create a minimal test filesystem structure
fn create_test_filesystem(root: &std::path::Path) -> Result<()> {
// Create directories required by transform_for_boot
fs::create_dir_all(root.join("boot"))?;
fs::create_dir_all(root.join("sysroot"))?;
// Create usr/bin directory
let usr_bin = root.join("usr/bin");
fs::create_dir_all(&usr_bin)?;
// Create usr/bin/hello with executable permissions
let hello_path = usr_bin.join("hello");
fs::write(&hello_path, "test\n")?;
fs::set_permissions(&hello_path, Permissions::from_mode(0o755))?;
// Create etc directory
let etc = root.join("etc");
fs::create_dir_all(&etc)?;
// Create etc/config with regular file permissions
let config_path = etc.join("config");
fs::write(&config_path, "test\n")?;
fs::set_permissions(&config_path, Permissions::from_mode(0o644))?;
Ok(())
}
#[test]
fn test_compute_composefs_digest() {
// Create temp directory with test filesystem structure
let td = tempfile::tempdir().unwrap();
create_test_filesystem(td.path()).unwrap();
// Compute the digest
let path = Utf8Path::from_path(td.path()).unwrap();
let digest = compute_composefs_digest(path, None).unwrap();
// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
assert_eq!(
digest.len(),
128,
"Expected 512-bit hex digest, got length {}",
digest.len()
);
assert!(
digest.chars().all(|c| c.is_ascii_hexdigit()),
"Digest contains non-hex characters: {digest}"
);
// Verify consistency - computing twice on the same filesystem produces the same result
let digest2 = compute_composefs_digest(path, None).unwrap();
assert_eq!(
digest, digest2,
"Digest should be consistent across multiple computations"
);
}
#[test]
fn test_compute_composefs_digest_rejects_root() {
let result = compute_composefs_digest(Utf8Path::new("/"), None);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Cannot operate on active root filesystem"),
"Unexpected error message: {err}"
);
}
}

View File

@@ -1,5 +1,6 @@
pub(crate) mod boot;
pub(crate) mod delete;
pub(crate) mod digest;
pub(crate) mod finalize;
pub(crate) mod gc;
pub(crate) mod repo;

View File

@@ -7,7 +7,6 @@ use std::fs::File;
use std::io::{BufWriter, Seek};
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::sync::Arc;
use anyhow::{anyhow, ensure, Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
@@ -32,10 +31,10 @@ use ostree_ext::ostree;
use ostree_ext::sysroot::SysrootLock;
use schemars::schema_for;
use serde::{Deserialize, Serialize};
use tempfile::tempdir_in;
use crate::bootc_composefs::delete::delete_composefs_deployment;
use crate::bootc_composefs::{
digest::{compute_composefs_digest, new_temp_composefs_repo},
finalize::{composefs_backend_finalize, get_etc_diff},
rollback::composefs_rollback,
state::composefs_usr_overlay,
@@ -48,7 +47,7 @@ use crate::podstorage::set_additional_image_store;
use crate::progress_jsonl::{ProgressWriter, RawProgressFd};
use crate::spec::Host;
use crate::spec::ImageReference;
use crate::store::{BootedOstree, ComposefsRepository, Storage};
use crate::store::{BootedOstree, Storage};
use crate::store::{BootedStorage, BootedStorageKind};
use crate::utils::sigpolicy_from_opt;
@@ -358,9 +357,20 @@ pub(crate) enum ContainerOpts {
#[clap(long)]
no_truncate: bool,
},
/// Output the bootable composefs digest.
/// Output the bootable composefs digest for a directory.
#[clap(hide = true)]
ComputeComposefsDigest {
/// Path to the filesystem root
#[clap(default_value = "/target")]
path: Utf8PathBuf,
/// Additionally generate a dumpfile written to the target path
#[clap(long)]
write_dumpfile_to: Option<Utf8PathBuf>,
},
/// Output the bootable composefs digest from container storage.
#[clap(hide = true)]
ComputeComposefsDigestFromStorage {
/// Additionally generate a dumpfile written to the target path
#[clap(long)]
write_dumpfile_to: Option<Utf8PathBuf>,
@@ -1499,21 +1509,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
Ok(())
}
ContainerOpts::ComputeComposefsDigest {
path,
write_dumpfile_to,
} => {
let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref())?;
println!("{digest}");
Ok(())
}
ContainerOpts::ComputeComposefsDigestFromStorage {
write_dumpfile_to,
image,
} => {
// Allocate a tempdir
let td = tempdir_in("/var/tmp")?;
let td = td.path();
let td = &Dir::open_ambient_dir(td, cap_std::ambient_authority())?;
td.create_dir("repo")?;
let repo = td.open_dir("repo")?;
let mut repo =
ComposefsRepository::open_path(&repo, ".").context("Init cfs repo")?;
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
repo.set_insecure(true);
let repo = &Arc::new(repo);
let (_td_guard, repo) = new_temp_composefs_repo()?;
let mut proxycfg = ImageProxyConfig::default();
@@ -1540,11 +1547,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
};
let imgref = format!("containers-storage:{image}");
let (imgid, verity) = composefs_oci::pull(repo, &imgref, None, Some(proxycfg))
let (imgid, verity) = composefs_oci::pull(&repo, &imgref, None, Some(proxycfg))
.await
.context("Pulling image")?;
let imgid = hex::encode(imgid);
let mut fs = composefs_oci::image::create_filesystem(repo, &imgid, Some(&verity))
let mut fs = composefs_oci::image::create_filesystem(&repo, &imgid, Some(&verity))
.context("Populating fs")?;
fs.transform_for_boot(&repo).context("Preparing for boot")?;
let id = fs.compute_image_id();

View File

@@ -125,6 +125,61 @@ fn test_variant_base_crosscheck() -> Result<()> {
Ok(())
}
/// Test that compute-composefs-digest works on a directory
pub(crate) fn test_compute_composefs_digest() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
// Create temp directory with test filesystem structure
let td = tempfile::tempdir()?;
let root = td.path();
// Create directories required by transform_for_boot
fs::create_dir_all(root.join("boot"))?;
fs::create_dir_all(root.join("sysroot"))?;
// Create usr/bin/hello (executable)
let usr_bin = root.join("usr/bin");
fs::create_dir_all(&usr_bin)?;
let hello_path = usr_bin.join("hello");
fs::write(&hello_path, "test\n")?;
fs::set_permissions(&hello_path, fs::Permissions::from_mode(0o755))?;
// Create etc/config (regular file)
let etc = root.join("etc");
fs::create_dir_all(&etc)?;
let config_path = etc.join("config");
fs::write(&config_path, "test\n")?;
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o644))?;
// Run bootc container compute-composefs-digest
let sh = Shell::new()?;
let path_str = root.to_str().unwrap();
let digest = cmd!(sh, "bootc container compute-composefs-digest {path_str}").read()?;
let digest = digest.trim();
// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
assert_eq!(
digest.len(),
128,
"Expected 512-bit hex digest, got length {}",
digest.len()
);
assert!(
digest.chars().all(|c| c.is_ascii_hexdigit()),
"Digest contains non-hex characters: {digest}"
);
// Verify consistency - running the command twice produces the same result
let digest2 = cmd!(sh, "bootc container compute-composefs-digest {path_str}").read()?;
assert_eq!(
digest,
digest2.trim(),
"Digest should be consistent across multiple invocations"
);
Ok(())
}
/// Tests that should be run in a default container image.
#[context("Container tests")]
pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> {
@@ -136,6 +191,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> {
new_test("status", test_bootc_status),
new_test("container inspect", test_bootc_container_inspect),
new_test("system-reinstall --help", test_system_reinstall_help),
new_test("compute-composefs-digest", test_compute_composefs_digest),
];
libtest_mimic::run(&testargs, tests.into()).exit()

View File

@@ -8,4 +8,4 @@ graphroot=$(podman system info -f '{{.Store.GraphRoot}}')
# --pull=never because we don't want to pollute the output with progress and most use cases
# for this really should be operating on pre-pulled images.
exec podman run --pull=never --quiet --rm --privileged --read-only --security-opt=label=disable -v /sys:/sys:ro --net=none \
-v ${graphroot}:/run/host-container-storage:ro --tmpfs /var "$image" bootc container compute-composefs-digest
-v ${graphroot}:/run/host-container-storage:ro --tmpfs /var "$image" bootc container compute-composefs-digest-from-storage