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 if matches!(rule.probe.first().map(String::as_str), Some("deps") | Some("mods"))
303 && rule.expect != Adapter::Exit
304 {
305 return Err(format!(
306 "{where_}: built-in check '{}' takes no expect adapter (it classifies its own outcome)",
307 rule.probe[0]
308 ));
309 }
310 rules.push(rule);
311 }
312 }
313 Ok(Store { defs, rules })
314}
315
316pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
343 let mut out = Vec::with_capacity(argv.len());
344 for element in argv {
345 if let Some(name) = element
347 .strip_prefix("{def:")
348 .and_then(|r| r.strip_suffix('}'))
349 && !name.contains('{')
350 {
351 match defs.get(name) {
352 Some(Def::Many(items)) => {
353 out.extend(items.iter().cloned());
354 continue;
355 }
356 Some(Def::One(s)) => {
357 out.push(s.clone());
358 continue;
359 }
360 None => return Err(format!("unknown def '{name}'")),
361 }
362 }
363 let mut text = element.clone();
365 while let Some(start) = text.find("{def:") {
366 let rest = &text[start + 5..];
367 let Some(end) = rest.find('}') else {
368 break; };
370 let name = &rest[..end];
371 match defs.get(name) {
372 Some(Def::One(s)) => {
373 text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
374 }
375 Some(Def::Many(_)) => {
376 return Err(format!(
377 "def '{name}' is a list and can only stand alone as one argv element"
378 ));
379 }
380 None => return Err(format!("unknown def '{name}'")),
381 }
382 }
383 out.push(text);
384 }
385 Ok(out)
386}
387
388pub struct BridgeEntry {
393 pub prefix: &'static [&'static str],
395 pub enforced: &'static [&'static str],
397 pub offline_flag: Option<&'static str>,
399 pub network_meaningful: bool,
401}
402
403pub const BRIDGE: &[BridgeEntry] = &[
406 BridgeEntry {
407 prefix: &["cargo", "metadata"],
408 enforced: &["--locked", "--offline", "--format-version", "1"],
409 offline_flag: None, network_meaningful: false,
411 },
412 BridgeEntry {
413 prefix: &["cargo", "tree"],
414 enforced: &["--locked"],
415 offline_flag: Some("--offline"),
416 network_meaningful: false,
417 },
418 BridgeEntry {
419 prefix: &["cargo", "deny", "check"],
420 enforced: &[],
421 offline_flag: Some("--offline"),
422 network_meaningful: true,
423 },
424 BridgeEntry {
425 prefix: &["rust-analyzer", "search"],
426 enforced: &[],
427 offline_flag: None,
428 network_meaningful: false,
429 },
430 BridgeEntry {
431 prefix: &["rust-analyzer", "symbols"],
432 enforced: &[],
433 offline_flag: None,
434 network_meaningful: false,
435 },
436];
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440pub enum Builtin {
441 Deps,
443 Mods,
445}
446
447pub enum Gated<'a> {
449 Observer,
451 Bridge(&'a BridgeEntry),
453 Builtin(Builtin),
455}
456
457pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
474 let name = allowlist::gated_name(&argv[0]);
475 if name == "deps" {
476 return Ok(Gated::Builtin(Builtin::Deps));
477 }
478 if name == "mods" {
479 return Ok(Gated::Builtin(Builtin::Mods));
480 }
481 if name == "ct-check" {
482 return Err("a probe may not run ct-check (no self-recursion through the store)".to_string());
483 }
484 if name == "ct-rules" {
485 return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
486 }
487 if name == "ct-each" {
488 if argv.iter().any(|a| a == "--mutating") {
489 return Err("a probe may not pass --mutating (rules observe; they never change anything)"
490 .to_string());
491 }
492 return Ok(Gated::Observer);
493 }
494 if allowlist::is_allowed(&name) || name == "ct-test" {
495 return Ok(Gated::Observer);
496 }
497 for entry in BRIDGE {
498 if name == entry.prefix[0]
499 && argv.len() >= entry.prefix.len()
500 && argv[1..entry.prefix.len()]
501 .iter()
502 .zip(&entry.prefix[1..])
503 .all(|(a, p)| a == p)
504 {
505 return Ok(Gated::Bridge(entry));
506 }
507 }
508 Err(format!(
509 "'{}' is not a permitted probe: probes run the suite's read-only tools \
510 or a compiled-in bridge invocation ({}); the gate is immutable",
511 argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
512 BRIDGE
513 .iter()
514 .map(|b| b.prefix.join(" "))
515 .collect::<Vec<_>>()
516 .join(", ")
517 ))
518}
519
520pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
540 fn append(out: &mut Vec<String>, flag: &str) {
541 if !out.iter().any(|a| a == flag) {
542 out.push(flag.to_string());
543 }
544 }
545 let mut out = argv.to_vec();
546 let mut i = 0;
547 while i < entry.enforced.len() {
548 let flag = entry.enforced[i];
549 if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
552 if !argv.iter().any(|a| a == flag) {
553 out.push(flag.to_string());
554 out.push(entry.enforced[i + 1].to_string());
555 }
556 i += 2;
557 } else {
558 append(&mut out, flag);
559 i += 1;
560 }
561 }
562 if let Some(offline) = entry.offline_flag
563 && !(network && entry.network_meaningful)
564 {
565 append(&mut out, offline);
566 }
567 out
568}
569
570pub fn run_probe(
578 expanded: &[String],
579 gated: &Gated,
580 root: &Path,
581 network: bool,
582 timeout: Option<std::time::Duration>,
583 adapter: &Adapter,
584) -> (ProbeOutcome, String, crate::supervise::Outcome) {
585 if let Gated::Builtin(kind) = gated {
586 let (outcome, reason, report) = match kind {
587 Builtin::Deps => crate::deps::check(&expanded[1..], root, timeout),
588 Builtin::Mods => crate::modgraph::check(&expanded[1..], root, timeout),
589 };
590 return (
591 outcome,
592 reason,
593 crate::supervise::Outcome {
594 stdout: report,
595 stderr: String::new(),
596 status: None,
597 timed_out: false,
598 },
599 );
600 }
601 let argv = match gated {
602 Gated::Observer => expanded.to_vec(),
603 Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
604 Gated::Builtin(_) => unreachable!("built-in checks handled above"),
605 };
606 let name = allowlist::gated_name(&argv[0]);
607 let mut command = std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
608 command.args(&argv[1..]).current_dir(root);
609 let empty = || crate::supervise::Outcome {
610 stdout: String::new(),
611 stderr: String::new(),
612 status: None,
613 timed_out: false,
614 };
615 match crate::supervise::run_captured(command, None, timeout) {
616 Err(e) => (
617 ProbeOutcome::Broken,
618 format!("could not launch '{}': {e}", argv[0]),
619 empty(),
620 ),
621 Ok(outcome) if outcome.timed_out => {
622 let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
623 (
624 ProbeOutcome::Broken,
625 format!("timed out after {label}; probe killed"),
626 outcome,
627 )
628 }
629 Ok(outcome) => {
630 let code = outcome.status.and_then(|s| s.code());
631 let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
632 (result, reason, outcome)
633 }
634 }
635}
636
637#[derive(Debug, Clone, PartialEq, Eq)]
641pub enum ProbeOutcome {
642 Holds,
644 Violated,
646 Broken,
648}
649
650pub fn classify(
654 adapter: &Adapter,
655 code: Option<i32>,
656 stdout: &str,
657 stderr: &str,
658) -> (ProbeOutcome, String) {
659 let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
660 match code {
661 Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
662 Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
663 Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
664 None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
665 }
666 };
667 match adapter {
668 Adapter::Exit => by_exit(code),
669 Adapter::Empty => match code {
670 Some(0) if stdout.trim().is_empty() => {
671 (ProbeOutcome::Holds, "probe printed nothing".to_string())
672 }
673 Some(0) => (
674 ProbeOutcome::Violated,
675 "expect empty: probe printed output".to_string(),
676 ),
677 Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
678 None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
679 },
680 Adapter::Match { ok, err } => {
681 let hit = |pat: &str| -> Result<bool, String> {
682 Ok(pattern::compile(pat)
683 .map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
684 .is_match(stdout)
685 || pattern::compile(pat).unwrap().is_match(stderr))
686 };
687 if let Some(p) = err {
688 match hit(p) {
689 Ok(true) => {
690 return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
691 }
692 Ok(false) => {}
693 Err(e) => return (ProbeOutcome::Broken, e),
694 }
695 }
696 if let Some(p) = ok {
697 return match hit(p) {
698 Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
699 Ok(false) => (
700 ProbeOutcome::Violated,
701 format!("ok-match '{p}' not found"),
702 ),
703 Err(e) => (ProbeOutcome::Broken, e),
704 };
705 }
706 by_exit(code)
707 }
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714
715 const SAMPLE: &str = r#"{
716 // a comment, because the store is JSONC
717 "defs": {
718 "layer": "src/domain",
719 "types": ["A", "B"]
720 },
721 "rules": [
722 {
723 "id": "one",
724 "question": "Q1?",
725 "probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
726 "why": "because",
727 "tags": ["t1"]
728 },
729 {
730 "id": "two",
731 "question": "Q2?",
732 "probe": ["cargo", "tree", "-d"],
733 "expect": "empty",
734 "severity": "warn",
735 "pending": true,
736 "timeout": 5
737 }
738 ]
739}"#;
740
741 #[test]
742 fn parses_defs_rules_and_optional_fields() {
743 let store = parse_store(SAMPLE).unwrap();
744 assert_eq!(store.defs.len(), 2);
745 assert_eq!(store.rules.len(), 2);
746 let two = &store.rules[1];
747 assert_eq!(two.severity, Severity::Warn);
748 assert!(two.pending);
749 assert_eq!(two.expect, Adapter::Empty);
750 assert_eq!(two.timeout, Some(5.0));
751 assert_eq!(store.rules[0].severity, Severity::Fail);
752 assert_eq!(store.rules[0].expect, Adapter::Exit);
753 }
754
755 #[test]
756 fn rejects_duplicates_and_malformed_entries() {
757 let dup = r#"{"rules":[
758 {"id":"x","question":"q","probe":["ls"]},
759 {"id":"x","question":"q","probe":["ls"]}]}"#;
760 assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
761 let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
762 assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
763 let unknown = r#"{"stuff": 1}"#;
764 assert!(parse_store(unknown).unwrap_err().contains("unknown store key"));
765 let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
766 assert!(parse_store(badsev).unwrap_err().contains("invalid severity"));
767 let builtin_adapter =
770 r#"{"rules":[{"id":"x","question":"q","probe":["deps","--acyclic"],"expect":"empty"}]}"#;
771 assert!(parse_store(builtin_adapter)
772 .unwrap_err()
773 .contains("takes no expect adapter"));
774 }
775
776 #[test]
777 fn adapter_parsing_accepts_strings_and_matcher_objects() {
778 assert_eq!(Adapter::from_value(&serde_json::json!("exit")).unwrap(), Adapter::Exit);
779 assert_eq!(Adapter::from_value(&serde_json::json!("empty")).unwrap(), Adapter::Empty);
780 let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
781 assert_eq!(m, Adapter::Match { ok: Some("fine".into()), err: None });
782 assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
783 assert!(Adapter::from_value(&serde_json::json!({})).is_err());
784 assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
785 }
786
787 #[test]
788 fn gate_admits_observers_and_bridge_only() {
789 let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
790 assert!(matches!(gate_probe(&argv(&["ct-outline", "--base", "."])), Ok(Gated::Observer)));
791 assert!(matches!(gate_probe(&argv(&["ct-test", "--cmd", "cat"])), Ok(Gated::Observer)));
792 assert!(matches!(gate_probe(&argv(&["cargo", "deny", "check", "bans"])), Ok(Gated::Bridge(_))));
793 assert!(matches!(gate_probe(&argv(&["rust-analyzer", "symbols"])), Ok(Gated::Bridge(_))));
794 assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
796 assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
797 assert!(gate_probe(&argv(&["cargo"])).is_err());
798 assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
799 }
800
801 #[test]
802 fn gate_classifies_builtin_checks() {
803 let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
804 assert!(matches!(
806 gate_probe(&argv(&["deps", "--acyclic"])),
807 Ok(Gated::Builtin(Builtin::Deps))
808 ));
809 assert!(matches!(
810 gate_probe(&argv(&["mods", "--forbid", "a=>b"])),
811 Ok(Gated::Builtin(Builtin::Mods))
812 ));
813 assert!(gate_probe(&argv(&["ct-deps", "--acyclic"])).is_err());
815 assert!(gate_probe(&argv(&["ct-mods", "--acyclic"])).is_err());
816 }
817
818 #[test]
819 fn classify_exit_empty_and_matchers() {
820 use ProbeOutcome::*;
821 assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
822 assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
823 assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
824
825 assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
826 assert_eq!(classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0, Violated);
827 assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
828
829 let m = Adapter::Match { ok: Some("did not match any packages".into()), err: None };
830 assert_eq!(classify(&m, Some(101), "", "error: ... did not match any packages").0, Holds);
832 let m = Adapter::Match { ok: None, err: Some("^openssl".into()) };
833 assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
834 assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
836 let m = Adapter::Match { ok: Some("proof".into()), err: None };
838 assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
839 }
840}