1use serde::{Deserialize, Serialize};
28use sha2::{Digest, Sha256};
29use std::collections::BTreeMap;
30use std::path::{Path, PathBuf};
31
32pub type Digest64 = Option<String>;
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct SealSet {
42 pub entries: BTreeMap<PathBuf, Digest64>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct SealBreach {
49 pub path: PathBuf,
50 pub before: Digest64,
51 pub after: Digest64,
52}
53
54impl SealBreach {
55 pub fn describe(&self) -> String {
57 let what = match (&self.before, &self.after) {
58 (Some(_), Some(_)) => "was modified",
59 (None, Some(_)) => "was created",
60 (Some(_), None) => "was deleted",
61 (None, None) => "changed", };
63 format!("{} {}", self.path.display(), what)
64 }
65}
66
67impl SealSet {
68 pub fn capture<I, P>(root: &Path, paths: I) -> Self
74 where
75 I: IntoIterator<Item = P>,
76 P: AsRef<Path>,
77 {
78 let mut entries = BTreeMap::new();
79 for p in paths {
80 let rel = p.as_ref().to_path_buf();
81 let full = if rel.is_absolute() {
82 rel.clone()
83 } else {
84 root.join(&rel)
85 };
86 entries.insert(rel, digest_file(&full));
87 }
88 Self { entries }
89 }
90
91 pub fn is_empty(&self) -> bool {
92 self.entries.is_empty()
93 }
94
95 pub fn breaches(&self, now: &SealSet) -> Vec<SealBreach> {
101 let mut out = Vec::new();
102 let mut keys: Vec<&PathBuf> = self.entries.keys().chain(now.entries.keys()).collect();
103 keys.sort();
104 keys.dedup();
105 for k in keys {
106 let before = self.entries.get(k).cloned().flatten();
107 let after = now.entries.get(k).cloned().flatten();
108 if before != after {
109 out.push(SealBreach {
110 path: k.clone(),
111 before,
112 after,
113 });
114 }
115 }
116 out
117 }
118}
119
120fn digest_file(path: &Path) -> Digest64 {
122 let bytes = std::fs::read(path).ok()?;
123 let mut h = Sha256::new();
124 h.update(&bytes);
125 Some(format!("{:x}", h.finalize()))
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 fn tmp() -> PathBuf {
133 let d = std::env::temp_dir().join(format!(
134 "harness-seal-{}-{:?}",
135 std::process::id(),
136 std::thread::current().id()
137 ));
138 std::fs::create_dir_all(&d).unwrap();
139 d
140 }
141
142 #[test]
143 fn an_untouched_contract_is_not_a_breach() {
144 let d = tmp();
145 std::fs::write(d.join("check.sh"), "exit 0").unwrap();
146 let a = SealSet::capture(&d, ["check.sh"]);
147 let b = SealSet::capture(&d, ["check.sh"]);
148 assert!(a.breaches(&b).is_empty());
149 let _ = std::fs::remove_dir_all(&d);
150 }
151
152 #[test]
153 fn loosening_the_test_is_caught() {
154 let d = tmp();
157 let f = d.join("expected.txt");
158 std::fs::write(&f, "42").unwrap();
159 let before = SealSet::capture(&d, ["expected.txt"]);
160 std::fs::write(&f, "any").unwrap();
161 let after = SealSet::capture(&d, ["expected.txt"]);
162
163 let b = before.breaches(&after);
164 assert_eq!(b.len(), 1);
165 assert_eq!(b[0].path, PathBuf::from("expected.txt"));
166 assert!(b[0].describe().contains("was modified"));
167 let _ = std::fs::remove_dir_all(&d);
168 }
169
170 #[test]
171 fn deleting_the_contract_is_a_breach_not_a_pass() {
172 let d = tmp();
175 let f = d.join("gone.txt");
176 std::fs::write(&f, "contract").unwrap();
177 let before = SealSet::capture(&d, ["gone.txt"]);
178 std::fs::remove_file(&f).unwrap();
179 let after = SealSet::capture(&d, ["gone.txt"]);
180
181 let b = before.breaches(&after);
182 assert_eq!(b.len(), 1);
183 assert!(b[0].describe().contains("was deleted"));
184 let _ = std::fs::remove_dir_all(&d);
185 }
186
187 #[test]
188 fn creating_a_contract_that_was_absent_is_a_breach() {
189 let d = tmp();
190 let before = SealSet::capture(&d, ["appears.txt"]);
191 std::fs::write(d.join("appears.txt"), "now here").unwrap();
192 let after = SealSet::capture(&d, ["appears.txt"]);
193 let b = before.breaches(&after);
194 assert_eq!(b.len(), 1);
195 assert!(b[0].describe().contains("was created"));
196 let _ = std::fs::remove_dir_all(&d);
197 }
198
199 #[test]
200 fn a_file_that_never_existed_is_not_a_breach() {
201 let d = tmp();
202 let a = SealSet::capture(&d, ["nope.txt"]);
203 let b = SealSet::capture(&d, ["nope.txt"]);
204 assert!(a.breaches(&b).is_empty(), "absent twice is consistent");
205 let _ = std::fs::remove_dir_all(&d);
206 }
207
208 #[test]
209 fn the_digest_is_of_content_not_of_the_path() {
210 let d = tmp();
213 std::fs::write(d.join("a.txt"), "same").unwrap();
214 std::fs::write(d.join("b.txt"), "same").unwrap();
215 let s = SealSet::capture(&d, ["a.txt", "b.txt"]);
216 let vals: Vec<_> = s.entries.values().cloned().collect();
217 assert_eq!(vals[0], vals[1]);
218 assert!(vals[0].is_some());
219 let _ = std::fs::remove_dir_all(&d);
220 }
221}