1use crate::canonical::compute_id;
4use crate::store::Store;
5use crate::tick::{Check, Ground, Liveness, Tick};
6use std::path::Path;
7
8pub struct GuardArgs {
9 pub selector: String,
10 pub id: String,
11 pub target: Option<String>, pub counter_test: String,
13 pub platforms: Vec<String>,
14 pub triggered_by: Vec<String>,
15 pub surfaces: Vec<String>,
16 pub verified_at_sha: Option<String>,
17 pub blame: Option<String>,
18 pub authority: Option<String>,
19}
20
21fn resolve_target(grounds: &[Ground], target: &Option<String>) -> Result<usize, String> {
22 let unbound: Vec<usize> = grounds
23 .iter()
24 .enumerate()
25 .filter(|(_, g)| g.check.is_none())
26 .map(|(i, _)| i)
27 .collect();
28 match target {
29 None => match unbound.as_slice() {
30 [one] => Ok(*one),
31 [] => Err("no unbound ground to guard".into()),
32 _ => Err("more than one unbound ground — name the target (claim or index)".into()),
33 },
34 Some(t) => {
35 if let Ok(idx) = t.parse::<usize>() {
36 if idx < grounds.len() {
37 return Ok(idx);
38 }
39 return Err(format!("ground index {idx} out of range"));
40 }
41 let matches: Vec<usize> = grounds
42 .iter()
43 .enumerate()
44 .filter(|(_, g)| g.claim == *t)
45 .map(|(i, _)| i)
46 .collect();
47 match matches.as_slice() {
48 [one] => Ok(*one),
49 [] => Err(format!("no ground with claim {t:?}")),
50 _ => Err(format!("ambiguous: multiple grounds with claim {t:?}")),
51 }
52 }
53 }
54}
55
56pub fn run(repo: &Path, a: GuardArgs) -> Result<Tick, String> {
57 let store = Store::at(repo);
58 let parent = store
59 .read_tick(&a.id)
60 .map_err(|e| format!("{e}"))?
61 .ok_or(format!("no tick with id {}", a.id))?;
62 let head = store
63 .read_head()
64 .map_err(|e| format!("reading HEAD: {e}"))?;
65 if a.id != head {
66 return Err(format!(
67 "guard can only amend the current HEAD decision; {} is not HEAD ({})",
68 a.id, head
69 ));
70 }
71 let idx = resolve_target(&parent.grounds, &a.target)?;
72 let g = &parent.grounds[idx];
73 if let Some(Check::Person { .. }) = g.check {
75 return Err("a human-rechecked ground cannot carry a test (R2 hard error)".into());
76 }
77 if let Some(val) = &a.authority {
80 crate::capture::validate_authority(val)?;
81 }
82 if g.supports.starts_with("rejected:") && a.authority.as_deref() != Some("user-ruled") {
88 return Err(
89 "a rejected road can carry a tripwire test only when guarded with --authority user-ruled"
90 .into(),
91 );
92 }
93 if g.check.is_some() {
94 return Err("ground already has a check".into());
95 }
96 if a.counter_test.trim().is_empty() {
97 return Err("a test binding requires a counter-test (no vacuous binding)".into());
98 }
99 if a.platforms.is_empty() || a.triggered_by.is_empty() || a.surfaces.is_empty() {
100 return Err(
101 "a test binding requires at least one platform, triggered-by, and surface".into(),
102 );
103 }
104 let verified_at_sha = crate::capture::resolve_sha(repo, &a.verified_at_sha)?;
105 let blame = crate::capture::resolve_blame(repo, a.blame)?;
106
107 let mut grounds = parent.grounds.clone();
108 grounds[idx] = Ground {
109 claim: grounds[idx].claim.clone(),
110 supports: grounds[idx].supports.clone(),
111 check: Some(Check::Test {
112 reference: a.selector,
113 verified_at_sha,
114 counter_test: Some(a.counter_test),
115 liveness: Liveness {
116 platforms: a.platforms,
117 triggered_by: a.triggered_by,
118 surfaces: a.surfaces,
119 },
120 }),
121 };
122 let held_since = time::OffsetDateTime::now_utc()
123 .format(&time::format_description::well_known::Rfc3339)
124 .map_err(|e| format!("timestamp: {e}"))?;
125 let mut child = Tick {
126 id: String::new(),
127 parent_id: parent.id.clone(),
128 observe: parent.observe.clone(),
129 decision: parent.decision.clone(),
130 grounds,
131 status: "live".into(),
132 held_since,
133 blame,
134 authority: a.authority,
135 jurisdiction: parent.jurisdiction.clone(), source_ref: parent.source_ref.clone(), provenance: None,
140 corrects: None,
141 };
142 child.id = compute_id(&child);
143 store
144 .write_tick(&child)
145 .map_err(|e| format!("writing tick: {e}"))?;
146 Ok(child)
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 fn repo_with_unbound() -> (std::path::PathBuf, String) {
154 use std::sync::atomic::{AtomicU64, Ordering};
155 static N: AtomicU64 = AtomicU64::new(0);
156 let p = std::env::temp_dir().join(format!(
157 "ev-guard-{}-{}",
158 std::process::id(),
159 N.fetch_add(1, Ordering::Relaxed)
160 ));
161 let _ = std::fs::remove_dir_all(&p);
162 std::fs::create_dir_all(&p).unwrap();
163 Store::at(&p).init().unwrap();
164 let args: Vec<String> = [
165 "--assume",
166 "schema stays frozen",
167 "--assume",
168 "team ok",
169 "--revisit",
170 "Q3",
171 "--blame",
172 "Wang Yu",
173 ]
174 .iter()
175 .map(|x| x.to_string())
176 .collect();
177 let t = crate::capture::run(&p, Some("build our own retrieval"), &args).unwrap();
178 (p, t.id)
179 }
180 fn args(selector: &str, id: &str, target: Option<&str>) -> GuardArgs {
181 GuardArgs {
182 selector: selector.into(),
183 id: id.into(),
184 target: target.map(|s| s.into()),
185 counter_test: "pytest x::counter".into(),
186 platforms: vec!["linux-ci".into()],
187 triggered_by: vec!["f".into()],
188 surfaces: vec!["s".into()],
189 verified_at_sha: Some("d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into()),
190 blame: Some("Wang Yu".into()),
191 authority: None,
192 }
193 }
194
195 #[test]
196 fn guard_should_bind_a_named_unbound_ground_and_write_a_child_when_the_target_is_named() {
197 let (p, id) = repo_with_unbound();
199
200 let child = run(
202 &p,
203 args(
204 "pytest tests/test_schema_frozen.py",
205 &id,
206 Some("schema stays frozen"),
207 ),
208 )
209 .expect("ok");
210
211 assert_eq!(child.parent_id, id);
213 let i = child
214 .grounds
215 .iter()
216 .position(|g| g.claim == "schema stays frozen")
217 .unwrap();
218 assert!(matches!(child.grounds[i].check, Some(Check::Test { .. })));
219 }
220
221 #[test]
222 fn guard_should_still_error_without_a_counter_test() {
223 let (p, id) = repo_with_unbound();
227 let mut a = args("pytest x", &id, Some("schema stays frozen"));
228 a.counter_test = " ".into(); let e = run(&p, a);
232
233 assert!(e.is_err());
235 }
236
237 #[test]
238 fn guard_should_refuse_the_target_when_the_ground_is_human_rechecked() {
239 let (p, id) = repo_with_unbound();
241
242 let e = run(&p, args("pytest x", &id, Some("team ok")));
244
245 assert!(e.is_err());
247 }
248
249 #[test]
250 fn guard_should_require_a_target_when_more_than_one_ground_is_unbound() {
251 let (p, _id) = repo_with_unbound();
253 let t2 = crate::capture::run(
254 &p,
255 Some("d2"),
256 &["--assume", "a", "--assume", "b", "--blame", "Wang Yu"]
257 .iter()
258 .map(|x| x.to_string())
259 .collect::<Vec<_>>(),
260 )
261 .unwrap();
262
263 let e = run(&p, args("pytest x", &t2.id, None));
265
266 assert!(e.is_err());
268 }
269
270 #[test]
271 fn guard_should_refuse_the_target_when_it_is_not_head() {
272 let p = repo_with_unbound().0;
274 let t1 = crate::capture::run(
275 &p,
276 Some("d1"),
277 &["--assume", "a", "--blame", "Wang Yu"]
278 .iter()
279 .map(|x| x.to_string())
280 .collect::<Vec<_>>(),
281 )
282 .unwrap();
283 let _t2 = crate::capture::run(
284 &p,
285 Some("d2"),
286 &["--assume", "b", "--blame", "Wang Yu"]
287 .iter()
288 .map(|x| x.to_string())
289 .collect::<Vec<_>>(),
290 )
291 .unwrap();
292
293 let e = run(&p, args("pytest x", &t1.id, Some("a")));
295
296 assert!(e.is_err());
298 }
299
300 #[test]
301 fn guard_should_refuse_a_rejected_road_tripwire_when_authority_is_absent() {
302 let p = repo_with_unbound().0;
304 let t = crate::capture::run(
305 &p,
306 Some("d"),
307 &["--reject", "x: y", "--blame", "Wang Yu"]
308 .iter()
309 .map(|x| x.to_string())
310 .collect::<Vec<_>>(),
311 )
312 .unwrap();
313
314 let e = run(&p, args("pytest x", &t.id, Some("y")));
316
317 assert!(e.is_err());
319 }
320
321 #[test]
322 fn guard_should_bind_a_tripwire_to_a_rejected_road_when_authority_is_user_ruled() {
323 let p = repo_with_unbound().0;
325 let t = crate::capture::run(
326 &p,
327 Some("d"),
328 &["--reject", "x: y", "--blame", "Wang Yu"]
329 .iter()
330 .map(|x| x.to_string())
331 .collect::<Vec<_>>(),
332 )
333 .unwrap();
334
335 let mut a = args("pytest x", &t.id, Some("y"));
337 a.authority = Some("user-ruled".into());
338 let child = run(&p, a).expect("a user-ruled rejected-road tripwire binds");
339
340 assert_eq!(child.parent_id, t.id);
342 let g = child
343 .grounds
344 .iter()
345 .find(|g| g.supports.starts_with("rejected:"))
346 .expect("a rejected road");
347 assert!(matches!(g.check, Some(Check::Test { .. })));
348 assert_eq!(child.provenance, None);
350 }
351}