agent_harness_adapter_core/hook/
guard.rs1use std::{fs, path::PathBuf};
8
9use crate::hash::{Fnv, hash_text};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct LoopGuard {
14 dir: PathBuf,
15}
16
17impl LoopGuard {
18 pub fn new(dir: impl Into<PathBuf>) -> Self {
22 LoopGuard { dir: dir.into() }
23 }
24
25 fn path(&self, key: &str) -> PathBuf {
28 let safe = !key.is_empty()
29 && key.len() <= 64
30 && key
31 .bytes()
32 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
33 let name = if safe {
34 key.to_string()
35 } else {
36 let mut h = Fnv::new();
37 h.update(key.as_bytes());
38 h.render().replace(':', "-")
39 };
40 self.dir.join(format!("{name}.hash"))
41 }
42
43 fn read(&self, key: &str) -> Option<String> {
45 let text = fs::read_to_string(self.path(key)).ok()?;
46 let text = text.trim().to_string();
47 (!text.is_empty()).then_some(text)
48 }
49
50 fn write(&self, key: &str, hash: &str) {
53 if fs::create_dir_all(&self.dir).is_err() {
54 return;
55 }
56 let ignore = self.dir.join(".gitignore");
57 if !ignore.exists() {
58 let _ = fs::write(&ignore, "*\n");
59 }
60 let _ = fs::write(self.path(key), format!("{hash}\n"));
61 }
62
63 pub fn clear(&self, key: &str) {
65 let _ = fs::remove_file(self.path(key));
66 }
67
68 pub fn should_block(&self, key: &str, text: &str) -> bool {
73 let hash = hash_text(text);
74 if self.read(key).as_deref() == Some(hash.as_str()) {
75 return false;
76 }
77 self.write(key, &hash);
78 true
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use proptest::prelude::*;
85
86 use super::*;
87 use crate::test_support::temp_dir;
88
89 #[test]
91 fn reads_what_it_wrote_per_key() {
92 let dir = temp_dir("guard");
93 let guard = LoopGuard::new(dir.join("cache"));
94 assert_eq!(guard.read("repo"), None);
95 guard.write("repo", "fnv1a64:01");
96 assert_eq!(guard.read("repo").as_deref(), Some("fnv1a64:01"));
97 assert_eq!(
98 fs::read_to_string(dir.join("cache/.gitignore")).unwrap(),
99 "*\n"
100 );
101 assert!(dir.join("cache/repo.hash").is_file());
102 assert_eq!(guard.read("other"), None);
104 guard.write("a/../b c", "fnv1a64:02");
105 assert_eq!(guard.read("a/../b c").as_deref(), Some("fnv1a64:02"));
106 assert!(!dir.join("b c.hash").exists());
107 guard.clear("repo");
108 assert_eq!(guard.read("repo"), None);
109 fs::remove_dir_all(&dir).unwrap();
110 }
111
112 #[test]
114 fn blocks_once_per_text() {
115 let dir = temp_dir("guard-block");
116 let guard = LoopGuard::new(&dir);
117 assert!(guard.should_block("s1", "2 errors"));
118 assert!(!guard.should_block("s1", "2 errors"));
119 assert!(!guard.should_block("s1", "2 errors\r\n".trim_end()));
120 assert!(guard.should_block("s2", "2 errors"));
121 assert!(guard.should_block("s1", "1 error"));
122 assert!(guard.should_block("s1", "2 errors"));
123 fs::remove_dir_all(&dir).unwrap();
124 }
125
126 #[test]
128 fn an_unwritable_directory_is_silent() {
129 let dir = temp_dir("guard-unwritable");
130 fs::write(dir.join("file"), "x").unwrap();
131 let guard = LoopGuard::new(dir.join("file"));
132 guard.write("k", "h");
133 assert_eq!(guard.read("k"), None);
134 assert!(guard.should_block("k", "t"));
136 assert!(guard.should_block("k", "t"));
137 fs::remove_dir_all(&dir).unwrap();
138 }
139
140 proptest! {
141 #![proptest_config(ProptestConfig::with_cases(64))]
142
143 #[test]
145 fn the_guard_blocks_once_per_text(texts in prop::collection::vec(prop::sample::select(vec!["", "1 error", "2 errors"]), 1..10)) {
146 let dir = temp_dir("guard-prop");
147 let guard = LoopGuard::new(dir.join("cache"));
148 let mut last: Option<&str> = None;
149 for text in texts {
150 let blocked = guard.should_block("session", text);
151 prop_assert_eq!(blocked, last != Some(text));
152 if blocked {
153 last = Some(text);
154 }
155 }
156 let _ = fs::remove_dir_all(&dir);
157 }
158 }
159}