Skip to content

Commit 72f1f27

Browse files
jeckersbcgwalters
authored andcommitted
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: #1862 Assisted-by: OpenCode (Claude Opus 4.5) Signed-off-by: John Eckersberg <[email protected]>
1 parent d90f019 commit 72f1f27

File tree

5 files changed

+235
-19
lines changed

5 files changed

+235
-19
lines changed
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
//! Composefs digest computation utilities.
2+
3+
use std::fs::File;
4+
use std::io::BufWriter;
5+
use std::sync::Arc;
6+
7+
use anyhow::{Context, Result};
8+
use camino::Utf8Path;
9+
use cap_std_ext::cap_std;
10+
use cap_std_ext::cap_std::fs::Dir;
11+
use composefs::dumpfile;
12+
use composefs::fsverity::FsVerityHashValue;
13+
use composefs_boot::BootOps as _;
14+
use tempfile::TempDir;
15+
16+
use crate::store::ComposefsRepository;
17+
18+
/// Creates a temporary composefs repository for computing digests.
19+
///
20+
/// Returns the TempDir guard (must be kept alive for the repo to remain valid)
21+
/// and the repository wrapped in Arc.
22+
pub(crate) fn new_temp_composefs_repo() -> Result<(TempDir, Arc<ComposefsRepository>)> {
23+
let td_guard = tempfile::tempdir_in("/var/tmp")?;
24+
let td_path = td_guard.path();
25+
let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?;
26+
27+
td_dir.create_dir("repo")?;
28+
let repo_dir = td_dir.open_dir("repo")?;
29+
let mut repo = ComposefsRepository::open_path(&repo_dir, ".").context("Init cfs repo")?;
30+
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
31+
repo.set_insecure(true);
32+
Ok((td_guard, Arc::new(repo)))
33+
}
34+
35+
/// Computes the bootable composefs digest for a filesystem at the given path.
36+
///
37+
/// This reads the filesystem from the specified path, transforms it for boot,
38+
/// and computes the composefs image ID.
39+
///
40+
/// # Arguments
41+
/// * `path` - Path to the filesystem root to compute digest for
42+
/// * `write_dumpfile_to` - Optional path to write a dumpfile
43+
///
44+
/// # Returns
45+
/// The computed digest as a 128-character hex string (SHA-512).
46+
///
47+
/// # Errors
48+
/// Returns an error if:
49+
/// * The path is "/" (cannot operate on active root filesystem)
50+
/// * The filesystem cannot be read
51+
/// * The transform or digest computation fails
52+
pub(crate) fn compute_composefs_digest(
53+
path: &Utf8Path,
54+
write_dumpfile_to: Option<&Utf8Path>,
55+
) -> Result<String> {
56+
if path.as_str() == "/" {
57+
anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead");
58+
}
59+
60+
let (_td_guard, repo) = new_temp_composefs_repo()?;
61+
62+
// Read filesystem from path, transform for boot, compute digest
63+
let mut fs =
64+
composefs::fs::read_filesystem(rustix::fs::CWD, path.as_std_path(), Some(&repo), false)?;
65+
fs.transform_for_boot(&repo).context("Preparing for boot")?;
66+
let id = fs.compute_image_id();
67+
let digest = id.to_hex();
68+
69+
if let Some(dumpfile_path) = write_dumpfile_to {
70+
let mut w = File::create(dumpfile_path)
71+
.with_context(|| format!("Opening {dumpfile_path}"))
72+
.map(BufWriter::new)?;
73+
dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
74+
}
75+
76+
Ok(digest)
77+
}
78+
79+
#[cfg(test)]
80+
mod tests {
81+
use super::*;
82+
use std::fs::{self, Permissions};
83+
use std::os::unix::fs::PermissionsExt;
84+
85+
/// Helper to create a minimal test filesystem structure
86+
fn create_test_filesystem(root: &std::path::Path) -> Result<()> {
87+
// Create directories required by transform_for_boot
88+
fs::create_dir_all(root.join("boot"))?;
89+
fs::create_dir_all(root.join("sysroot"))?;
90+
91+
// Create usr/bin directory
92+
let usr_bin = root.join("usr/bin");
93+
fs::create_dir_all(&usr_bin)?;
94+
95+
// Create usr/bin/hello with executable permissions
96+
let hello_path = usr_bin.join("hello");
97+
fs::write(&hello_path, "test\n")?;
98+
fs::set_permissions(&hello_path, Permissions::from_mode(0o755))?;
99+
100+
// Create etc directory
101+
let etc = root.join("etc");
102+
fs::create_dir_all(&etc)?;
103+
104+
// Create etc/config with regular file permissions
105+
let config_path = etc.join("config");
106+
fs::write(&config_path, "test\n")?;
107+
fs::set_permissions(&config_path, Permissions::from_mode(0o644))?;
108+
109+
Ok(())
110+
}
111+
112+
#[test]
113+
fn test_compute_composefs_digest() {
114+
// Create temp directory with test filesystem structure
115+
let td = tempfile::tempdir().unwrap();
116+
create_test_filesystem(td.path()).unwrap();
117+
118+
// Compute the digest
119+
let path = Utf8Path::from_path(td.path()).unwrap();
120+
let digest = compute_composefs_digest(path, None).unwrap();
121+
122+
// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
123+
assert_eq!(
124+
digest.len(),
125+
128,
126+
"Expected 512-bit hex digest, got length {}",
127+
digest.len()
128+
);
129+
assert!(
130+
digest.chars().all(|c| c.is_ascii_hexdigit()),
131+
"Digest contains non-hex characters: {digest}"
132+
);
133+
134+
// Verify consistency - computing twice on the same filesystem produces the same result
135+
let digest2 = compute_composefs_digest(path, None).unwrap();
136+
assert_eq!(
137+
digest, digest2,
138+
"Digest should be consistent across multiple computations"
139+
);
140+
}
141+
142+
#[test]
143+
fn test_compute_composefs_digest_rejects_root() {
144+
let result = compute_composefs_digest(Utf8Path::new("/"), None);
145+
assert!(result.is_err());
146+
let err = result.unwrap_err().to_string();
147+
assert!(
148+
err.contains("Cannot operate on active root filesystem"),
149+
"Unexpected error message: {err}"
150+
);
151+
}
152+
}

crates/lib/src/bootc_composefs/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub(crate) mod boot;
22
pub(crate) mod delete;
3+
pub(crate) mod digest;
34
pub(crate) mod finalize;
45
pub(crate) mod gc;
56
pub(crate) mod repo;

crates/lib/src/cli.rs

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use std::fs::File;
77
use std::io::{BufWriter, Seek};
88
use std::os::unix::process::CommandExt;
99
use std::process::Command;
10-
use std::sync::Arc;
1110

1211
use anyhow::{anyhow, ensure, Context, Result};
1312
use camino::{Utf8Path, Utf8PathBuf};
@@ -32,10 +31,10 @@ use ostree_ext::ostree;
3231
use ostree_ext::sysroot::SysrootLock;
3332
use schemars::schema_for;
3433
use serde::{Deserialize, Serialize};
35-
use tempfile::tempdir_in;
3634

3735
use crate::bootc_composefs::delete::delete_composefs_deployment;
3836
use crate::bootc_composefs::{
37+
digest::{compute_composefs_digest, new_temp_composefs_repo},
3938
finalize::{composefs_backend_finalize, get_etc_diff},
4039
rollback::composefs_rollback,
4140
state::composefs_usr_overlay,
@@ -48,7 +47,7 @@ use crate::podstorage::set_additional_image_store;
4847
use crate::progress_jsonl::{ProgressWriter, RawProgressFd};
4948
use crate::spec::Host;
5049
use crate::spec::ImageReference;
51-
use crate::store::{BootedOstree, ComposefsRepository, Storage};
50+
use crate::store::{BootedOstree, Storage};
5251
use crate::store::{BootedStorage, BootedStorageKind};
5352
use crate::utils::sigpolicy_from_opt;
5453

@@ -358,9 +357,20 @@ pub(crate) enum ContainerOpts {
358357
#[clap(long)]
359358
no_truncate: bool,
360359
},
361-
/// Output the bootable composefs digest.
360+
/// Output the bootable composefs digest for a directory.
362361
#[clap(hide = true)]
363362
ComputeComposefsDigest {
363+
/// Path to the filesystem root
364+
#[clap(default_value = "/target")]
365+
path: Utf8PathBuf,
366+
367+
/// Additionally generate a dumpfile written to the target path
368+
#[clap(long)]
369+
write_dumpfile_to: Option<Utf8PathBuf>,
370+
},
371+
/// Output the bootable composefs digest from container storage.
372+
#[clap(hide = true)]
373+
ComputeComposefsDigestFromStorage {
364374
/// Additionally generate a dumpfile written to the target path
365375
#[clap(long)]
366376
write_dumpfile_to: Option<Utf8PathBuf>,
@@ -1499,21 +1509,18 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
14991509
Ok(())
15001510
}
15011511
ContainerOpts::ComputeComposefsDigest {
1512+
path,
1513+
write_dumpfile_to,
1514+
} => {
1515+
let digest = compute_composefs_digest(&path, write_dumpfile_to.as_deref())?;
1516+
println!("{digest}");
1517+
Ok(())
1518+
}
1519+
ContainerOpts::ComputeComposefsDigestFromStorage {
15021520
write_dumpfile_to,
15031521
image,
15041522
} => {
1505-
// Allocate a tempdir
1506-
let td = tempdir_in("/var/tmp")?;
1507-
let td = td.path();
1508-
let td = &Dir::open_ambient_dir(td, cap_std::ambient_authority())?;
1509-
1510-
td.create_dir("repo")?;
1511-
let repo = td.open_dir("repo")?;
1512-
let mut repo =
1513-
ComposefsRepository::open_path(&repo, ".").context("Init cfs repo")?;
1514-
// We don't need to hard require verity on the *host* system, we're just computing a checksum here
1515-
repo.set_insecure(true);
1516-
let repo = &Arc::new(repo);
1523+
let (_td_guard, repo) = new_temp_composefs_repo()?;
15171524

15181525
let mut proxycfg = ImageProxyConfig::default();
15191526

@@ -1540,11 +1547,11 @@ async fn run_from_opt(opt: Opt) -> Result<()> {
15401547
};
15411548

15421549
let imgref = format!("containers-storage:{image}");
1543-
let (imgid, verity) = composefs_oci::pull(repo, &imgref, None, Some(proxycfg))
1550+
let (imgid, verity) = composefs_oci::pull(&repo, &imgref, None, Some(proxycfg))
15441551
.await
15451552
.context("Pulling image")?;
15461553
let imgid = hex::encode(imgid);
1547-
let mut fs = composefs_oci::image::create_filesystem(repo, &imgid, Some(&verity))
1554+
let mut fs = composefs_oci::image::create_filesystem(&repo, &imgid, Some(&verity))
15481555
.context("Populating fs")?;
15491556
fs.transform_for_boot(&repo).context("Preparing for boot")?;
15501557
let id = fs.compute_image_id();

crates/tests-integration/src/container.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,61 @@ fn test_variant_base_crosscheck() -> Result<()> {
125125
Ok(())
126126
}
127127

128+
/// Test that compute-composefs-digest works on a directory
129+
pub(crate) fn test_compute_composefs_digest() -> Result<()> {
130+
use std::os::unix::fs::PermissionsExt;
131+
132+
// Create temp directory with test filesystem structure
133+
let td = tempfile::tempdir()?;
134+
let root = td.path();
135+
136+
// Create directories required by transform_for_boot
137+
fs::create_dir_all(root.join("boot"))?;
138+
fs::create_dir_all(root.join("sysroot"))?;
139+
140+
// Create usr/bin/hello (executable)
141+
let usr_bin = root.join("usr/bin");
142+
fs::create_dir_all(&usr_bin)?;
143+
let hello_path = usr_bin.join("hello");
144+
fs::write(&hello_path, "test\n")?;
145+
fs::set_permissions(&hello_path, fs::Permissions::from_mode(0o755))?;
146+
147+
// Create etc/config (regular file)
148+
let etc = root.join("etc");
149+
fs::create_dir_all(&etc)?;
150+
let config_path = etc.join("config");
151+
fs::write(&config_path, "test\n")?;
152+
fs::set_permissions(&config_path, fs::Permissions::from_mode(0o644))?;
153+
154+
// Run bootc container compute-composefs-digest
155+
let sh = Shell::new()?;
156+
let path_str = root.to_str().unwrap();
157+
let digest = cmd!(sh, "bootc container compute-composefs-digest {path_str}").read()?;
158+
let digest = digest.trim();
159+
160+
// Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
161+
assert_eq!(
162+
digest.len(),
163+
128,
164+
"Expected 512-bit hex digest, got length {}",
165+
digest.len()
166+
);
167+
assert!(
168+
digest.chars().all(|c| c.is_ascii_hexdigit()),
169+
"Digest contains non-hex characters: {digest}"
170+
);
171+
172+
// Verify consistency - running the command twice produces the same result
173+
let digest2 = cmd!(sh, "bootc container compute-composefs-digest {path_str}").read()?;
174+
assert_eq!(
175+
digest,
176+
digest2.trim(),
177+
"Digest should be consistent across multiple invocations"
178+
);
179+
180+
Ok(())
181+
}
182+
128183
/// Tests that should be run in a default container image.
129184
#[context("Container tests")]
130185
pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> {
@@ -136,6 +191,7 @@ pub(crate) fn run(testargs: libtest_mimic::Arguments) -> Result<()> {
136191
new_test("status", test_bootc_status),
137192
new_test("container inspect", test_bootc_container_inspect),
138193
new_test("system-reinstall --help", test_system_reinstall_help),
194+
new_test("compute-composefs-digest", test_compute_composefs_digest),
139195
];
140196

141197
libtest_mimic::run(&testargs, tests.into()).exit()

hack/compute-composefs-digest

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

0 commit comments

Comments
 (0)