use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub type Digest64 = Option<String>;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealSet {
pub entries: BTreeMap<PathBuf, Digest64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SealBreach {
pub path: PathBuf,
pub before: Digest64,
pub after: Digest64,
}
impl SealBreach {
pub fn describe(&self) -> String {
let what = match (&self.before, &self.after) {
(Some(_), Some(_)) => "was modified",
(None, Some(_)) => "was created",
(Some(_), None) => "was deleted",
(None, None) => "changed", };
format!("{} {}", self.path.display(), what)
}
}
impl SealSet {
pub fn capture<I, P>(root: &Path, paths: I) -> Self
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
let mut entries = BTreeMap::new();
for p in paths {
let rel = p.as_ref().to_path_buf();
let full = if rel.is_absolute() {
rel.clone()
} else {
root.join(&rel)
};
entries.insert(rel, digest_file(&full));
}
Self { entries }
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn breaches(&self, now: &SealSet) -> Vec<SealBreach> {
let mut out = Vec::new();
let mut keys: Vec<&PathBuf> = self.entries.keys().chain(now.entries.keys()).collect();
keys.sort();
keys.dedup();
for k in keys {
let before = self.entries.get(k).cloned().flatten();
let after = now.entries.get(k).cloned().flatten();
if before != after {
out.push(SealBreach {
path: k.clone(),
before,
after,
});
}
}
out
}
}
fn digest_file(path: &Path) -> Digest64 {
let bytes = std::fs::read(path).ok()?;
let mut h = Sha256::new();
h.update(&bytes);
Some(format!("{:x}", h.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> PathBuf {
let d = std::env::temp_dir().join(format!(
"harness-seal-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn an_untouched_contract_is_not_a_breach() {
let d = tmp();
std::fs::write(d.join("check.sh"), "exit 0").unwrap();
let a = SealSet::capture(&d, ["check.sh"]);
let b = SealSet::capture(&d, ["check.sh"]);
assert!(a.breaches(&b).is_empty());
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn loosening_the_test_is_caught() {
let d = tmp();
let f = d.join("expected.txt");
std::fs::write(&f, "42").unwrap();
let before = SealSet::capture(&d, ["expected.txt"]);
std::fs::write(&f, "any").unwrap();
let after = SealSet::capture(&d, ["expected.txt"]);
let b = before.breaches(&after);
assert_eq!(b.len(), 1);
assert_eq!(b[0].path, PathBuf::from("expected.txt"));
assert!(b[0].describe().contains("was modified"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn deleting_the_contract_is_a_breach_not_a_pass() {
let d = tmp();
let f = d.join("gone.txt");
std::fs::write(&f, "contract").unwrap();
let before = SealSet::capture(&d, ["gone.txt"]);
std::fs::remove_file(&f).unwrap();
let after = SealSet::capture(&d, ["gone.txt"]);
let b = before.breaches(&after);
assert_eq!(b.len(), 1);
assert!(b[0].describe().contains("was deleted"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn creating_a_contract_that_was_absent_is_a_breach() {
let d = tmp();
let before = SealSet::capture(&d, ["appears.txt"]);
std::fs::write(d.join("appears.txt"), "now here").unwrap();
let after = SealSet::capture(&d, ["appears.txt"]);
let b = before.breaches(&after);
assert_eq!(b.len(), 1);
assert!(b[0].describe().contains("was created"));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_that_never_existed_is_not_a_breach() {
let d = tmp();
let a = SealSet::capture(&d, ["nope.txt"]);
let b = SealSet::capture(&d, ["nope.txt"]);
assert!(a.breaches(&b).is_empty(), "absent twice is consistent");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn the_digest_is_of_content_not_of_the_path() {
let d = tmp();
std::fs::write(d.join("a.txt"), "same").unwrap();
std::fs::write(d.join("b.txt"), "same").unwrap();
let s = SealSet::capture(&d, ["a.txt", "b.txt"]);
let vals: Vec<_> = s.entries.values().cloned().collect();
assert_eq!(vals[0], vals[1]);
assert!(vals[0].is_some());
let _ = std::fs::remove_dir_all(&d);
}
}