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