Skip to main content

bootc_lib/bootc_composefs/
digest.rs

1//! Composefs digest computation utilities.
2
3use std::fs::File;
4use std::io::BufWriter;
5use std::os::fd::OwnedFd;
6use std::sync::Arc;
7
8use anyhow::{Context, Result};
9use camino::Utf8Path;
10use cap_std_ext::cap_std;
11use cap_std_ext::cap_std::fs::Dir;
12use composefs::dumpfile;
13use composefs::erofs::format::FormatVersion;
14use composefs::fsverity::FsVerityHashValue;
15use composefs::repository::RepositoryConfig;
16use composefs_boot::BootOps as _;
17use composefs_ctl::composefs;
18use composefs_ctl::composefs_boot;
19use tempfile::TempDir;
20
21use crate::store::ComposefsRepository;
22
23/// Creates a temporary composefs repository for computing digests.
24///
25/// The `erofs_version` controls which EROFS format the digest is computed for:
26/// use `FormatVersion::V1` to get a `composefs.digest=v1-sha512-12:<hex>` karg (V1 EROFS,
27/// C-tool compatible) or `FormatVersion::V2` for the legacy `composefs=<hex>` karg.
28///
29/// Returns the TempDir guard (must be kept alive for the repo to remain valid)
30/// and the repository wrapped in Arc.
31#[fn_error_context::context("Creating new temp composefs repo")]
32pub(crate) fn new_temp_composefs_repo(
33    erofs_version: FormatVersion,
34) -> Result<(TempDir, Arc<ComposefsRepository>)> {
35    let td_guard = tempfile::tempdir_in("/var/tmp")?;
36    let td_path = td_guard.path();
37    let td_dir = Dir::open_ambient_dir(td_path, cap_std::ambient_authority())?;
38
39    td_dir.create_dir("repo")?;
40    let repo_dir = td_dir.open_dir("repo")?;
41    // We don't need to hard require verity on the *host* system, we're just computing a checksum here
42    let mut config = RepositoryConfig::new(composefs::fsverity::Algorithm::SHA512).set_insecure();
43    config.erofs_formats = composefs::erofs::format::FormatConfig::single(erofs_version);
44    let (repo, _created) =
45        ComposefsRepository::init_path(&repo_dir, ".", config).context("Init cfs repo")?;
46    Ok((td_guard, Arc::new(repo)))
47}
48
49/// Computes the bootable composefs digest for a filesystem at the given path.
50///
51/// This reads the filesystem from the specified path, transforms it for boot,
52/// and computes the composefs image ID.
53///
54/// # Arguments
55/// * `path` - Path to the filesystem root to compute digest for
56/// * `write_dumpfile_to` - Optional path to write a dumpfile
57///
58/// # Returns
59/// The computed digest as a 128-character hex string (SHA-512).
60///
61/// # Errors
62/// Returns an error if:
63/// * The path is "/" (cannot operate on active root filesystem)
64/// * The filesystem cannot be read
65/// * The transform or digest computation fails
66#[fn_error_context::context("Computing composefs digest")]
67pub(crate) async fn compute_composefs_digest(
68    path: &Utf8Path,
69    erofs_version: FormatVersion,
70    write_dumpfile_to: Option<&Utf8Path>,
71) -> Result<String> {
72    if path.as_str() == "/" {
73        anyhow::bail!("Cannot operate on active root filesystem; mount separate target instead");
74    }
75
76    let (_td_guard, repo) = new_temp_composefs_repo(erofs_version)?;
77
78    // Read filesystem from path, transform for boot, compute digest
79    let dirfd: OwnedFd = rustix::fs::open(
80        path.as_std_path(),
81        rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
82        rustix::fs::Mode::empty(),
83    )
84    .with_context(|| format!("Opening {path}"))?;
85    let mut fs = composefs::fs::read_container_root(
86        dirfd,
87        std::path::PathBuf::from("."),
88        Some(repo.clone()),
89        &composefs::generic_tree::OciTransformOptions::default(),
90    )
91    .await
92    .context("Reading container root")?;
93    fs.transform_for_boot(&repo).context("Preparing for boot")?;
94    let id = fs.compute_image_id(erofs_version);
95    let digest = id.to_hex();
96
97    if let Some(dumpfile_path) = write_dumpfile_to {
98        let mut w = File::create(dumpfile_path)
99            .with_context(|| format!("Opening {dumpfile_path}"))
100            .map(BufWriter::new)?;
101        dumpfile::write_dumpfile(&mut w, &fs).context("Writing dumpfile")?;
102    }
103
104    Ok(digest)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::fs::{self, Permissions};
111    use std::os::unix::fs::PermissionsExt;
112
113    /// Helper to create a minimal test filesystem structure
114    fn create_test_filesystem(root: &std::path::Path) -> Result<()> {
115        // Create directories required by transform_for_boot
116        fs::create_dir_all(root.join("boot"))?;
117        fs::create_dir_all(root.join("sysroot"))?;
118
119        // Create usr/bin directory
120        let usr_bin = root.join("usr/bin");
121        fs::create_dir_all(&usr_bin)?;
122
123        // Create usr/bin/hello with executable permissions
124        let hello_path = usr_bin.join("hello");
125        fs::write(&hello_path, "test\n")?;
126        fs::set_permissions(&hello_path, Permissions::from_mode(0o755))?;
127
128        // Create etc directory
129        let etc = root.join("etc");
130        fs::create_dir_all(&etc)?;
131
132        // Create etc/config with regular file permissions
133        let config_path = etc.join("config");
134        fs::write(&config_path, "test\n")?;
135        fs::set_permissions(&config_path, Permissions::from_mode(0o644))?;
136
137        Ok(())
138    }
139
140    #[tokio::test(flavor = "multi_thread")]
141    async fn test_compute_composefs_digest() {
142        // Create temp directory with test filesystem structure
143        let td = tempfile::tempdir().unwrap();
144        create_test_filesystem(td.path()).unwrap();
145
146        // Compute the digest
147        let path = Utf8Path::from_path(td.path()).unwrap();
148        let digest = compute_composefs_digest(path, FormatVersion::V2, None)
149            .await
150            .unwrap();
151
152        // Verify it's a valid hex string of expected length (SHA-512 = 128 hex chars)
153        assert_eq!(
154            digest.len(),
155            128,
156            "Expected 512-bit hex digest, got length {}",
157            digest.len()
158        );
159        assert!(
160            digest.chars().all(|c| c.is_ascii_hexdigit()),
161            "Digest contains non-hex characters: {digest}"
162        );
163
164        // Verify consistency - computing twice on the same filesystem produces the same result
165        let digest2 = compute_composefs_digest(path, FormatVersion::V2, None)
166            .await
167            .unwrap();
168        assert_eq!(
169            digest, digest2,
170            "Digest should be consistent across multiple computations"
171        );
172    }
173
174    #[tokio::test]
175    async fn test_compute_composefs_digest_rejects_root() {
176        let result = compute_composefs_digest(Utf8Path::new("/"), FormatVersion::V2, None).await;
177        assert!(result.is_err());
178        let err = result.unwrap_err();
179        let found = err.chain().any(|e| {
180            e.to_string()
181                .contains("Cannot operate on active root filesystem")
182        });
183
184        assert!(found, "Unexpected error chain: {err:?}");
185    }
186}