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!(
137 "invalid expect '{other}' (use exit, empty, or a matcher object)"
138 )),
139 },
140 serde_json::Value::Object(o) => {
141 let get = |k: &str| -> Result<Option<String>, String> {
142 match o.get(k) {
143 None => Ok(None),
144 Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
145 Some(_) => Err(format!("expect.{k} must be a string")),
146 }
147 };
148 let ok = get("ok-match")?;
149 let err = get("err-match")?;
150 if ok.is_none() && err.is_none() {
151 return Err("expect object needs ok-match and/or err-match".to_string());
152 }
153 for key in o.keys() {
154 if key != "ok-match" && key != "err-match" {
155 return Err(format!("unknown expect key '{key}'"));
156 }
157 }
158 Ok(Adapter::Match { ok, err })
159 }
160 _ => Err("expect must be a string or a matcher object".to_string()),
161 }
162 }
163}
164
165#[derive(Debug, Clone)]
167pub struct Rule {
168 pub id: String,
170 pub question: String,
172 pub probe: Vec<String>,
174 pub why: Option<String>,
176 pub prompt: Option<String>,
180 pub tags: Vec<String>,
182 pub added: Option<String>,
184 pub timeout: Option<f64>,
186 pub pending: bool,
188 pub severity: Severity,
190 pub expect: Adapter,
192 pub network: bool,
194}
195
196#[derive(Debug, Default)]
198pub struct Store {
199 pub defs: BTreeMap<String, Def>,
201 pub rules: Vec<Rule>,
203}
204
205fn as_str(v: &serde_json::Value, what: &str) -> Result<String, String> {
206 v.as_str()
207 .map(String::from)
208 .ok_or_else(|| format!("{what} must be a string"))
209}
210
211fn as_str_list(v: &serde_json::Value, what: &str) -> Result<Vec<String>, String> {
212 v.as_array()
213 .ok_or_else(|| format!("{what} must be an array of strings"))?
214 .iter()
215 .map(|e| as_str(e, what))
216 .collect()
217}
218
219pub fn parse_store(text: &str) -> Result<Store, String> {
221 let value = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
222 .map_err(|e| format!("store parse error: {e}"))?
223 .ok_or("store is empty")?;
224 let obj = value.as_object().ok_or("store root must be an object")?;
225 for key in obj.keys() {
226 if key != "defs" && key != "rules" {
227 return Err(format!("unknown store key '{key}' (expected defs/rules)"));
228 }
229 }
230
231 let mut defs = BTreeMap::new();
232 if let Some(d) = obj.get("defs") {
233 let d = d.as_object().ok_or("defs must be an object")?;
234 for (name, val) in d {
235 let def = match val {
236 serde_json::Value::String(s) => Def::One(s.clone()),
237 serde_json::Value::Array(_) => {
238 Def::Many(as_str_list(val, &format!("def '{name}'"))?)
239 }
240 _ => {
241 return Err(format!(
242 "def '{name}' must be a string or a list of strings"
243 ));
244 }
245 };
246 defs.insert(name.clone(), def);
247 }
248 }
249
250 let mut rules = Vec::new();
251 let mut seen = std::collections::HashSet::new();
252 if let Some(r) = obj.get("rules") {
253 let arr = r.as_array().ok_or("rules must be an array")?;
254 for (i, entry) in arr.iter().enumerate() {
255 let o = entry
256 .as_object()
257 .ok_or_else(|| format!("rules[{i}] must be an object"))?;
258 let id = as_str(
259 o.get("id")
260 .ok_or_else(|| format!("rules[{i}]: missing id"))?,
261 "id",
262 )?;
263 if id.is_empty() || id.contains(char::is_whitespace) {
264 return Err(format!("rules[{i}]: invalid id '{id}'"));
265 }
266 if !seen.insert(id.clone()) {
267 return Err(format!("duplicate rule id '{id}'"));
268 }
269 let where_ = format!("rule '{id}'");
270 let question = as_str(
271 o.get("question")
272 .ok_or_else(|| format!("{where_}: missing question"))?,
273 "question",
274 )?;
275 let probe = as_str_list(
276 o.get("probe")
277 .ok_or_else(|| format!("{where_}: missing probe"))?,
278 "probe",
279 )?;
280 if probe.is_empty() {
281 return Err(format!("{where_}: probe must not be empty"));
282 }
283 let rule = Rule {
284 id,
285 question,
286 probe,
287 why: o.get("why").map(|v| as_str(v, "why")).transpose()?,
288 prompt: o.get("prompt").map(|v| as_str(v, "prompt")).transpose()?,
289 tags: match o.get("tags") {
290 Some(v) => as_str_list(v, "tags")?,
291 None => Vec::new(),
292 },
293 added: o.get("added").map(|v| as_str(v, "added")).transpose()?,
294 timeout: match o.get("timeout") {
295 Some(v) => Some(
296 v.as_f64()
297 .ok_or_else(|| format!("{where_}: timeout must be a number"))?,
298 ),
299 None => None,
300 },
301 pending: o.get("pending").and_then(|v| v.as_bool()).unwrap_or(false),
302 severity: match o.get("severity") {
303 Some(v) => Severity::parse(&as_str(v, "severity")?)
304 .map_err(|e| format!("{where_}: {e}"))?,
305 None => Severity::Fail,
306 },
307 expect: match o.get("expect") {
308 Some(v) => Adapter::from_value(v).map_err(|e| format!("{where_}: {e}"))?,
309 None => Adapter::Exit,
310 },
311 network: o.get("network").and_then(|v| v.as_bool()).unwrap_or(false),
312 };
313 if matches!(
317 rule.probe.first().map(String::as_str),
318 Some("deps") | Some("mods")
319 ) && rule.expect != Adapter::Exit
320 {
321 return Err(format!(
322 "{where_}: built-in check '{}' takes no expect adapter (it classifies its own outcome)",
323 rule.probe[0]
324 ));
325 }
326 rules.push(rule);
327 }
328 }
329 Ok(Store { defs, rules })
330}
331
332pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
359 let mut out = Vec::with_capacity(argv.len());
360 for element in argv {
361 if let Some(name) = element
363 .strip_prefix("{def:")
364 .and_then(|r| r.strip_suffix('}'))
365 && !name.contains('{')
366 {
367 match defs.get(name) {
368 Some(Def::Many(items)) => {
369 out.extend(items.iter().cloned());
370 continue;
371 }
372 Some(Def::One(s)) => {
373 out.push(s.clone());
374 continue;
375 }
376 None => return Err(format!("unknown def '{name}'")),
377 }
378 }
379 let mut text = element.clone();
381 while let Some(start) = text.find("{def:") {
382 let rest = &text[start + 5..];
383 let Some(end) = rest.find('}') else {
384 break; };
386 let name = &rest[..end];
387 match defs.get(name) {
388 Some(Def::One(s)) => {
389 text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
390 }
391 Some(Def::Many(_)) => {
392 return Err(format!(
393 "def '{name}' is a list and can only stand alone as one argv element"
394 ));
395 }
396 None => return Err(format!("unknown def '{name}'")),
397 }
398 }
399 out.push(text);
400 }
401 Ok(out)
402}
403
404pub struct BridgeEntry {
409 pub prefix: &'static [&'static str],
411 pub enforced: &'static [&'static str],
413 pub offline_flag: Option<&'static str>,
415 pub network_meaningful: bool,
417}
418
419pub const BRIDGE: &[BridgeEntry] = &[
422 BridgeEntry {
423 prefix: &["cargo", "metadata"],
424 enforced: &["--locked", "--offline", "--format-version", "1"],
425 offline_flag: None, network_meaningful: false,
427 },
428 BridgeEntry {
429 prefix: &["cargo", "tree"],
430 enforced: &["--locked"],
431 offline_flag: Some("--offline"),
432 network_meaningful: false,
433 },
434 BridgeEntry {
435 prefix: &["cargo", "deny", "check"],
436 enforced: &[],
437 offline_flag: Some("--offline"),
438 network_meaningful: true,
439 },
440 BridgeEntry {
441 prefix: &["rust-analyzer", "search"],
442 enforced: &[],
443 offline_flag: None,
444 network_meaningful: false,
445 },
446 BridgeEntry {
447 prefix: &["rust-analyzer", "symbols"],
448 enforced: &[],
449 offline_flag: None,
450 network_meaningful: false,
451 },
452];
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum Builtin {
457 Deps,
459 Mods,
461}
462
463pub enum Gated<'a> {
465 Observer,
467 Bridge(&'a BridgeEntry),
469 Builtin(Builtin),
471}
472
473pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
490 let name = allowlist::gated_name(&argv[0]);
491 if name == "deps" {
492 return Ok(Gated::Builtin(Builtin::Deps));
493 }
494 if name == "mods" {
495 return Ok(Gated::Builtin(Builtin::Mods));
496 }
497 if name == "ct-check" {
498 return Err(
499 "a probe may not run ct-check (no self-recursion through the store)".to_string(),
500 );
501 }
502 if name == "ct-rules" {
503 return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
504 }
505 if name == "ct-each" {
506 if argv.iter().any(|a| a == "--mutating") {
507 return Err(
508 "a probe may not pass --mutating (rules observe; they never change anything)"
509 .to_string(),
510 );
511 }
512 return Ok(Gated::Observer);
513 }
514 if allowlist::is_allowed(&name) || name == "ct-test" {
515 return Ok(Gated::Observer);
516 }
517 for entry in BRIDGE {
518 if name == entry.prefix[0]
519 && argv.len() >= entry.prefix.len()
520 && argv[1..entry.prefix.len()]
521 .iter()
522 .zip(&entry.prefix[1..])
523 .all(|(a, p)| a == p)
524 {
525 return Ok(Gated::Bridge(entry));
526 }
527 }
528 Err(format!(
529 "'{}' is not a permitted probe: probes run the suite's read-only tools \
530 or a compiled-in bridge invocation ({}); the gate is immutable",
531 argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
532 BRIDGE
533 .iter()
534 .map(|b| b.prefix.join(" "))
535 .collect::<Vec<_>>()
536 .join(", ")
537 ))
538}
539
540pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
560 fn append(out: &mut Vec<String>, flag: &str) {
561 if !out.iter().any(|a| a == flag) {
562 out.push(flag.to_string());
563 }
564 }
565 let mut out = argv.to_vec();
566 let mut i = 0;
567 while i < entry.enforced.len() {
568 let flag = entry.enforced[i];
569 if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
572 if !argv.iter().any(|a| a == flag) {
573 out.push(flag.to_string());
574 out.push(entry.enforced[i + 1].to_string());
575 }
576 i += 2;
577 } else {
578 append(&mut out, flag);
579 i += 1;
580 }
581 }
582 if let Some(offline) = entry.offline_flag
583 && !(network && entry.network_meaningful)
584 {
585 append(&mut out, offline);
586 }
587 out
588}
589
590pub fn run_probe(
598 expanded: &[String],
599 gated: &Gated,
600 root: &Path,
601 network: bool,
602 timeout: Option<std::time::Duration>,
603 adapter: &Adapter,
604) -> (ProbeOutcome, String, crate::supervise::Outcome) {
605 if let Gated::Builtin(kind) = gated {
606 let (outcome, reason, report) = match kind {
607 Builtin::Deps => crate::deps::check(&expanded[1..], root, timeout),
608 Builtin::Mods => crate::modgraph::check(&expanded[1..], root, timeout),
609 };
610 return (
611 outcome,
612 reason,
613 crate::supervise::Outcome {
614 stdout: report,
615 stderr: String::new(),
616 status: None,
617 timed_out: false,
618 },
619 );
620 }
621 let argv = match gated {
622 Gated::Observer => expanded.to_vec(),
623 Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
624 Gated::Builtin(_) => unreachable!("built-in checks handled above"),
625 };
626 let name = allowlist::gated_name(&argv[0]);
627 let mut command =
628 std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
629 command.args(&argv[1..]).current_dir(root);
630 let empty = || crate::supervise::Outcome {
631 stdout: String::new(),
632 stderr: String::new(),
633 status: None,
634 timed_out: false,
635 };
636 match crate::supervise::run_captured(command, None, timeout) {
637 Err(e) => (
638 ProbeOutcome::Broken,
639 format!("could not launch '{}': {e}", argv[0]),
640 empty(),
641 ),
642 Ok(outcome) if outcome.timed_out => {
643 let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
644 (
645 ProbeOutcome::Broken,
646 format!("timed out after {label}; probe killed"),
647 outcome,
648 )
649 }
650 Ok(outcome) => {
651 let code = outcome.status.and_then(|s| s.code());
652 let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
653 (result, reason, outcome)
654 }
655 }
656}
657
658#[derive(Debug, Clone, PartialEq, Eq)]
662pub enum ProbeOutcome {
663 Holds,
665 Violated,
667 Broken,
669}
670
671pub fn classify(
675 adapter: &Adapter,
676 code: Option<i32>,
677 stdout: &str,
678 stderr: &str,
679) -> (ProbeOutcome, String) {
680 let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
681 match code {
682 Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
683 Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
684 Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
685 None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
686 }
687 };
688 match adapter {
689 Adapter::Exit => by_exit(code),
690 Adapter::Empty => match code {
691 Some(0) if stdout.trim().is_empty() => {
692 (ProbeOutcome::Holds, "probe printed nothing".to_string())
693 }
694 Some(0) => (
695 ProbeOutcome::Violated,
696 "expect empty: probe printed output".to_string(),
697 ),
698 Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
699 None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
700 },
701 Adapter::Match { ok, err } => {
702 let hit = |pat: &str| -> Result<bool, String> {
703 Ok(pattern::compile(pat)
704 .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
705 .is_match(stdout)
706 || pattern::compile(pat).unwrap().is_match(stderr))
707 };
708 if let Some(p) = err {
709 match hit(p) {
710 Ok(true) => {
711 return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
712 }
713 Ok(false) => {}
714 Err(e) => return (ProbeOutcome::Broken, e),
715 }
716 }
717 if let Some(p) = ok {
718 return match hit(p) {
719 Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
720 Ok(false) => (ProbeOutcome::Violated, format!("ok-match '{p}' not found")),
721 Err(e) => (ProbeOutcome::Broken, e),
722 };
723 }
724 by_exit(code)
725 }
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 const SAMPLE: &str = r#"{
734 // a comment, because the store is JSONC
735 "defs": {
736 "layer": "src/domain",
737 "types": ["A", "B"]
738 },
739 "rules": [
740 {
741 "id": "one",
742 "question": "Q1?",
743 "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
744 "why": "because",
745 "tags": ["t1"]
746 },
747 {
748 "id": "two",
749 "question": "Q2?",
750 "probe": ["cargo", "tree", "-d"],
751 "expect": "empty",
752 "severity": "warn",
753 "pending": true,
754 "timeout": 5
755 }
756 ]
757}"#;
758
759 #[test]
760 fn parses_defs_rules_and_optional_fields() {
761 let store = parse_store(SAMPLE).unwrap();
762 assert_eq!(store.defs.len(), 2);
763 assert_eq!(store.rules.len(), 2);
764 let two = &store.rules[1];
765 assert_eq!(two.severity, Severity::Warn);
766 assert!(two.pending);
767 assert_eq!(two.expect, Adapter::Empty);
768 assert_eq!(two.timeout, Some(5.0));
769 assert_eq!(store.rules[0].severity, Severity::Fail);
770 assert_eq!(store.rules[0].expect, Adapter::Exit);
771 }
772
773 #[test]
774 fn rejects_duplicates_and_malformed_entries() {
775 let dup = r#"{"rules":[
776 {"id":"x","question":"q","probe":["ls"]},
777 {"id":"x","question":"q","probe":["ls"]}]}"#;
778 assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
779 let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
780 assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
781 let unknown = r#"{"stuff": 1}"#;
782 assert!(
783 parse_store(unknown)
784 .unwrap_err()
785 .contains("unknown store key")
786 );
787 let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
788 assert!(
789 parse_store(badsev)
790 .unwrap_err()
791 .contains("invalid severity")
792 );
793 let builtin_adapter = r#"{"rules":[{"id":"x","question":"q","probe":["deps","--acyclic"],"expect":"empty"}]}"#;
796 assert!(
797 parse_store(builtin_adapter)
798 .unwrap_err()
799 .contains("takes no expect adapter")
800 );
801 }
802
803 #[test]
804 fn adapter_parsing_accepts_strings_and_matcher_objects() {
805 assert_eq!(
806 Adapter::from_value(&serde_json::json!("exit")).unwrap(),
807 Adapter::Exit
808 );
809 assert_eq!(
810 Adapter::from_value(&serde_json::json!("empty")).unwrap(),
811 Adapter::Empty
812 );
813 let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
814 assert_eq!(
815 m,
816 Adapter::Match {
817 ok: Some("fine".into()),
818 err: None
819 }
820 );
821 assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
822 assert!(Adapter::from_value(&serde_json::json!({})).is_err());
823 assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
824 }
825
826 #[test]
827 fn gate_admits_observers_and_bridge_only() {
828 let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
829 assert!(matches!(
830 gate_probe(&argv(&["ct-outline", "--base", "."])),
831 Ok(Gated::Observer)
832 ));
833 assert!(matches!(
834 gate_probe(&argv(&["ct-test", "--cmd", "cat"])),
835 Ok(Gated::Observer)
836 ));
837 assert!(matches!(
838 gate_probe(&argv(&["cargo", "deny", "check", "bans"])),
839 Ok(Gated::Bridge(_))
840 ));
841 assert!(matches!(
842 gate_probe(&argv(&["rust-analyzer", "symbols"])),
843 Ok(Gated::Bridge(_))
844 ));
845 assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
847 assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
848 assert!(gate_probe(&argv(&["cargo"])).is_err());
849 assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
850 }
851
852 #[test]
853 fn gate_classifies_builtin_checks() {
854 let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
855 assert!(matches!(
857 gate_probe(&argv(&["deps", "--acyclic"])),
858 Ok(Gated::Builtin(Builtin::Deps))
859 ));
860 assert!(matches!(
861 gate_probe(&argv(&["mods", "--forbid", "a=>b"])),
862 Ok(Gated::Builtin(Builtin::Mods))
863 ));
864 assert!(gate_probe(&argv(&["ct-deps", "--acyclic"])).is_err());
866 assert!(gate_probe(&argv(&["ct-mods", "--acyclic"])).is_err());
867 }
868
869 #[test]
870 fn classify_exit_empty_and_matchers() {
871 use ProbeOutcome::*;
872 assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
873 assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
874 assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
875
876 assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
877 assert_eq!(
878 classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0,
879 Violated
880 );
881 assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
882
883 let m = Adapter::Match {
884 ok: Some("did not match any packages".into()),
885 err: None,
886 };
887 assert_eq!(
889 classify(&m, Some(101), "", "error: ... did not match any packages").0,
890 Holds
891 );
892 let m = Adapter::Match {
893 ok: None,
894 err: Some("^openssl".into()),
895 };
896 assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
897 assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
899 let m = Adapter::Match {
901 ok: Some("proof".into()),
902 err: None,
903 };
904 assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
905 }
906}