Skip to main content

secunit_core/evidence/
hasher.rs

1//! SHA-256 over files and serialized JSON, plus atomic write helpers.
2//!
3//! Hashing is integrity-critical (`docs/storage.md`); aim for
4//! 100% test coverage. The atomic-write helper writes to a sibling
5//! `.tmp` file, fsyncs, and renames into place, so a crash mid-finalize
6//! never leaves a half-written manifest.
7
8use std::fs::{self, File, OpenOptions};
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11
12use sha2::{Digest, Sha256};
13use walkdir::WalkDir;
14
15/// Compute the SHA-256 of a file's bytes. Streams 64 KiB chunks so it
16/// works on large captures without buffering.
17pub fn sha256_file(path: &Path) -> io::Result<String> {
18    let mut file = File::open(path)?;
19    let mut hasher = Sha256::new();
20    let mut buf = [0u8; 65536];
21    loop {
22        let n = file.read(&mut buf)?;
23        if n == 0 {
24            break;
25        }
26        hasher.update(&buf[..n]);
27    }
28    Ok(hex::encode(hasher.finalize()))
29}
30
31/// Compute the SHA-256 of an in-memory byte slice. Used for hashing the
32/// canonical-JSON form of a manifest before writing it to disk.
33pub fn sha256_bytes(bytes: &[u8]) -> String {
34    let mut hasher = Sha256::new();
35    hasher.update(bytes);
36    hex::encode(hasher.finalize())
37}
38
39/// Walk a directory and return `(relative_path, sha256, byte_count)` for
40/// every regular file found. Paths are returned in deterministic
41/// lexicographic order so manifests are stable across re-runs.
42///
43/// Files whose names appear in `exclude_top_level` are skipped **only at
44/// the root of the walk** — used to keep `manifest.json`, `prepare.json`,
45/// `result.json`, and the `.run-pending` sentinel out of the artifact
46/// list. A skill that writes a file with one of those names *under*
47/// `by-system/<name>/raw/` still gets it hashed; the exclusion is
48/// metadata-only, not magical.
49pub fn hash_tree(root: &Path, exclude_top_level: &[&str]) -> io::Result<Vec<HashedArtifact>> {
50    let mut entries: Vec<HashedArtifact> = Vec::new();
51    for entry in WalkDir::new(root).sort_by_file_name() {
52        let entry = entry.map_err(io::Error::other)?;
53        if !entry.file_type().is_file() {
54            continue;
55        }
56        let abs = entry.path();
57        let is_top_level = abs.parent() == Some(root);
58        if is_top_level {
59            if let Some(name) = entry.file_name().to_str() {
60                if exclude_top_level.contains(&name) {
61                    continue;
62                }
63            }
64        }
65        let rel = abs
66            .strip_prefix(root)
67            .map_err(|e| io::Error::other(format!("strip_prefix: {e}")))?
68            .to_path_buf();
69        // Force forward slashes in manifest paths regardless of host.
70        let rel_str = rel
71            .components()
72            .map(|c| c.as_os_str().to_string_lossy().into_owned())
73            .collect::<Vec<_>>()
74            .join("/");
75        let sha = sha256_file(abs)?;
76        let bytes = fs::metadata(abs)?.len();
77        entries.push(HashedArtifact {
78            path: rel_str,
79            sha256: sha,
80            bytes,
81            absolute: abs.to_path_buf(),
82        });
83    }
84    Ok(entries)
85}
86
87#[derive(Debug, Clone)]
88pub struct HashedArtifact {
89    pub path: String,
90    pub sha256: String,
91    pub bytes: u64,
92    pub absolute: PathBuf,
93}
94
95/// Atomic write: stream `bytes` into `<dest>.tmp`, fsync the file and the
96/// parent directory, then rename over `dest`. On POSIX the rename is
97/// atomic. The fsync of the parent directory is what guarantees that
98/// the rename itself survives a crash; without it, the rename's metadata
99/// can still be lost.
100pub fn atomic_write(dest: &Path, bytes: &[u8]) -> io::Result<()> {
101    let parent = dest
102        .parent()
103        .ok_or_else(|| io::Error::other("atomic_write: dest has no parent"))?;
104    let tmp = parent.join(format!(
105        ".{}.tmp",
106        dest.file_name()
107            .and_then(|s| s.to_str())
108            .unwrap_or("anonymous")
109    ));
110
111    {
112        let mut f = OpenOptions::new()
113            .write(true)
114            .create(true)
115            .truncate(true)
116            .open(&tmp)?;
117        f.write_all(bytes)?;
118        f.sync_all()?;
119    }
120    fs::rename(&tmp, dest)?;
121    // fsync the directory to durably commit the rename.
122    if let Ok(dir) = File::open(parent) {
123        let _ = dir.sync_all();
124    }
125    Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn sha256_known_vector() {
134        // SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
135        assert_eq!(
136            sha256_bytes(b"abc"),
137            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
138        );
139    }
140
141    #[test]
142    fn hash_tree_is_deterministic_and_sorted() {
143        let dir = tempfile::tempdir().unwrap();
144        fs::create_dir_all(dir.path().join("sub")).unwrap();
145        fs::write(dir.path().join("b.txt"), b"two").unwrap();
146        fs::write(dir.path().join("a.txt"), b"one").unwrap();
147        fs::write(dir.path().join("sub/c.txt"), b"three").unwrap();
148        // Excluded file at the top level should be skipped.
149        fs::write(dir.path().join("manifest.json"), b"meta").unwrap();
150
151        let result = hash_tree(dir.path(), &["manifest.json"]).unwrap();
152        let paths: Vec<_> = result.iter().map(|h| h.path.clone()).collect();
153        assert_eq!(paths, vec!["a.txt", "b.txt", "sub/c.txt"]);
154        assert_eq!(result[0].sha256, sha256_bytes(b"one"));
155        assert_eq!(result[1].sha256, sha256_bytes(b"two"));
156        assert_eq!(result[2].sha256, sha256_bytes(b"three"));
157        assert_eq!(result[0].bytes, 3);
158        assert_eq!(result[2].bytes, 5);
159    }
160
161    #[test]
162    fn hash_tree_excludes_only_at_top_level() {
163        // A file with the same name as a metadata exclusion, but nested
164        // under by-system/, must still be hashed — the exclusion is for
165        // run-dir metadata only, not magical anywhere it appears.
166        let dir = tempfile::tempdir().unwrap();
167        fs::create_dir_all(dir.path().join("by-system/foo/raw")).unwrap();
168        fs::write(dir.path().join("manifest.json"), b"top-meta").unwrap();
169        fs::write(
170            dir.path().join("by-system/foo/raw/manifest.json"),
171            b"nested",
172        )
173        .unwrap();
174
175        let result = hash_tree(dir.path(), &["manifest.json"]).unwrap();
176        let paths: Vec<_> = result.iter().map(|h| h.path.clone()).collect();
177        assert_eq!(paths, vec!["by-system/foo/raw/manifest.json"]);
178        assert_eq!(result[0].sha256, sha256_bytes(b"nested"));
179    }
180
181    #[test]
182    fn atomic_write_replaces_existing_file() {
183        let dir = tempfile::tempdir().unwrap();
184        let dest = dir.path().join("out.json");
185        atomic_write(&dest, b"first").unwrap();
186        assert_eq!(fs::read(&dest).unwrap(), b"first");
187        atomic_write(&dest, b"second").unwrap();
188        assert_eq!(fs::read(&dest).unwrap(), b"second");
189        // No leftover .tmp.
190        let leftovers: Vec<_> = fs::read_dir(dir.path())
191            .unwrap()
192            .filter_map(Result::ok)
193            .filter(|e| {
194                e.file_name()
195                    .to_str()
196                    .map(|s| s.ends_with(".tmp"))
197                    .unwrap_or(false)
198            })
199            .collect();
200        assert!(leftovers.is_empty(), "leftover tmp files: {leftovers:?}");
201    }
202}