1use crate::error::{NucleusError, Result};
2use crate::filesystem::ContextPopulator;
3use sha2::{Digest, Sha256};
4use std::collections::BTreeMap;
5use std::ffi::OsStr;
6use std::fs;
7use std::io::{BufReader, Read};
8use std::path::Path;
9
10pub const ROOTFS_ATTESTATION_FILE: &str = ".nucleus-rootfs-sha256";
11
12pub type DirectoryManifest = BTreeMap<String, String>;
13
14#[derive(Clone, Copy)]
15enum ScanMode {
16 Context,
17 Rootfs,
18}
19
20pub fn snapshot_context_dir(root: &Path) -> Result<DirectoryManifest> {
21 let mut manifest = BTreeMap::new();
22 scan_dir(root, root, ScanMode::Context, &mut manifest)?;
23 Ok(manifest)
24}
25
26pub fn verify_context_integrity(source: &Path, dest: &Path) -> Result<()> {
27 let expected = snapshot_context_dir(source)?;
28 verify_context_manifest(&expected, dest)
29}
30
31pub fn verify_context_manifest(expected: &DirectoryManifest, dest: &Path) -> Result<()> {
32 let actual = snapshot_context_dir(dest)?;
33 compare_manifests(expected, &actual, "context")
34}
35
36pub fn verify_rootfs_attestation(root: &Path) -> Result<()> {
37 let manifest_path = root.join(ROOTFS_ATTESTATION_FILE);
38 if !manifest_path.exists() {
39 return Err(NucleusError::FilesystemError(format!(
40 "Rootfs attestation requested but manifest is missing: {:?}",
41 manifest_path
42 )));
43 }
44
45 let expected = read_manifest_file(&manifest_path)?;
46 let mut actual = BTreeMap::new();
47 scan_dir(root, root, ScanMode::Rootfs, &mut actual)?;
48 compare_manifests(&expected, &actual, "rootfs")
49}
50
51fn read_manifest_file(path: &Path) -> Result<DirectoryManifest> {
52 let content = fs::read_to_string(path).map_err(|e| {
53 NucleusError::FilesystemError(format!("Failed to read manifest {:?}: {}", path, e))
54 })?;
55
56 let mut manifest = BTreeMap::new();
57 for (line_no, line) in content.lines().enumerate() {
58 if line.trim().is_empty() {
59 continue;
60 }
61 let Some((digest, rel_path)) = line.split_once('\t') else {
62 return Err(NucleusError::FilesystemError(format!(
63 "Invalid attestation line {} in {:?}: expected '<sha256>\\t<path>'",
64 line_no + 1,
65 path
66 )));
67 };
68 manifest.insert(rel_path.to_string(), digest.to_string());
69 }
70
71 Ok(manifest)
72}
73
74fn compare_manifests(
75 expected: &DirectoryManifest,
76 actual: &DirectoryManifest,
77 label: &str,
78) -> Result<()> {
79 if expected == actual {
80 return Ok(());
81 }
82
83 let mut missing = Vec::new();
84 let mut mismatched = Vec::new();
85 let mut extra = Vec::new();
86
87 for (path, digest) in expected {
88 match actual.get(path) {
89 Some(actual_digest) if actual_digest == digest => {}
90 Some(actual_digest) => mismatched.push(format!(
91 "{} (expected {}, got {})",
92 path, digest, actual_digest
93 )),
94 None => missing.push(path.clone()),
95 }
96 }
97
98 for path in actual.keys() {
99 if !expected.contains_key(path) {
100 extra.push(path.clone());
101 }
102 }
103
104 let mut details = Vec::new();
105 if !missing.is_empty() {
106 details.push(format!("missing: {}", summarize(&missing)));
107 }
108 if !mismatched.is_empty() {
109 details.push(format!("mismatched: {}", summarize(&mismatched)));
110 }
111 if !extra.is_empty() {
112 details.push(format!("extra: {}", summarize(&extra)));
113 }
114
115 Err(NucleusError::FilesystemError(format!(
116 "{} integrity verification failed ({})",
117 label,
118 details.join("; ")
119 )))
120}
121
122fn summarize(items: &[String]) -> String {
123 const LIMIT: usize = 5;
124 if items.len() <= LIMIT {
125 items.join(", ")
126 } else {
127 format!("{}, ... ({} total)", items[..LIMIT].join(", "), items.len())
128 }
129}
130
131fn scan_dir(
132 root: &Path,
133 current: &Path,
134 mode: ScanMode,
135 manifest: &mut DirectoryManifest,
136) -> Result<()> {
137 let mut entries: Vec<_> = fs::read_dir(current)
138 .map_err(|e| {
139 NucleusError::FilesystemError(format!("Failed to read directory {:?}: {}", current, e))
140 })?
141 .collect::<std::result::Result<Vec<_>, _>>()
142 .map_err(|e| {
143 NucleusError::FilesystemError(format!("Failed to enumerate {:?}: {}", current, e))
144 })?;
145 entries.sort_by_key(|a| a.file_name());
146
147 for entry in entries {
148 let path = entry.path();
149 let name = entry.file_name();
150
151 if should_skip(&mode, &name, &path, root)? {
152 continue;
153 }
154
155 match mode {
156 ScanMode::Context => scan_context_entry(root, &path, manifest)?,
157 ScanMode::Rootfs => scan_rootfs_entry(root, &path, manifest)?,
158 }
159 }
160
161 Ok(())
162}
163
164fn should_skip(mode: &ScanMode, name: &OsStr, path: &Path, root: &Path) -> Result<bool> {
165 match mode {
166 ScanMode::Context => Ok(ContextPopulator::should_exclude_name(name)),
167 ScanMode::Rootfs => {
168 let rel = relative_path(root, path)?;
169 Ok(rel == ROOTFS_ATTESTATION_FILE)
170 }
171 }
172}
173
174fn scan_context_entry(root: &Path, path: &Path, manifest: &mut DirectoryManifest) -> Result<()> {
175 let metadata = fs::symlink_metadata(path)
176 .map_err(|e| NucleusError::FilesystemError(format!("Failed to stat {:?}: {}", path, e)))?;
177
178 if metadata.is_symlink() {
179 return Ok(());
180 }
181
182 if metadata.is_dir() {
183 scan_dir(root, path, ScanMode::Context, manifest)?;
184 return Ok(());
185 }
186
187 if metadata.is_file() {
188 manifest.insert(relative_path(root, path)?, hash_file(path)?);
189 }
190
191 Ok(())
192}
193
194fn scan_rootfs_entry(root: &Path, path: &Path, manifest: &mut DirectoryManifest) -> Result<()> {
195 let symlink_metadata = fs::symlink_metadata(path)
196 .map_err(|e| NucleusError::FilesystemError(format!("Failed to stat {:?}: {}", path, e)))?;
197 if symlink_metadata.is_symlink() {
198 validate_rootfs_symlink_target(root, path)?;
199 }
200
201 let metadata = fs::metadata(path)
202 .map_err(|e| NucleusError::FilesystemError(format!("Failed to stat {:?}: {}", path, e)))?;
203
204 if metadata.is_dir() {
205 scan_dir(root, path, ScanMode::Rootfs, manifest)?;
206 return Ok(());
207 }
208
209 if metadata.is_file() {
210 manifest.insert(relative_path(root, path)?, hash_file(path)?);
211 }
212
213 Ok(())
214}
215
216fn validate_rootfs_symlink_target(root: &Path, path: &Path) -> Result<()> {
217 let resolved = fs::canonicalize(path).map_err(|e| {
218 NucleusError::FilesystemError(format!(
219 "Failed to resolve rootfs symlink target {:?}: {}",
220 path, e
221 ))
222 })?;
223 let canonical_root = fs::canonicalize(root).map_err(|e| {
224 NucleusError::FilesystemError(format!("Failed to resolve rootfs {:?}: {}", root, e))
225 })?;
226
227 if resolved.starts_with(&canonical_root) || resolved.starts_with("/nix/store") {
228 return Ok(());
229 }
230
231 Err(NucleusError::FilesystemError(format!(
232 "Rootfs symlink {:?} resolves outside allowed roots: {:?}",
233 path, resolved
234 )))
235}
236
237fn relative_path(root: &Path, path: &Path) -> Result<String> {
238 let rel = path.strip_prefix(root).map_err(|e| {
239 NucleusError::FilesystemError(format!(
240 "Failed to compute relative path for {:?} under {:?}: {}",
241 path, root, e
242 ))
243 })?;
244
245 path_to_string(rel)
246}
247
248fn path_to_string(path: &Path) -> Result<String> {
249 path.to_str()
250 .map(|p| p.trim_start_matches('/').to_string())
251 .ok_or_else(|| {
252 NucleusError::FilesystemError(format!("Non-UTF-8 path in attestation: {:?}", path))
253 })
254}
255
256fn hash_file(path: &Path) -> Result<String> {
257 let file = fs::File::open(path)
258 .map_err(|e| NucleusError::FilesystemError(format!("Failed to open {:?}: {}", path, e)))?;
259 let mut reader = BufReader::new(file);
260 let mut hasher = Sha256::new();
261 let mut buf = [0u8; 8192];
262
263 loop {
264 let read = reader.read(&mut buf).map_err(|e| {
265 NucleusError::FilesystemError(format!("Failed to read {:?}: {}", path, e))
266 })?;
267 if read == 0 {
268 break;
269 }
270 hasher.update(&buf[..read]);
271 }
272
273 Ok(hex::encode(hasher.finalize()))
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn test_context_manifest_skips_symlinks_and_excluded_files() {
282 let temp = tempfile::TempDir::new().unwrap();
283 let root = temp.path();
284 fs::write(root.join("README.md"), "ok").unwrap();
285 fs::write(root.join(".env"), "secret").unwrap();
286 std::os::unix::fs::symlink(root.join("README.md"), root.join("link")).unwrap();
287
288 let manifest = snapshot_context_dir(root).unwrap();
289 assert!(manifest.contains_key("README.md"));
290 assert!(!manifest.contains_key(".env"));
291 assert!(!manifest.contains_key("link"));
292 }
293
294 #[test]
295 fn test_compare_manifest_reports_mismatch() {
296 let expected = BTreeMap::from([(String::from("a"), String::from("deadbeef"))]);
297 let actual = BTreeMap::from([(String::from("a"), String::from("cafebabe"))]);
298
299 let err = compare_manifests(&expected, &actual, "context").unwrap_err();
300 assert!(err.to_string().contains("integrity verification failed"));
301 }
302
303 #[test]
304 fn test_read_manifest_file() {
305 let temp = tempfile::TempDir::new().unwrap();
306 let path = temp.path().join("manifest");
307 fs::write(&path, "abc\tbin/tool\n").unwrap();
308
309 let manifest = read_manifest_file(&path).unwrap();
310 assert_eq!(manifest.get("bin/tool").unwrap(), "abc");
311 }
312
313 #[test]
314 fn test_rootfs_attestation_rejects_symlink_targets_outside_allowed_roots() {
315 let temp = tempfile::TempDir::new().unwrap();
316 let root = temp.path().join("rootfs");
317 fs::create_dir_all(root.join("bin")).unwrap();
318
319 let outside = temp.path().join("host-secret");
320 fs::write(&outside, "host-only").unwrap();
321 std::os::unix::fs::symlink(&outside, root.join("bin/tool")).unwrap();
322
323 let digest = hash_file(&outside).unwrap();
324 fs::write(
325 root.join(ROOTFS_ATTESTATION_FILE),
326 format!("{}\tbin/tool\n", digest),
327 )
328 .unwrap();
329
330 let err = verify_rootfs_attestation(&root).unwrap_err();
331 assert!(err.to_string().contains("outside allowed roots"));
332 }
333}