secunit_core/evidence/
hasher.rs1use 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
15pub 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
31pub fn sha256_bytes(bytes: &[u8]) -> String {
34 let mut hasher = Sha256::new();
35 hasher.update(bytes);
36 hex::encode(hasher.finalize())
37}
38
39pub 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 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
95pub 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 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 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 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 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 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}