bootc_lib/bootc_composefs/
digest.rs1use 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#[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 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#[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 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 fn create_test_filesystem(root: &std::path::Path) -> Result<()> {
115 fs::create_dir_all(root.join("boot"))?;
117 fs::create_dir_all(root.join("sysroot"))?;
118
119 let usr_bin = root.join("usr/bin");
121 fs::create_dir_all(&usr_bin)?;
122
123 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 let etc = root.join("etc");
130 fs::create_dir_all(&etc)?;
131
132 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 let td = tempfile::tempdir().unwrap();
144 create_test_filesystem(td.path()).unwrap();
145
146 let path = Utf8Path::from_path(td.path()).unwrap();
148 let digest = compute_composefs_digest(path, FormatVersion::V2, None)
149 .await
150 .unwrap();
151
152 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 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}