use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::allowlist;
use crate::pattern;
pub const STORE_FILE: &str = "rules.jsonc";
pub fn discover_root(start: &Path) -> Option<PathBuf> {
let mut dir = Some(start.to_path_buf());
while let Some(d) = dir {
if d.join(".ct").is_dir() {
return Some(d);
}
dir = d.parent().map(Path::to_path_buf);
}
None
}
pub fn store_path(root: &Path) -> PathBuf {
root.join(".ct").join(STORE_FILE)
}
pub fn probe_root(store: &Path) -> PathBuf {
match store.parent() {
Some(dir) if dir.file_name().is_some_and(|n| n == ".ct") => dir
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| dir.to_path_buf()),
Some(dir) => dir.to_path_buf(),
None => PathBuf::from("."),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Def {
One(String),
Many(Vec<String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Severity {
#[default]
Fail,
Warn,
}
impl Severity {
pub fn parse(s: &str) -> Result<Severity, String> {
match s {
"fail" => Ok(Severity::Fail),
"warn" => Ok(Severity::Warn),
other => Err(format!("invalid severity '{other}' (use fail or warn)")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum Adapter {
#[default]
Exit,
Empty,
Match {
ok: Option<String>,
err: Option<String>,
},
}
impl Adapter {
pub fn from_value(v: &serde_json::Value) -> Result<Adapter, String> {
match v {
serde_json::Value::String(s) => match s.as_str() {
"exit" => Ok(Adapter::Exit),
"empty" => Ok(Adapter::Empty),
other => Err(format!("invalid expect '{other}' (use exit, empty, or a matcher object)")),
},
serde_json::Value::Object(o) => {
let get = |k: &str| -> Result<Option<String>, String> {
match o.get(k) {
None => Ok(None),
Some(serde_json::Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(format!("expect.{k} must be a string")),
}
};
let ok = get("ok-match")?;
let err = get("err-match")?;
if ok.is_none() && err.is_none() {
return Err("expect object needs ok-match and/or err-match".to_string());
}
for key in o.keys() {
if key != "ok-match" && key != "err-match" {
return Err(format!("unknown expect key '{key}'"));
}
}
Ok(Adapter::Match { ok, err })
}
_ => Err("expect must be a string or a matcher object".to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct Rule {
pub id: String,
pub question: String,
pub probe: Vec<String>,
pub why: Option<String>,
pub prompt: Option<String>,
pub tags: Vec<String>,
pub added: Option<String>,
pub timeout: Option<f64>,
pub pending: bool,
pub severity: Severity,
pub expect: Adapter,
pub network: bool,
}
#[derive(Debug, Default)]
pub struct Store {
pub defs: BTreeMap<String, Def>,
pub rules: Vec<Rule>,
}
fn as_str(v: &serde_json::Value, what: &str) -> Result<String, String> {
v.as_str()
.map(String::from)
.ok_or_else(|| format!("{what} must be a string"))
}
fn as_str_list(v: &serde_json::Value, what: &str) -> Result<Vec<String>, String> {
v.as_array()
.ok_or_else(|| format!("{what} must be an array of strings"))?
.iter()
.map(|e| as_str(e, what))
.collect()
}
pub fn parse_store(text: &str) -> Result<Store, String> {
let value = jsonc_parser::parse_to_serde_value(text, &jsonc_parser::ParseOptions::default())
.map_err(|e| format!("store parse error: {e}"))?
.ok_or("store is empty")?;
let obj = value.as_object().ok_or("store root must be an object")?;
for key in obj.keys() {
if key != "defs" && key != "rules" {
return Err(format!("unknown store key '{key}' (expected defs/rules)"));
}
}
let mut defs = BTreeMap::new();
if let Some(d) = obj.get("defs") {
let d = d.as_object().ok_or("defs must be an object")?;
for (name, val) in d {
let def = match val {
serde_json::Value::String(s) => Def::One(s.clone()),
serde_json::Value::Array(_) => {
Def::Many(as_str_list(val, &format!("def '{name}'"))?)
}
_ => return Err(format!("def '{name}' must be a string or a list of strings")),
};
defs.insert(name.clone(), def);
}
}
let mut rules = Vec::new();
let mut seen = std::collections::HashSet::new();
if let Some(r) = obj.get("rules") {
let arr = r.as_array().ok_or("rules must be an array")?;
for (i, entry) in arr.iter().enumerate() {
let o = entry
.as_object()
.ok_or_else(|| format!("rules[{i}] must be an object"))?;
let id = as_str(o.get("id").ok_or_else(|| format!("rules[{i}]: missing id"))?, "id")?;
if id.is_empty() || id.contains(char::is_whitespace) {
return Err(format!("rules[{i}]: invalid id '{id}'"));
}
if !seen.insert(id.clone()) {
return Err(format!("duplicate rule id '{id}'"));
}
let where_ = format!("rule '{id}'");
let question = as_str(
o.get("question")
.ok_or_else(|| format!("{where_}: missing question"))?,
"question",
)?;
let probe = as_str_list(
o.get("probe").ok_or_else(|| format!("{where_}: missing probe"))?,
"probe",
)?;
if probe.is_empty() {
return Err(format!("{where_}: probe must not be empty"));
}
let rule = Rule {
id,
question,
probe,
why: o.get("why").map(|v| as_str(v, "why")).transpose()?,
prompt: o.get("prompt").map(|v| as_str(v, "prompt")).transpose()?,
tags: match o.get("tags") {
Some(v) => as_str_list(v, "tags")?,
None => Vec::new(),
},
added: o.get("added").map(|v| as_str(v, "added")).transpose()?,
timeout: match o.get("timeout") {
Some(v) => Some(v.as_f64().ok_or_else(|| format!("{where_}: timeout must be a number"))?),
None => None,
},
pending: o.get("pending").and_then(|v| v.as_bool()).unwrap_or(false),
severity: match o.get("severity") {
Some(v) => Severity::parse(&as_str(v, "severity")?)
.map_err(|e| format!("{where_}: {e}"))?,
None => Severity::Fail,
},
expect: match o.get("expect") {
Some(v) => Adapter::from_value(v).map_err(|e| format!("{where_}: {e}"))?,
None => Adapter::Exit,
},
network: o.get("network").and_then(|v| v.as_bool()).unwrap_or(false),
};
rules.push(rule);
}
}
Ok(Store { defs, rules })
}
pub fn expand_defs(argv: &[String], defs: &BTreeMap<String, Def>) -> Result<Vec<String>, String> {
let mut out = Vec::with_capacity(argv.len());
for element in argv {
if let Some(name) = element
.strip_prefix("{def:")
.and_then(|r| r.strip_suffix('}'))
&& !name.contains('{')
{
match defs.get(name) {
Some(Def::Many(items)) => {
out.extend(items.iter().cloned());
continue;
}
Some(Def::One(s)) => {
out.push(s.clone());
continue;
}
None => return Err(format!("unknown def '{name}'")),
}
}
let mut text = element.clone();
while let Some(start) = text.find("{def:") {
let rest = &text[start + 5..];
let Some(end) = rest.find('}') else {
break; };
let name = &rest[..end];
match defs.get(name) {
Some(Def::One(s)) => {
text = format!("{}{}{}", &text[..start], s, &rest[end + 1..]);
}
Some(Def::Many(_)) => {
return Err(format!(
"def '{name}' is a list and can only stand alone as one argv element"
));
}
None => return Err(format!("unknown def '{name}'")),
}
}
out.push(text);
}
Ok(out)
}
pub struct BridgeEntry {
pub prefix: &'static [&'static str],
pub enforced: &'static [&'static str],
pub offline_flag: Option<&'static str>,
pub network_meaningful: bool,
}
pub const BRIDGE: &[BridgeEntry] = &[
BridgeEntry {
prefix: &["cargo", "metadata"],
enforced: &["--locked", "--offline", "--format-version", "1"],
offline_flag: None, network_meaningful: false,
},
BridgeEntry {
prefix: &["cargo", "tree"],
enforced: &["--locked"],
offline_flag: Some("--offline"),
network_meaningful: false,
},
BridgeEntry {
prefix: &["cargo", "deny", "check"],
enforced: &[],
offline_flag: Some("--offline"),
network_meaningful: true,
},
BridgeEntry {
prefix: &["rust-analyzer", "search"],
enforced: &[],
offline_flag: None,
network_meaningful: false,
},
BridgeEntry {
prefix: &["rust-analyzer", "symbols"],
enforced: &[],
offline_flag: None,
network_meaningful: false,
},
];
pub enum Gated<'a> {
Observer,
Bridge(&'a BridgeEntry),
}
pub fn gate_probe(argv: &[String]) -> Result<Gated<'static>, String> {
let name = allowlist::gated_name(&argv[0]);
if name == "ct-check" {
return Err("a probe may not run ct-check (no self-recursion through the store)".to_string());
}
if name == "ct-rules" {
return Err("a probe may not run ct-rules (probes observe; they never write)".to_string());
}
if name == "ct-each" {
if argv.iter().any(|a| a == "--mutating") {
return Err("a probe may not pass --mutating (rules observe; they never change anything)"
.to_string());
}
return Ok(Gated::Observer);
}
if allowlist::is_allowed(&name) || name == "ct-test" {
return Ok(Gated::Observer);
}
for entry in BRIDGE {
if name == entry.prefix[0]
&& argv.len() >= entry.prefix.len()
&& argv[1..entry.prefix.len()]
.iter()
.zip(&entry.prefix[1..])
.all(|(a, p)| a == p)
{
return Ok(Gated::Bridge(entry));
}
}
Err(format!(
"'{}' is not a permitted probe: probes run the suite's read-only tools \
or a compiled-in bridge invocation ({}); the gate is immutable",
argv.iter().take(3).cloned().collect::<Vec<_>>().join(" "),
BRIDGE
.iter()
.map(|b| b.prefix.join(" "))
.collect::<Vec<_>>()
.join(", ")
))
}
pub fn bridge_argv(entry: &BridgeEntry, argv: &[String], network: bool) -> Vec<String> {
fn append(out: &mut Vec<String>, flag: &str) {
if !out.iter().any(|a| a == flag) {
out.push(flag.to_string());
}
}
let mut out = argv.to_vec();
let mut i = 0;
while i < entry.enforced.len() {
let flag = entry.enforced[i];
if i + 1 < entry.enforced.len() && !entry.enforced[i + 1].starts_with('-') {
if !argv.iter().any(|a| a == flag) {
out.push(flag.to_string());
out.push(entry.enforced[i + 1].to_string());
}
i += 2;
} else {
append(&mut out, flag);
i += 1;
}
}
if let Some(offline) = entry.offline_flag
&& !(network && entry.network_meaningful)
{
append(&mut out, offline);
}
out
}
pub fn run_probe(
expanded: &[String],
gated: &Gated,
root: &Path,
network: bool,
timeout: Option<std::time::Duration>,
adapter: &Adapter,
) -> (ProbeOutcome, String, crate::supervise::Outcome) {
let argv = match gated {
Gated::Observer => expanded.to_vec(),
Gated::Bridge(entry) => bridge_argv(entry, expanded, network),
};
let name = allowlist::gated_name(&argv[0]);
let mut command = std::process::Command::new(crate::supervise::resolve_program(&argv[0], &name));
command.args(&argv[1..]).current_dir(root);
let empty = || crate::supervise::Outcome {
stdout: String::new(),
stderr: String::new(),
status: None,
timed_out: false,
};
match crate::supervise::run_captured(command, None, timeout) {
Err(e) => (
ProbeOutcome::Broken,
format!("could not launch '{}': {e}", argv[0]),
empty(),
),
Ok(outcome) if outcome.timed_out => {
let label = timeout.map(crate::pulse::limit_label).unwrap_or_default();
(
ProbeOutcome::Broken,
format!("timed out after {label}; probe killed"),
outcome,
)
}
Ok(outcome) => {
let code = outcome.status.and_then(|s| s.code());
let (result, reason) = classify(adapter, code, &outcome.stdout, &outcome.stderr);
(result, reason, outcome)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProbeOutcome {
Holds,
Violated,
Broken,
}
pub fn classify(
adapter: &Adapter,
code: Option<i32>,
stdout: &str,
stderr: &str,
) -> (ProbeOutcome, String) {
let by_exit = |code: Option<i32>| -> (ProbeOutcome, String) {
match code {
Some(0) => (ProbeOutcome::Holds, "probe exited 0".to_string()),
Some(1) => (ProbeOutcome::Violated, "probe exited 1".to_string()),
Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
}
};
match adapter {
Adapter::Exit => by_exit(code),
Adapter::Empty => match code {
Some(0) if stdout.trim().is_empty() => {
(ProbeOutcome::Holds, "probe printed nothing".to_string())
}
Some(0) => (
ProbeOutcome::Violated,
"expect empty: probe printed output".to_string(),
),
Some(c) => (ProbeOutcome::Broken, format!("probe exited {c}")),
None => (ProbeOutcome::Broken, "probe died on a signal".to_string()),
},
Adapter::Match { ok, err } => {
let hit = |pat: &str| -> Result<bool, String> {
Ok(pattern::compile(pat)
.map_err(|e| format!("invalid expect pattern '{pat}': {e}"))?
.is_match(stdout)
|| pattern::compile(pat).unwrap().is_match(stderr))
};
if let Some(p) = err {
match hit(p) {
Ok(true) => {
return (ProbeOutcome::Violated, format!("err-match '{p}' matched"));
}
Ok(false) => {}
Err(e) => return (ProbeOutcome::Broken, e),
}
}
if let Some(p) = ok {
return match hit(p) {
Ok(true) => (ProbeOutcome::Holds, format!("ok-match '{p}' matched")),
Ok(false) => (
ProbeOutcome::Violated,
format!("ok-match '{p}' not found"),
),
Err(e) => (ProbeOutcome::Broken, e),
};
}
by_exit(code)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
// a comment, because the store is JSONC
"defs": {
"layer": "src/domain",
"types": ["A", "B"]
},
"rules": [
{
"id": "one",
"question": "Q1?",
"probe": ["ct-search", "--base", "{def:layer}", "--expect", "none"],
"why": "because",
"tags": ["t1"]
},
{
"id": "two",
"question": "Q2?",
"probe": ["cargo", "tree", "-d"],
"expect": "empty",
"severity": "warn",
"pending": true,
"timeout": 5
}
]
}"#;
#[test]
fn parses_defs_rules_and_optional_fields() {
let store = parse_store(SAMPLE).unwrap();
assert_eq!(store.defs.len(), 2);
assert_eq!(store.rules.len(), 2);
let two = &store.rules[1];
assert_eq!(two.severity, Severity::Warn);
assert!(two.pending);
assert_eq!(two.expect, Adapter::Empty);
assert_eq!(two.timeout, Some(5.0));
assert_eq!(store.rules[0].severity, Severity::Fail);
assert_eq!(store.rules[0].expect, Adapter::Exit);
}
#[test]
fn rejects_duplicates_and_malformed_entries() {
let dup = r#"{"rules":[
{"id":"x","question":"q","probe":["ls"]},
{"id":"x","question":"q","probe":["ls"]}]}"#;
assert!(parse_store(dup).unwrap_err().contains("duplicate rule id"));
let bad = r#"{"rules":[{"id":"x","question":"q","probe":[]}]}"#;
assert!(parse_store(bad).unwrap_err().contains("must not be empty"));
let unknown = r#"{"stuff": 1}"#;
assert!(parse_store(unknown).unwrap_err().contains("unknown store key"));
let badsev = r#"{"rules":[{"id":"x","question":"q","probe":["ls"],"severity":"high"}]}"#;
assert!(parse_store(badsev).unwrap_err().contains("invalid severity"));
}
#[test]
fn adapter_parsing_accepts_strings_and_matcher_objects() {
assert_eq!(Adapter::from_value(&serde_json::json!("exit")).unwrap(), Adapter::Exit);
assert_eq!(Adapter::from_value(&serde_json::json!("empty")).unwrap(), Adapter::Empty);
let m = Adapter::from_value(&serde_json::json!({"ok-match": "fine"})).unwrap();
assert_eq!(m, Adapter::Match { ok: Some("fine".into()), err: None });
assert!(Adapter::from_value(&serde_json::json!("sometimes")).is_err());
assert!(Adapter::from_value(&serde_json::json!({})).is_err());
assert!(Adapter::from_value(&serde_json::json!({"oops": "x"})).is_err());
}
#[test]
fn gate_admits_observers_and_bridge_only() {
let argv = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert!(matches!(gate_probe(&argv(&["ct-outline", "--base", "."])), Ok(Gated::Observer)));
assert!(matches!(gate_probe(&argv(&["ct-test", "--cmd", "cat"])), Ok(Gated::Observer)));
assert!(matches!(gate_probe(&argv(&["cargo", "deny", "check", "bans"])), Ok(Gated::Bridge(_))));
assert!(matches!(gate_probe(&argv(&["rust-analyzer", "symbols"])), Ok(Gated::Bridge(_))));
assert!(gate_probe(&argv(&["ct-edit", "--find", "a", "--replace", "b"])).is_err());
assert!(gate_probe(&argv(&["cargo", "build"])).is_err());
assert!(gate_probe(&argv(&["cargo"])).is_err());
assert!(gate_probe(&argv(&["sh", "-c", "true"])).is_err());
}
#[test]
fn classify_exit_empty_and_matchers() {
use ProbeOutcome::*;
assert_eq!(classify(&Adapter::Exit, Some(0), "", "").0, Holds);
assert_eq!(classify(&Adapter::Exit, Some(1), "", "").0, Violated);
assert_eq!(classify(&Adapter::Exit, Some(101), "", "").0, Broken);
assert_eq!(classify(&Adapter::Empty, Some(0), " \n", "").0, Holds);
assert_eq!(classify(&Adapter::Empty, Some(0), "dupe v1\n", "").0, Violated);
assert_eq!(classify(&Adapter::Empty, Some(2), "", "").0, Broken);
let m = Adapter::Match { ok: Some("did not match any packages".into()), err: None };
assert_eq!(classify(&m, Some(101), "", "error: ... did not match any packages").0, Holds);
let m = Adapter::Match { ok: None, err: Some("^openssl".into()) };
assert_eq!(classify(&m, Some(0), "openssl v1.0\n", "").0, Violated);
assert_eq!(classify(&m, Some(0), "clean", "").0, Holds);
let m = Adapter::Match { ok: Some("proof".into()), err: None };
assert_eq!(classify(&m, Some(0), "no luck", "").0, Violated);
}
}