1use sha2::{Digest, Sha256};
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct FileSnapshot {
9 pub path: PathBuf,
10 pub bytes: Vec<u8>,
11 pub digest: String,
12}
13
14impl FileSnapshot {
15 pub fn from_bytes(path: impl Into<PathBuf>, bytes: Vec<u8>) -> Self {
16 let digest = digest_bytes(&bytes);
17 Self {
18 path: path.into(),
19 bytes,
20 digest,
21 }
22 }
23
24 pub fn from_path(path: &Path) -> std::io::Result<Self> {
25 let bytes = std::fs::read(path)?;
26 Ok(Self::from_bytes(path, bytes))
27 }
28}
29
30#[derive(Debug, Clone, Default)]
31pub struct SnapshotStore {
32 by_path: HashMap<PathBuf, FileSnapshot>,
33}
34
35impl SnapshotStore {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn snapshot_file(&mut self, path: &Path) -> Option<FileSnapshot> {
41 let snap = FileSnapshot::from_path(path).ok()?;
42 self.by_path.insert(snap.path.clone(), snap.clone());
43 Some(snap)
44 }
45
46 pub fn snapshot_bytes(&mut self, path: impl Into<PathBuf>, bytes: Vec<u8>) -> FileSnapshot {
47 let snap = FileSnapshot::from_bytes(path, bytes);
48 self.by_path.insert(snap.path.clone(), snap.clone());
49 snap
50 }
51
52 pub fn get(&self, path: &Path) -> Option<&FileSnapshot> {
53 self.by_path.get(path)
54 }
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct FileVersionGuard {
59 observed: HashMap<PathBuf, String>,
60}
61
62impl FileVersionGuard {
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 pub fn observe(&mut self, path: impl Into<PathBuf>, bytes: &[u8]) {
68 self.observed.insert(path.into(), digest_bytes(bytes));
69 }
70
71 pub fn observe_path(&mut self, path: &Path) -> std::io::Result<()> {
72 let bytes = std::fs::read(path)?;
73 self.observe(path, &bytes);
74 Ok(())
75 }
76
77 pub fn check(&self, path: &Path) -> Result<(), String> {
78 let Some(expected) = self.observed.get(path) else {
79 return Ok(());
80 };
81 let current = match std::fs::read(path) {
82 Ok(bytes) => digest_bytes(&bytes),
83 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
84 return Err(format!("stale observed file {} (missing)", path.display()));
85 }
86 Err(e) => return Err(format!("stale observed file {}: {e}", path.display())),
87 };
88 if ¤t != expected {
89 return Err(format!(
90 "stale observed file {} (digest changed)",
91 path.display()
92 ));
93 }
94 Ok(())
95 }
96}
97
98pub fn digest_bytes(bytes: &[u8]) -> String {
99 Sha256::digest(bytes)
100 .iter()
101 .map(|b| format!("{b:02x}"))
102 .collect()
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn snapshot_round_trips_bytes() {
111 let mut store = SnapshotStore::new();
112 let snap = store.snapshot_bytes("src/lib.rs", b"hello".to_vec());
113 assert_eq!(snap.bytes, b"hello");
114 assert_eq!(
115 store.get(Path::new("src/lib.rs")).unwrap().digest,
116 snap.digest
117 );
118 }
119
120 #[test]
121 fn version_guard_allows_unobserved_and_matching() {
122 let dir = tempfile::tempdir().unwrap();
123 let path = dir.path().join("f.txt");
124 std::fs::write(&path, b"a").unwrap();
125 let mut guard = FileVersionGuard::new();
126 assert!(guard.check(&path).is_ok());
127 guard.observe(&path, b"a");
128 assert!(guard.check(&path).is_ok());
129 }
130
131 #[test]
132 fn version_guard_fail_closed_on_stale() {
133 let dir = tempfile::tempdir().unwrap();
134 let path = dir.path().join("f.txt");
135 std::fs::write(&path, b"a").unwrap();
136 let mut guard = FileVersionGuard::new();
137 guard.observe_path(&path).unwrap();
138 std::fs::write(&path, b"b").unwrap();
139 assert!(guard.check(&path).unwrap_err().contains("stale"));
140 }
141}