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::{Component, Path};
9
10pub const ROOTFS_ATTESTATION_FILE: &str = ".nucleus-rootfs-sha256";
11pub const ROOTFS_STORE_PATHS_FILE: &str = ".nucleus-rootfs-store-paths";
12const NIX_STORE_HASH_LEN: usize = 32;
13const NIX_STORE_HASH_ALPHABET: &[u8] = b"0123456789abcdfghijklmnpqrsvwxyz";
14
15pub type DirectoryManifest = BTreeMap<String, String>;
16
17#[derive(Clone, Copy)]
18enum ScanMode {
19 Context,
20 Rootfs,
21}
22
23pub fn snapshot_context_dir(root: &Path) -> Result<DirectoryManifest> {
24 let mut manifest = BTreeMap::new();
25 scan_dir(root, root, ScanMode::Context, &mut manifest)?;
26 Ok(manifest)
27}
28
29pub fn verify_context_integrity(source: &Path, dest: &Path) -> Result<()> {
30 let expected = snapshot_context_dir(source)?;
31 verify_context_manifest(&expected, dest)
32}
33
34pub fn verify_context_manifest(expected: &DirectoryManifest, dest: &Path) -> Result<()> {
35 let actual = snapshot_context_dir(dest)?;
36 compare_manifests(expected, &actual, "context")
37}
38
39pub fn verify_rootfs_attestation(root: &Path) -> Result<()> {
40 let manifest_path = root.join(ROOTFS_ATTESTATION_FILE);
41 if !manifest_path.exists() {
42 return Err(NucleusError::FilesystemError(format!(
43 "Rootfs attestation requested but manifest is missing: {:?}",
44 manifest_path
45 )));
46 }
47
48 let expected = read_manifest_file(&manifest_path)?;
49 let mut actual = BTreeMap::new();
50 scan_dir(root, root, ScanMode::Rootfs, &mut actual)?;
51 compare_manifests(&expected, &actual, "rootfs")
52}
53
54pub fn read_rootfs_attestation(root: &Path) -> Result<DirectoryManifest> {
55 read_manifest_file(&root.join(ROOTFS_ATTESTATION_FILE))
56}
57
58pub fn is_immediate_nix_store_object_path(path: &Path) -> bool {
59 immediate_nix_store_object_name(path).is_some()
60}
61
62fn immediate_nix_store_object_name(path: &Path) -> Option<&OsStr> {
63 let (store_name, has_trailing_components) = nix_store_object_name(path)?;
64 if has_trailing_components || !is_valid_nix_store_object_name(store_name) {
65 return None;
66 }
67 Some(store_name)
68}
69
70fn nix_store_object_name(path: &Path) -> Option<(&OsStr, bool)> {
71 let mut components = path.components();
72 if components.next() != Some(Component::RootDir) {
73 return None;
74 }
75 match components.next() {
76 Some(Component::Normal(component)) if component == OsStr::new("nix") => {}
77 _ => return None,
78 }
79 match components.next() {
80 Some(Component::Normal(component)) if component == OsStr::new("store") => {}
81 _ => return None,
82 }
83 let store_name = match components.next() {
84 Some(Component::Normal(component)) => component,
85 _ => return None,
86 };
87 Some((store_name, components.next().is_some()))
88}
89
90fn is_valid_nix_store_object_name(name: &OsStr) -> bool {
91 let Some(name) = name.to_str() else {
92 return false;
93 };
94 let Some((hash, package_name)) = name.split_once('-') else {
95 return false;
96 };
97
98 hash.len() == NIX_STORE_HASH_LEN
99 && !package_name.is_empty()
100 && package_name != "."
101 && package_name != ".."
102 && hash
103 .bytes()
104 .all(|byte| NIX_STORE_HASH_ALPHABET.contains(&byte))
105 && package_name.bytes().all(is_valid_nix_store_name_byte)
106}
107
108fn is_valid_nix_store_name_byte(byte: u8) -> bool {
109 byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.' | b'_' | b'?' | b'=')
110}
111
112fn read_manifest_file(path: &Path) -> Result<DirectoryManifest> {
113 let content = fs::read_to_string(path).map_err(|e| {
114 NucleusError::FilesystemError(format!("Failed to read manifest {:?}: {}", path, e))
115 })?;
116
117 let mut manifest = BTreeMap::new();
118 for (line_no, line) in content.lines().enumerate() {
119 if line.trim().is_empty() {
120 continue;
121 }
122 let Some((digest, rel_path)) = line.split_once('\t') else {
123 return Err(NucleusError::FilesystemError(format!(
124 "Invalid attestation line {} in {:?}: expected '<sha256>\\t<path>'",
125 line_no + 1,
126 path
127 )));
128 };
129 manifest.insert(rel_path.to_string(), digest.to_string());
130 }
131
132 Ok(manifest)
133}
134
135fn compare_manifests(
136 expected: &DirectoryManifest,
137 actual: &DirectoryManifest,
138 label: &str,
139) -> Result<()> {
140 if expected == actual {
141 return Ok(());
142 }
143
144 let mut missing = Vec::new();
145 let mut mismatched = Vec::new();
146 let mut extra = Vec::new();
147
148 for (path, digest) in expected {
149 match actual.get(path) {
150 Some(actual_digest) if actual_digest == digest => {}
151 Some(actual_digest) => mismatched.push(format!(
152 "{} (expected {}, got {})",
153 path, digest, actual_digest
154 )),
155 None => missing.push(path.clone()),
156 }
157 }
158
159 for path in actual.keys() {
160 if !expected.contains_key(path) {
161 extra.push(path.clone());
162 }
163 }
164
165 let mut details = Vec::new();
166 if !missing.is_empty() {
167 details.push(format!("missing: {}", summarize(&missing)));
168 }
169 if !mismatched.is_empty() {
170 details.push(format!("mismatched: {}", summarize(&mismatched)));
171 }
172 if !extra.is_empty() {
173 details.push(format!("extra: {}", summarize(&extra)));
174 }
175
176 Err(NucleusError::FilesystemError(format!(
177 "{} integrity verification failed ({})",
178 label,
179 details.join("; ")
180 )))
181}
182
183fn summarize(items: &[String]) -> String {
184 const LIMIT: usize = 5;
185 if items.len() <= LIMIT {
186 items.join(", ")
187 } else {
188 format!("{}, ... ({} total)", items[..LIMIT].join(", "), items.len())
189 }
190}
191
192fn scan_dir(
193 root: &Path,
194 current: &Path,
195 mode: ScanMode,
196 manifest: &mut DirectoryManifest,
197) -> Result<()> {
198 let mut entries: Vec<_> = fs::read_dir(current)
199 .map_err(|e| {
200 NucleusError::FilesystemError(format!("Failed to read directory {:?}: {}", current, e))
201 })?
202 .collect::<std::result::Result<Vec<_>, _>>()
203 .map_err(|e| {
204 NucleusError::FilesystemError(format!("Failed to enumerate {:?}: {}", current, e))
205 })?;
206 entries.sort_by_key(|a| a.file_name());
207
208 for entry in entries {
209 let path = entry.path();
210 let name = entry.file_name();
211
212 if should_skip(&mode, &name, &path, root)? {
213 continue;
214 }
215
216 match mode {
217 ScanMode::Context => scan_context_entry(root, &path, manifest)?,
218 ScanMode::Rootfs => scan_rootfs_entry(root, &path, manifest)?,
219 }
220 }
221
222 Ok(())
223}
224
225fn should_skip(mode: &ScanMode, name: &OsStr, path: &Path, root: &Path) -> Result<bool> {
226 match mode {
227 ScanMode::Context => Ok(ContextPopulator::should_exclude_name(name)),
228 ScanMode::Rootfs => {
229 let rel = relative_path(root, path)?;
230 Ok(rel == ROOTFS_ATTESTATION_FILE)
231 }
232 }
233}
234
235fn scan_context_entry(root: &Path, path: &Path, manifest: &mut DirectoryManifest) -> Result<()> {
236 let metadata = fs::symlink_metadata(path)
237 .map_err(|e| NucleusError::FilesystemError(format!("Failed to stat {:?}: {}", path, e)))?;
238
239 if metadata.is_symlink() {
240 return Ok(());
241 }
242
243 if metadata.is_dir() {
244 scan_dir(root, path, ScanMode::Context, manifest)?;
245 return Ok(());
246 }
247
248 if metadata.is_file() {
249 manifest.insert(relative_path(root, path)?, hash_file(path)?);
250 }
251
252 Ok(())
253}
254
255fn scan_rootfs_entry(root: &Path, path: &Path, manifest: &mut DirectoryManifest) -> Result<()> {
256 let symlink_metadata = fs::symlink_metadata(path)
257 .map_err(|e| NucleusError::FilesystemError(format!("Failed to stat {:?}: {}", path, e)))?;
258 if symlink_metadata.is_symlink() {
259 validate_rootfs_symlink_target(root, path)?;
260 }
261
262 let metadata = fs::metadata(path)
263 .map_err(|e| NucleusError::FilesystemError(format!("Failed to stat {:?}: {}", path, e)))?;
264
265 if metadata.is_dir() {
266 scan_dir(root, path, ScanMode::Rootfs, manifest)?;
267 return Ok(());
268 }
269
270 if metadata.is_file() {
271 manifest.insert(relative_path(root, path)?, hash_file(path)?);
272 }
273
274 Ok(())
275}
276
277fn validate_rootfs_symlink_target(root: &Path, path: &Path) -> Result<()> {
278 let resolved = fs::canonicalize(path).map_err(|e| {
279 NucleusError::FilesystemError(format!(
280 "Failed to resolve rootfs symlink target {:?}: {}",
281 path, e
282 ))
283 })?;
284 let canonical_root = fs::canonicalize(root).map_err(|e| {
285 NucleusError::FilesystemError(format!("Failed to resolve rootfs {:?}: {}", root, e))
286 })?;
287
288 if resolved.starts_with(&canonical_root) {
289 return Ok(());
290 }
291 if let Some((store_name, _)) = nix_store_object_name(&resolved) {
292 if is_valid_nix_store_object_name(store_name) {
293 return Ok(());
294 }
295 return Err(NucleusError::FilesystemError(format!(
296 "Rootfs symlink {:?} resolves to invalid /nix/store path: {:?}",
297 path, resolved
298 )));
299 }
300
301 Err(NucleusError::FilesystemError(format!(
302 "Rootfs symlink {:?} resolves outside allowed roots: {:?}",
303 path, resolved
304 )))
305}
306
307fn relative_path(root: &Path, path: &Path) -> Result<String> {
308 let rel = path.strip_prefix(root).map_err(|e| {
309 NucleusError::FilesystemError(format!(
310 "Failed to compute relative path for {:?} under {:?}: {}",
311 path, root, e
312 ))
313 })?;
314
315 path_to_string(rel)
316}
317
318fn path_to_string(path: &Path) -> Result<String> {
319 path.to_str()
320 .map(|p| p.trim_start_matches('/').to_string())
321 .ok_or_else(|| {
322 NucleusError::FilesystemError(format!("Non-UTF-8 path in attestation: {:?}", path))
323 })
324}
325
326fn hash_file(path: &Path) -> Result<String> {
327 let file = fs::File::open(path)
328 .map_err(|e| NucleusError::FilesystemError(format!("Failed to open {:?}: {}", path, e)))?;
329 let mut reader = BufReader::new(file);
330 let mut hasher = Sha256::new();
331 let mut buf = [0u8; 8192];
332
333 loop {
334 let read = reader.read(&mut buf).map_err(|e| {
335 NucleusError::FilesystemError(format!("Failed to read {:?}: {}", path, e))
336 })?;
337 if read == 0 {
338 break;
339 }
340 hasher.update(&buf[..read]);
341 }
342
343 Ok(hex::encode(hasher.finalize()))
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn test_context_manifest_skips_symlinks_and_excluded_files() {
352 let temp = tempfile::TempDir::new().unwrap();
353 let root = temp.path();
354 fs::write(root.join("README.md"), "ok").unwrap();
355 fs::write(root.join(".env"), "secret").unwrap();
356 std::os::unix::fs::symlink(root.join("README.md"), root.join("link")).unwrap();
357
358 let manifest = snapshot_context_dir(root).unwrap();
359 assert!(manifest.contains_key("README.md"));
360 assert!(!manifest.contains_key(".env"));
361 assert!(!manifest.contains_key("link"));
362 }
363
364 #[test]
365 fn test_compare_manifest_reports_mismatch() {
366 let expected = BTreeMap::from([(String::from("a"), String::from("deadbeef"))]);
367 let actual = BTreeMap::from([(String::from("a"), String::from("cafebabe"))]);
368
369 let err = compare_manifests(&expected, &actual, "context").unwrap_err();
370 assert!(err.to_string().contains("integrity verification failed"));
371 }
372
373 #[test]
374 fn test_read_manifest_file() {
375 let temp = tempfile::TempDir::new().unwrap();
376 let path = temp.path().join("manifest");
377 fs::write(&path, "abc\tbin/tool\n").unwrap();
378
379 let manifest = read_manifest_file(&path).unwrap();
380 assert_eq!(manifest.get("bin/tool").unwrap(), "abc");
381 }
382
383 #[test]
384 fn test_immediate_nix_store_object_path_validation() {
385 let valid = Path::new("/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello-2.12.1");
386 assert!(is_immediate_nix_store_object_path(valid));
387
388 for path in [
389 "/nix/store",
390 "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello/bin",
391 "/nix/store/0123456789abcdfghijklmnpqrsvwxy-hello",
392 "/nix/store/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-hello",
393 "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-",
394 "/tmp/nix/store/0123456789abcdfghijklmnpqrsvwxyz-hello",
395 ] {
396 assert!(
397 !is_immediate_nix_store_object_path(Path::new(path)),
398 "{path} must not be accepted as an immediate Nix store object"
399 );
400 }
401 }
402
403 #[test]
404 fn test_rootfs_attestation_rejects_symlink_targets_outside_allowed_roots() {
405 let temp = tempfile::TempDir::new().unwrap();
406 let root = temp.path().join("rootfs");
407 fs::create_dir_all(root.join("bin")).unwrap();
408
409 let outside = temp.path().join("host-secret");
410 fs::write(&outside, "host-only").unwrap();
411 std::os::unix::fs::symlink(&outside, root.join("bin/tool")).unwrap();
412
413 let digest = hash_file(&outside).unwrap();
414 fs::write(
415 root.join(ROOTFS_ATTESTATION_FILE),
416 format!("{}\tbin/tool\n", digest),
417 )
418 .unwrap();
419
420 let err = verify_rootfs_attestation(&root).unwrap_err();
421 assert!(err.to_string().contains("outside allowed roots"));
422 }
423}