bootc_lib/bootc_composefs/
digest.rs

1//! Composefs digest computation utilities.
2
3use std::fs::File;
4use std::io::BufWriter;
5use std::sync::Arc;
6
7use anyhow::{Context, Result};
8use camino::Utf8Path;
9use cap_std_ext::cap_std;
10use cap_std_ext::cap_std::fs::Dir;
11use composefs::dumpfile;
12use composefs::fsverity::FsVerityHashValue;
13use composefs_boot::BootOps as _;
14use tempfile::TempDir;
15
16use 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.
22pub(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
52pub(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_container_root(rustix::fs::CWD, path.as_std_path(), Some(&repo))?;
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)]
80mod 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}