1use std::collections::BTreeMap;
20use std::path::{Path, PathBuf};
21
22use crate::allowlist;
23use crate::pattern;
24
25pub const STORE_FILE: &str = "rules.jsonc";
27
28pub fn discover_root(start: &Path) -> Option<PathBuf> {
40 let mut dir = Some(start.to_path_buf());
41 while let Some(d) = dir {
42 if d.join(".ct").is_dir() {
43 return Some(d);
44 }
45 dir = d.parent().map(Path::to_path_buf);
46 }
47 None
48}
49
50pub fn store_path(root: &Path) -> PathBuf {
52 root.join(".ct").join(STORE_FILE)
53}
54
55pub fn probe_root(store: &Path) -> PathBuf {
60 match store.parent() {
61 Some(dir) if dir.file_name().is_some_and(|n| n == ".ct") => dir
62 .parent()
63 .map(Path::to_path_buf)
64 .unwrap_or_else(|| dir.to_path_buf()),
65 Some(dir) => dir.to_path_buf(),
66 None => PathBuf::from("."),
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum Def {
76 One(String),
78 Many(Vec<String>),
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
85pub enum Severity {
86 #[default]
88 Fail,
89 Warn,
91}
92
93impl Severity {
94 pub fn parse(s: &str) -> Result<Severity, String> {
96 match s {
97 "fail" => Ok(Severity::Fail),
98 "warn" => Ok(Severity::Warn),
99 other => Err(format!("invalid severity '{other}' (use fail or warn)")),
100 }
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
107pub enum Adapter {
108 #[default]
111 Exit,
112 Empty,
116 Match {
121 ok: Option<String>,
123 err: Option<String>,
125 },
126}
127
128impl Adapter {
129 pub fn from_value(v: &serde_json::Value) -> Result<Adapter, String> {
132 match v {
133 serde_json::Value::String(s) => match s.as_str() {
134 "exit" => Ok(Adapter::Exit),
135 "empty" => Ok(Adapter::Empty),
136 other => Err(format!("invalid expect '{other}' (use exit, empty, or a matcher object)")),
137 },
138 serde_json::Value::Object(o) => {
139 let get = |k: &str| -> Result<Option<String>, String> {
140 match o.get(k) {
141 None => Ok(None),
142 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
143 Some(_) => Err(format!("expect.{k} must be a string")),
144 }
145 };
146 let ok = get("ok-match")?;
147 let err = get("err-match")?;
148 if ok.is_none() && err.is_none() {
149 return Err("expect object needs ok-match and/or err-match".to_string());
150 }
151 for key in o.keys() {
152 if key != "ok-match" && key != "err-match" {
153 return Err(format!("unknown expect key '{key}'"));
154 }
155 }
156 Ok(Adapter::Match { ok, err })
157 }
158 _ => Err("expect must be a string or a matcher object".to_string()),
159 }
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct Rule {
166 pub id: String,
168 pub question: String,
170 pub probe: Vec<String>,
172 pub why: Option<String>,
174 pub prompt: Option<String>,
178 pub tags: Vec<String>,
180 pub added: Option<String>,
182 pub timeout: Option<f64>,
184 pub pending: bool,
186 pub severity: Severity,
188 pub expect: Adapter,
190 pub network: bool,
192}
193
194#[derive(Debug, Default)]
196pub struct Store {
197 pub defs: BTreeMap<String, Def>,
199 pub rules: Vec<Rule>,
201}
202
203fn as_str(v: &serde_json::Value, what: &str) -> Result<String, String> {
204 v.as_str()
205 .map(String::from)
206 .ok_or_else(|| format!("{what} must be a string"))
207}
208
209fn as_str_list(v: &serde_json::Value, what: &str) -> Result<Vec<String>, String> {
210 v.as_array()
211 .ok_or_else(|| format!("{what} must be an array of strings"))?
212 .iter()
213 .map(|e| as_str(e, what))
214 .collect()
215}
216
217pub fn parse_store(text: &str) -> Result<Store, String> {
219 let value = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
220 .map_err(|e| format!("store parse error: {e}"))?
221 .ok_or("store is empty")?;
222 let obj = value.as_object().ok_or("store root must be an object")?;
223 for key in obj.keys() {
224 if key != "defs" && key != "rules" {
225 return Err(format!("unknown store key '{key}' (expected defs/rules)"));
226 }
227 }
228
229 let mut defs = BTreeMap::new();
230 if let Some(d) = obj.get("defs") {
231 let d = d.as_object().ok_or("defs must be an object")?;
232 for (name, val) in d {
233 let def = match val {
234 serde_json::Value::String(s) => Def::One(s.clone()),
235 serde_json::Value::Array(_) => {
236 Def::Many(as_str_list(val, &format!("def '{name}'"))?)
237 }
238 _ => return Err(format!("def '{name}' must be a string or a list of strings")),
239 };
240 defs.insert(name.clone(), def);
241 }
242 }
243
244 let mut rules = Vec::new();
245 let mut seen = std::collections::HashSet::new();
246 if let Some(r) = obj.get("rules") {
247 let arr = r.as_array().ok_or("rules must be an array")?;
248 for (i, entry) in arr.iter().enumerate() {
249 let o = entry
250 .as_object()
251 .ok_or_else(|| format!("rules[{i}] must be an object"))?;
252 let id = as_str(o.get("id").ok_or_else(|| format!("rules[{i}]: missing id"))?, "id")?;
253 if id.is_empty() || id.contains(char::is_whitespace) {
254 return Err(format!("rules[{i}]: invalid id '{id}'"));
255 }
256 if !seen.insert(id.clone()) {
257 return Err(format!("duplicate rule id '{id}'"));
258 }
259 let where_ = format!("rule '{id}'");
260 let question = as_str(
261 o.get("question")
262 .ok_or_else(|| format!("{where_}: missing question"))?,
263 "question",
264 )?;
265 let probe = as_str_list(
266 o.get("probe").ok_or_else(|| format!("{where_}: missing probe"))?,
267 "probe",
268 )?;
269 if probe.is_empty() {
270 return Err(format!("{where_}: probe must not be empty"));
271 }
272 let rule = Rule {
273 id,
274 question,
275 probe,
276 why: o.get("why").map(|v| as_str(v, "why")).transpose()?,
277 prompt: o.get("prompt").map(|v| as_str(v, "prompt")).transpose()?,
278 tags: match o.get("tags") {
279 Some(v) => as_str_list(v, "tags")?,
280 None => Vec::new(),
281 },
282 added: o.get("added").map(|v| as_str(v, "added")).transpose()?,
283 timeout: match o.get("timeout") {
284 Some(v) => Some(v.as_f64().ok_or_else(|| format!("{where_}: timeout must be a number"))?),
285 None => None,
286 },
287 pending: o.get("pending").and_then(|v| v.as_bool()).unwrap_or(false),
288 severity: match o.get("severity") {
289 Some(v) => Severity::parse(&as_str(v, "severity")?)
290 .map_err(|e| format!("{where_}: {e}"))?,
291 None => Severity::Fail,
292 },
293 expect: match o.get("expect") {
294 Some(v) => Adapter::from_value(v).map_err(|e| format!("{where_}: {e}"))?,
295 None => Adapter::Exit,
296 },
297 network: o.get("network").and_then(|v| v.as_bool()).unwrap_or(false),
298 };
299 rules.push(rule);
300 }
301 }
302 Ok(Store { defs, rules })
303}
304
305pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
332 let mut out = Vec::with_capacity(argv.len());
333 for element in argv {
334 if let Some(name) = element
336 .strip_prefix("{def:")
337 .and_then(|r| r.strip_suffix('}'))
338 && !name.contains('{')
339 {
340 match defs.get(name) {
341 Some(Def::Many(items)) => {
342 out.extend(items.iter().cloned());
343 continue;
344 }
345 Some(Def::One(s)) => {
346 out.push(s.clone());
347 continue;
348 }
349 None => return Err(format!("unknown def '{name}'")),
350 }
351 }
352 let mut text = element.clone();
354 while let Some(start) = text.find("{def:") {
355 let rest = &text[start + 5..];
356 let Some(end) = rest.find('}') else {
357 break; };
359 let name = &rest[..end];
360 match defs.get(name) {
361 Some(Def::One(s)) => {
362 text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
363 }
364 Some(Def::Many(_)) => {
365 return Err(format!(
366 "def '{name}' is a list and can only stand alone as one argv element"
367 ));
368 }
369 None => return Err(format!("unknown def '{name}'")),
370 }
371 }
372 out.push(text);
373 }
374 Ok(out)
375}
376
377pub struct BridgeEntry {
382 pub prefix: &'static [&'static str],
384 pub enforced: &'static [&'static str],
386 pub offline_flag: Option<&'static str>,
388 pub network_meaningful: bool,
390}
391
392pub const BRIDGE: &[BridgeEntry] = &[
395 BridgeEntry {
396 prefix: &["cargo", "metadata"],
397 enforced: &["--locked", "--offline", "--format-version", "1"],
398 offline_flag: None, network_meaningful: false,
400 },
401 BridgeEntry {
402 prefix: &["cargo", "tree"],
403 enforced: &["--locked"],
404 offline_flag: Some("--offline"),
405 network_meaningful: false,
406 },
407 BridgeEntry {
408 prefix: &["cargo", "deny", "check"],
409 enforced: &[],
410 offline_flag: Some("--offline"),
411 network_meaningful: true,
412 },
413 BridgeEntry {
414 prefix: &["rust-analyzer", "search"],
415 enforced: &[],
416 offline_flag: None,
417 network_meaningful: false,
418 },
419 BridgeEntry {
420 prefix: &["rust-analyzer", "symbols"],
421 enforced: &[],
422 offline_flag: None,
423 network_meaningful: false,
424 },
425];
426
427pub enum Gated<'a> {
429 Observer,
431 Bridge(&'a BridgeEntry),
433}
434
435pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
452 let name = allowlist::gated_name(&argv[0]);
453 if name == "ct-check" {
454 return Err("a probe may not run ct-check (no self-recursion through the store)".to_string());
455 }
456 if name == "ct-rules" {
457 return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
458 }
459 if name == "ct-each" {
460 if argv.iter().any(|a| a == "--mutating") {
461 return Err("a probe may not pass --mutating (rules observe; they never change anything)"
462 .to_string());
463 }
464 return Ok(Gated::Observer);
465 }
466 if allowlist::is_allowed(&name) || name == "ct-test" {
467 return Ok(Gated::Observer);
468 }
469 for entry in BRIDGE {
470 if name == entry.prefix[0]
471 && argv.len() >= entry.prefix.len()
472 && argv[1..entry.prefix.len()]
473 .iter()
474 .zip(&entry.prefix[1..])
475 .all(|(a, p)| a == p)
476 {
477 return Ok(Gated::Bridge(entry));
478 }
479 }
480 Err(format!(
481 "'{}' is not a permitted probe: probes run the suite's read-only tools \
482 or a compiled-in bridge invocation ({}); the gate is immutable",
483 argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
484 BRIDGE
485 .iter()
486 .map(|b| b.prefix.join(" "))
487 .collect::<Vec<_>>()
488 .join(", ")
489 ))
490}
491
492pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
512 fn append(out: &mut Vec<String>, flag: &str) {
513 if !out.iter().any(|a| a == flag) {
514 out.push(flag.to_string());
515 }
516 }
517 let mut out = argv.to_vec();
518 let mut i = 0;
519 while i < entry.enforced.len() {
520 let flag = entry.enforced[i];
521 if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
524 if !argv.iter().any(|a| a == flag) {
525 out.push(flag.to_string());
526 out.push(entry.enforced[i + 1].to_string());
527 }
528 i += 2;
529 } else {
530 append(&mut out, flag);
531 i += 1;
532 }
533 }
534 if let Some(offline) = entry.offline_flag
535 && !(network && entry.network_meaningful)
536 {
537 append(&mut out, offline);
538 }
539 out
540}
541
542pub fn run_probe(
550 expanded: &[String],
551 gated: &Gated,
552 root: &Path,
553 network: bool,
554 timeout: Option<std::time::Duration>,
555 adapter: &Adapter,
556) -> (ProbeOutcome, String, crate::supervise::Outcome) {
557 let argv = match gated {
558 Gated::Observer => expanded.to_vec(),
559 Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
560 };
561 let name = allowlist::gated_name(&argv[0]);
562 let mut command = std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
563 command.args(&argv[1..]).current_dir(root);
564 let empty = || crate::supervise::Outcome {
565 stdout: String::new(),
566 stderr: String::new(),
567 status: None,
568 timed_out: false,
569 };
570 match crate::supervise::run_captured(command, None, timeout) {
571 Err(e) => (
572 ProbeOutcome::Broken,
573 format!("could not launch '{}': {e}", argv[0]),
574 empty(),
575 ),
576 Ok(outcome) if outcome.timed_out => {
577 let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
578 (
579 ProbeOutcome::Broken,
580 format!("timed out after {label}; probe killed"),
581 outcome,
582 )
583 }
584 Ok(outcome) => {
585 let code = outcome.status.and_then(|s| s.code());
586 let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
587 (result, reason, outcome)
588 }
589 }
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
596pub enum ProbeOutcome {
597 Holds,
599 Violated,
601 Broken,
603}
604
605pub fn classify(
609 adapter: &Adapter,
610 code: Option<i32>,
611 stdout: &str,
612 stderr: &str,
613) -> (ProbeOutcome, String) {
614 let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
615 match code {
616 Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
617 Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
618 Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
619 None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
620 }
621 };
622 match adapter {
623 Adapter::Exit => by_exit(code),
624 Adapter::Empty => match code {
625 Some(0) if stdout.trim().is_empty() => {
626 (ProbeOutcome::Holds, "probe printed nothing".to_string())
627 }
628 Some(0) => (
629 ProbeOutcome::Violated,
630 "expect empty: probe printed output".to_string(),
631 ),
632 Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
633 None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
634 },
635 Adapter::Match { ok, err } => {
636 let hit = |pat: &str| -> Result<bool, String> {
637 Ok(pattern::compile(pat)
638 .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
639 .is_match(stdout)
640 || pattern::compile(pat).unwrap().is_match(stderr))
641 };
642 if let Some(p) = err {
643 match hit(p) {
644 Ok(true) => {
645 return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
646 }
647 Ok(false) => {}
648 Err(e) => return (ProbeOutcome::Broken, e),
649 }
650 }
651 if let Some(p) = ok {
652 return match hit(p) {
653 Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
654 Ok(false) => (
655 ProbeOutcome::Violated,
656 format!("ok-match '{p}' not found"),
657 ),
658 Err(e) => (ProbeOutcome::Broken, e),
659 };
660 }
661 by_exit(code)
662 }
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 const SAMPLE: &str = r#"{
671 // a comment, because the store is JSONC
672 "defs": {
673 "layer": "src/domain",
674 "types": ["A", "B"]
675 },
676 "rules": [
677 {
678 "id": "one",
679 "question": "Q1?",
680 "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
681 "why": "because",
682 "tags": ["t1"]
683 },
684 {
685 "id": "two",
686 "question": "Q2?",
687 "probe": ["cargo", "tree", "-d"],
688 "expect": "empty",
689 "severity": "warn",
690 "pending": true,
691 "timeout": 5
692 }
693 ]
694}"#;
695
696 #[test]
697 fn parses_defs_rules_and_optional_fields() {
698 let store = parse_store(SAMPLE).unwrap();
699 assert_eq!(store.defs.len(), 2);
700 assert_eq!(store.rules.len(), 2);
701 let two = &store.rules[1];
702 assert_eq!(two.severity, Severity::Warn);
703 assert!(two.pending);
704 assert_eq!(two.expect, Adapter::Empty);
705 assert_eq!(two.timeout, Some(5.0));
706 assert_eq!(store.rules[0].severity, Severity::Fail);
707 assert_eq!(store.rules[0].expect, Adapter::Exit);
708 }
709
710 #[test]
711 fn rejects_duplicates_and_malformed_entries() {
712 let dup = r#"{"rules":[
713 {"id":"x","question":"q","probe":["ls"]},
714 {"id":"x","question":"q","probe":["ls"]}]}"#;
715 assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
716 let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
717 assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
718 let unknown = r#"{"stuff": 1}"#;
719 assert!(parse_store(unknown).unwrap_err().contains("unknown store key"));
720 let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
721 assert!(parse_store(badsev).unwrap_err().contains("invalid severity"));
722 }
723
724 #[test]
725 fn adapter_parsing_accepts_strings_and_matcher_objects() {
726 assert_eq!(Adapter::from_value(&serde_json::json!("exit")).unwrap(), Adapter::Exit);
727 assert_eq!(Adapter::from_value(&serde_json::json!("empty")).unwrap(), Adapter::Empty);
728 let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
729 assert_eq!(m, Adapter::Match { ok: Some("fine".into()), err: None });
730 assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
731 assert!(Adapter::from_value(&serde_json::json!({})).is_err());
732 assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
733 }
734
735 #[test]
736 fn gate_admits_observers_and_bridge_only() {
737 let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
738 assert!(matches!(gate_probe(&argv(&["ct-outline", "--base", "."])), Ok(Gated::Observer)));
739 assert!(matches!(gate_probe(&argv(&["ct-test", "--cmd", "cat"])), Ok(Gated::Observer)));
740 assert!(matches!(gate_probe(&argv(&["cargo", "deny", "check", "bans"])), Ok(Gated::Bridge(_))));
741 assert!(matches!(gate_probe(&argv(&["rust-analyzer", "symbols"])), Ok(Gated::Bridge(_))));
742 assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
744 assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
745 assert!(gate_probe(&argv(&["cargo"])).is_err());
746 assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
747 }
748
749 #[test]
750 fn classify_exit_empty_and_matchers() {
751 use ProbeOutcome::*;
752 assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
753 assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
754 assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
755
756 assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
757 assert_eq!(classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0, Violated);
758 assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
759
760 let m = Adapter::Match { ok: Some("did not match any packages".into()), err: None };
761 assert_eq!(classify(&m, Some(101), "", "error: ... did not match any packages").0, Holds);
763 let m = Adapter::Match { ok: None, err: Some("^openssl".into()) };
764 assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
765 assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
767 let m = Adapter::Match { ok: Some("proof".into()), err: None };
769 assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
770 }
771}