From 72f1f2720d1976e752f728c4249081816092858f Mon Sep 17 00:00:00 2001 From: John Eckersberg Date: Wed, 17 Dec 2025 12:05:40 -0500 Subject: [PATCH] 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 --- crates/lib/src/bootc_composefs/digest.rs | 152 ++++++++++++++++++++++ crates/lib/src/bootc_composefs/mod.rs | 1 + crates/lib/src/cli.rs | 43 +++--- crates/tests-integration/src/container.rs | 56 ++++++++ hack/compute-composefs-digest | 2 +- 5 files changed, 235 insertions(+), 19 deletions(-) create mode 100644 crates/lib/src/bootc_composefs/digest.rs diff --git a/crates/lib/src/bootc_composefs/digest.rs b/crates/lib/src/bootc_composefs/digest.rs new file mode 100644 index 00000000..d031d527 --- /dev/null +++ b/crates/lib/src/bootc_composefs/digest.rs @@ -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)> { + 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 { + 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}" + ); + } +} diff --git a/crates/lib/src/bootc_composefs/mod.rs b/crates/lib/src/bootc_composefs/mod.rs index a9ced452..c73f6297 100644 --- a/crates/lib/src/bootc_composefs/mod.rs +++ b/crates/lib/src/bootc_composefs/mod.rs @@ -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; diff --git a/crates/lib/src/cli.rs b/crates/lib/src/cli.rs index d99388ba..22013946 100644 --- a/crates/lib/src/cli.rs +++ b/crates/lib/src/cli.rs @@ -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, + }, + /// 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, @@ -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(); diff --git a/crates/tests-integration/src/container.rs b/crates/tests-integration/src/container.rs index 3d09cadf..31f4c7f7 100644 --- a/crates/tests-integration/src/container.rs +++ b/crates/tests-integration/src/container.rs @@ -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() diff --git a/hack/compute-composefs-digest b/hack/compute-composefs-digest index 2ad66e5d..43e2c16f 100755 --- a/hack/compute-composefs-digest +++ b/hack/compute-composefs-digest @@ -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