Skip to main content

nucleus/filesystem/
attestation.rs

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}