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::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}