keyspoor 0.1.3

Offline secret scanner and Rust library for Git, CI and AI agents, with MCP, JSONL and SARIF
Documentation
use std::fs;
use std::io::Write;
use std::process::{Command, Stdio};
use tempfile::TempDir;

fn setup() -> (TempDir, std::path::PathBuf) {
    let dir = TempDir::new().unwrap();
    let rule = dir.path().join("rules.json");
    fs::write(&rule, r#"{"rules":[{"id":"canary","name":"Test canary","pattern":"CANARY_[a-z0-9]{16}","keywords":["CANARY_"],"confidence":"high"}]}"#).unwrap();
    (dir, rule)
}

fn cli(rule: &std::path::Path) -> Command {
    let mut cmd = Command::new(env!("CARGO_BIN_EXE_keyspoor"));
    cmd.args(["--no-builtin", "--rules"]).arg(rule);
    cmd
}

#[test]
fn cli_distinguishes_clean_finding_and_missing_input_and_redacts() {
    let (dir, rule) = setup();
    let path = dir.path().join("input.txt");
    fs::write(&path, "ordinary text\n").unwrap();
    let clean = cli(&rule).arg("scan").arg(&path).output().unwrap();
    assert_eq!(clean.status.code(), Some(0));
    fs::write(&path, "CANARY_abc123def456gh78\n").unwrap();
    let finding = cli(&rule).arg("scan").arg(&path).output().unwrap();
    assert_eq!(finding.status.code(), Some(1));
    let text = String::from_utf8(finding.stdout).unwrap();
    assert!(!text.contains("abc123def456gh78"));
    let report: serde_json::Value = serde_json::from_str(&text).unwrap();
    assert_eq!(report["complete"], true);
    assert_eq!(report["findings"].as_array().unwrap().len(), 1);
    let missing = cli(&rule)
        .arg("scan")
        .arg(dir.path().join("absent"))
        .output()
        .unwrap();
    assert_eq!(missing.status.code(), Some(2));
    let report: serde_json::Value = serde_json::from_slice(&missing.stdout).unwrap();
    assert_eq!(report["complete"], false);
}

#[test]
fn baseline_suppresses_existing_but_not_changed_secret_and_survives_error() {
    let (dir, rule) = setup();
    let path = dir.path().join("input.txt");
    let baseline = dir.path().join("baseline.json");
    fs::write(&path, "CANARY_abc123def456gh78\n").unwrap();
    let saved = cli(&rule)
        .arg("scan")
        .arg(&path)
        .arg("--write-baseline")
        .arg(&baseline)
        .output()
        .unwrap();
    assert_eq!(saved.status.code(), Some(1));
    let original = fs::read(&baseline).unwrap();
    let same = cli(&rule)
        .arg("scan")
        .arg(&path)
        .arg("--baseline")
        .arg(&baseline)
        .output()
        .unwrap();
    assert_eq!(same.status.code(), Some(0));
    fs::write(&path, "CANARY_def123abc456gh78\n").unwrap();
    let new = cli(&rule)
        .arg("scan")
        .arg(&path)
        .arg("--baseline")
        .arg(&baseline)
        .output()
        .unwrap();
    assert_eq!(new.status.code(), Some(1));
    let failed = cli(&rule)
        .arg("scan")
        .arg(dir.path().join("absent"))
        .arg("--write-baseline")
        .arg(&baseline)
        .output()
        .unwrap();
    assert_eq!(failed.status.code(), Some(2));
    assert_eq!(fs::read(&baseline).unwrap(), original);
}

#[test]
fn persistent_scanner_recovers_after_malformed_request_without_echoing_it() {
    let (_dir, rule) = setup();
    let mut child = cli(&rule)
        .arg("serve")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .unwrap();
    {
        let input = child.stdin.as_mut().unwrap();
        writeln!(input, "THIS_IS_SENSITIVE_BUT_INVALID_JSON").unwrap();
        writeln!(
            input,
            "{}",
            serde_json::json!({"id":7,"path":"test.txt","text":"CANARY_abc123def456gh78"})
        )
        .unwrap();
        writeln!(input, "{}", serde_json::json!({"id":8,"text":"clean"})).unwrap();
    }
    drop(child.stdin.take());
    let result = child.wait_with_output().unwrap();
    assert!(result.status.success());
    let text = String::from_utf8(result.stdout).unwrap();
    assert!(!text.contains("SENSITIVE"));
    assert!(!text.contains("abc123def456gh78"));
    let lines: Vec<serde_json::Value> = text
        .lines()
        .map(|line| serde_json::from_str(line).unwrap())
        .collect();
    assert_eq!(lines.len(), 3);
    assert!(lines[0]["error"].is_string());
    assert_eq!(lines[1]["result"]["findings"].as_array().unwrap().len(), 1);
    assert_eq!(lines[2]["result"]["findings"].as_array().unwrap().len(), 0);
}

#[test]
fn baseline_rejects_narrowed_scope_and_policy_changes_without_overwrite() {
    let (dir, rule) = setup();
    let root = dir.path().join("source");
    fs::create_dir_all(root.join("clean")).unwrap();
    fs::write(root.join("secret.txt"), "CANARY_abc123def456gh78").unwrap();
    let baseline = dir.path().join("baseline.json");
    assert_eq!(
        cli(&rule)
            .arg("scan")
            .arg(&root)
            .arg("--write-baseline")
            .arg(&baseline)
            .output()
            .unwrap()
            .status
            .code(),
        Some(1)
    );
    let original = fs::read(&baseline).unwrap();
    for switch in ["--baseline", "--write-baseline"] {
        let output = cli(&rule)
            .arg("scan")
            .arg(root.join("clean"))
            .arg(switch)
            .arg(&baseline)
            .output()
            .unwrap();
        assert_eq!(output.status.code(), Some(2));
        assert_eq!(fs::read(&baseline).unwrap(), original);
    }
    for extra in ["--no-ignore", "--no-decode"] {
        let output = cli(&rule)
            .arg("scan")
            .arg(&root)
            .arg(extra)
            .arg("--write-baseline")
            .arg(&baseline)
            .output()
            .unwrap();
        assert_eq!(output.status.code(), Some(2));
        assert_eq!(fs::read(&baseline).unwrap(), original);
    }
    fs::write(root.join(".ignore"), "secret.txt\n").unwrap();
    let output = cli(&rule)
        .arg("scan")
        .arg(&root)
        .arg("--write-baseline")
        .arg(&baseline)
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(2));
    assert_eq!(fs::read(&baseline).unwrap(), original);
}

#[test]
fn jsonl_events_preserve_findings_errors_and_exit_contract() {
    let (dir, rule) = setup();
    let input = dir.path().join("input.txt");
    fs::write(&input, "CANARY_abc123def456gh78").unwrap();
    let output = cli(&rule)
        .arg("scan")
        .arg(&input)
        .arg("--format=jsonl")
        .output()
        .unwrap();
    assert_eq!(output.status.code(), Some(1));
    let events: Vec<serde_json::Value> = String::from_utf8(output.stdout)
        .unwrap()
        .lines()
        .map(|s| serde_json::from_str(s).unwrap())
        .collect();
    assert_eq!(events.iter().filter(|e| e["type"] == "finding").count(), 1);
    let summary = events.last().unwrap();
    assert_eq!(summary["type"], "summary");
    assert_eq!(summary["complete"], true);
    assert_eq!(summary["finding_count"], 1);
    assert_eq!(summary["error_count"], 0);
    let missing = cli(&rule)
        .arg("scan")
        .arg(dir.path().join("missing"))
        .arg("--format=jsonl")
        .output()
        .unwrap();
    assert_eq!(missing.status.code(), Some(2));
    let events: Vec<serde_json::Value> = String::from_utf8(missing.stdout)
        .unwrap()
        .lines()
        .map(|s| serde_json::from_str(s).unwrap())
        .collect();
    assert!(events.iter().any(|e| e["type"] == "error"));
    assert_eq!(events.last().unwrap()["complete"], false);
}

#[test]
fn history_range_is_rejected_for_file_scans() {
    let (_dir, rule) = setup();
    assert_eq!(
        cli(&rule)
            .args(["scan", "--range", "HEAD~1..HEAD"])
            .output()
            .unwrap()
            .status
            .code(),
        Some(2)
    );
}