use std::collections::HashSet;
use std::fmt::Write;
use std::fs;
use std::path::Path;
use destructive_command_guard::packs::REGISTRY;
use destructive_command_guard::packs::test_helpers::{
CorpusCategory, CorpusTestCase, EvalSnapshot, diff_snapshots, load_corpus_dir,
verify_corpus_case,
};
use destructive_command_guard::{Config, LayeredAllowlist, evaluate_command_with_pack_order};
fn load_all_cases() -> Vec<(CorpusCategory, String, CorpusTestCase)> {
let corpus_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
load_corpus_dir(&corpus_dir).expect("Failed to load corpus directory")
}
fn run_category_tests(category: CorpusCategory) -> (usize, Vec<String>) {
let all_cases = load_all_cases();
let category_cases: Vec<_> = all_cases
.iter()
.filter(|(cat, _, _)| *cat == category)
.collect();
let total = category_cases.len();
let mut failures = Vec::new();
for (cat, file, case) in category_cases {
if let Err(msg) = verify_corpus_case(case, *cat) {
failures.push(format!("[{file}] {msg}"));
}
}
(total, failures)
}
#[derive(Default)]
struct ScenarioStep {
command: Option<String>,
expected_pack: Option<String>,
expected_decision: Option<String>,
reason: Option<String>,
}
struct ScenarioFixture {
id: Option<String>,
description: Option<String>,
steps: Vec<ScenarioStep>,
}
fn split_key_value(line: &str) -> Option<(&str, &str)> {
let (key, value) = line.split_once(':')?;
Some((key.trim(), value.trim()))
}
fn normalize_value(value: &str) -> Option<String> {
if value.is_empty() {
return None;
}
let trimmed = value.trim();
let unquoted = if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
};
if unquoted.is_empty() {
None
} else {
Some(unquoted.to_string())
}
}
#[allow(clippy::too_many_lines)]
fn parse_scenario_fixture(path: &Path) -> Result<ScenarioFixture, String> {
let contents = fs::read_to_string(path)
.map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
let mut fixture = ScenarioFixture {
id: None,
description: None,
steps: Vec::new(),
};
let mut current_step: Option<ScenarioStep> = None;
let mut in_steps = false;
for (idx, line) in contents.lines().enumerate() {
let line_no = idx + 1;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let indent = line.chars().take_while(|c| c.is_whitespace()).count();
if indent == 0 {
in_steps = false;
let Some((key, value)) = split_key_value(trimmed) else {
return Err(format!(
"{}:{}: expected top-level key:value pair",
path.display(),
line_no
));
};
match key {
"id" => {
fixture.id = normalize_value(value);
if fixture.id.is_none() {
return Err(format!(
"{}:{}: id value must be non-empty",
path.display(),
line_no
));
}
}
"description" => {
fixture.description = normalize_value(value);
if fixture.description.is_none() {
return Err(format!(
"{}:{}: description value must be non-empty",
path.display(),
line_no
));
}
}
"steps" => {
in_steps = true;
}
_ => {
return Err(format!(
"{}:{}: unexpected top-level key '{key}'",
path.display(),
line_no
));
}
}
continue;
}
if !in_steps {
return Err(format!(
"{}:{}: step field found before steps section",
path.display(),
line_no
));
}
if let Some(rest) = trimmed.strip_prefix("- ") {
let Some((key, value)) = split_key_value(rest) else {
return Err(format!(
"{}:{}: expected step key:value pair",
path.display(),
line_no
));
};
if key != "command" {
return Err(format!(
"{}:{}: step must start with command field",
path.display(),
line_no
));
}
if let Some(step) = current_step.take() {
fixture.steps.push(step);
}
current_step = Some(ScenarioStep {
command: normalize_value(value),
expected_pack: None,
expected_decision: None,
reason: None,
});
if current_step
.as_ref()
.and_then(|step| step.command.as_ref())
.is_none()
{
return Err(format!(
"{}:{}: command value must be non-empty",
path.display(),
line_no
));
}
continue;
}
let Some((key, value)) = split_key_value(trimmed) else {
return Err(format!(
"{}:{}: expected step key:value pair",
path.display(),
line_no
));
};
let step = current_step.as_mut().ok_or_else(|| {
format!(
"{}:{}: step field without a command",
path.display(),
line_no
)
})?;
match key {
"expected_pack" => step.expected_pack = normalize_value(value),
"expected_decision" => step.expected_decision = normalize_value(value),
"reason" => step.reason = normalize_value(value),
_ => {
return Err(format!(
"{}:{}: unexpected step key '{key}'",
path.display(),
line_no
));
}
}
}
if let Some(step) = current_step.take() {
fixture.steps.push(step);
}
if fixture.id.is_none() {
return Err(format!("{}: missing id", path.display()));
}
if fixture.description.is_none() {
return Err(format!("{}: missing description", path.display()));
}
if fixture.steps.is_empty() {
return Err(format!("{}: no steps found", path.display()));
}
for (idx, step) in fixture.steps.iter().enumerate() {
if step.command.as_deref().unwrap_or("").is_empty() {
return Err(format!(
"{}: step {} missing command",
path.display(),
idx + 1
));
}
if step.expected_pack.as_deref().unwrap_or("").is_empty() {
return Err(format!(
"{}: step {} missing expected_pack",
path.display(),
idx + 1
));
}
match step.expected_decision.as_deref() {
Some("allow" | "deny") => {}
Some(other) => {
return Err(format!(
"{}: step {} invalid expected_decision '{other}'",
path.display(),
idx + 1
));
}
None => {
return Err(format!(
"{}: step {} missing expected_decision",
path.display(),
idx + 1
));
}
}
if step.reason.as_deref().unwrap_or("").is_empty() {
return Err(format!(
"{}: step {} missing reason",
path.display(),
idx + 1
));
}
}
Ok(fixture)
}
#[test]
fn keyword_index_matches_legacy_might_match_on_regression_corpus() {
let config = Config::default();
let enabled_packs = config.enabled_pack_ids();
let enabled_keywords = REGISTRY.collect_enabled_keywords(&enabled_packs);
let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
let keyword_index = REGISTRY
.build_enabled_keyword_index(&ordered_packs)
.expect("keyword index should build for enabled pack set");
let compiled_overrides = config.overrides.compile();
let allowlists = LayeredAllowlist::default();
let heredoc_settings = config.heredoc_settings();
let all_cases = load_all_cases();
let mut saw_substring_digit = false;
let mut saw_wrapper_prefix = false;
let mut saw_quoted_command_word = false;
let mut failures = Vec::new();
for (category, file, case) in &all_cases {
let command = case.command.as_str();
let with_index = EvalSnapshot::from_result(
command,
&evaluate_command_with_pack_order(
command,
&enabled_keywords,
&ordered_packs,
Some(&keyword_index),
&compiled_overrides,
&allowlists,
&heredoc_settings,
),
);
let legacy = EvalSnapshot::from_result(
command,
&evaluate_command_with_pack_order(
command,
&enabled_keywords,
&ordered_packs,
None,
&compiled_overrides,
&allowlists,
&heredoc_settings,
),
);
saw_substring_digit |= command.starts_with("digit ");
saw_wrapper_prefix |= command.starts_with("sudo ")
|| command.starts_with("env ")
|| command.starts_with("command ")
|| command.starts_with("nohup ")
|| command.starts_with("time ");
saw_quoted_command_word |= command.contains("'git")
|| command.contains("\"git")
|| command.contains("'rm")
|| command.contains("\"rm");
if with_index != legacy {
let diff = diff_snapshots(&legacy, &with_index).unwrap_or_else(|| {
" (snapshots differed but no field-level diff was produced)".to_string()
});
failures.push(format!(
"[{file}] {category:?}: {desc}\n command: {command}\n\nDiff:\n{diff}",
desc = case.description
));
}
}
assert!(
saw_substring_digit,
"regression corpus should include a substring case like 'digit 123'"
);
assert!(
saw_wrapper_prefix,
"regression corpus should include wrapper-prefix cases (sudo/env/command/time/nohup)"
);
assert!(
saw_quoted_command_word,
"regression corpus should include quoted command-word cases"
);
assert!(
failures.is_empty(),
"Keyword index diverged from legacy PackEntry::might_match filtering ({} failure(s)):\n\n{}",
failures.len(),
failures.join("\n\n---\n\n")
);
}
#[test]
fn keyword_quick_reject_empty_keywords_is_conservative_end_to_end() {
let config = Config::default();
let enabled_packs = config.enabled_pack_ids();
let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
let keyword_index = REGISTRY
.build_enabled_keyword_index(&ordered_packs)
.expect("keyword index should build for enabled pack set");
let compiled_overrides = config.overrides.compile();
let allowlists = LayeredAllowlist::default();
let heredoc_settings = config.heredoc_settings();
let empty_keywords: [&str; 0] = [];
let destructive_commands = ["git reset --hard", "rm -rf src"];
for command in destructive_commands {
let with_index = evaluate_command_with_pack_order(
command,
&empty_keywords,
&ordered_packs,
Some(&keyword_index),
&compiled_overrides,
&allowlists,
&heredoc_settings,
);
let legacy = evaluate_command_with_pack_order(
command,
&empty_keywords,
&ordered_packs,
None,
&compiled_overrides,
&allowlists,
&heredoc_settings,
);
assert_eq!(
EvalSnapshot::from_result(command, &legacy).decision,
"deny",
"empty keyword list must not allow skipping pack evaluation"
);
assert_eq!(
EvalSnapshot::from_result(command, &with_index).decision,
"deny",
"empty keyword list must not allow skipping pack evaluation"
);
assert_eq!(
EvalSnapshot::from_result(command, &legacy),
EvalSnapshot::from_result(command, &with_index),
"index and legacy filtering must stay equivalent even with empty enabled_keywords"
);
}
}
#[test]
fn corpus_true_positives_isomorphism() {
let (total, failures) = run_category_tests(CorpusCategory::TruePositives);
if !failures.is_empty() {
let mut msg = format!(
"\n{}/{} true positive test(s) failed:\n",
failures.len(),
total
);
for failure in &failures {
let _ = writeln!(msg, " {failure}");
}
panic!("{msg}");
}
println!("All {total} true positive tests passed with full isomorphism check");
}
#[test]
fn corpus_false_positives_isomorphism() {
let (total, failures) = run_category_tests(CorpusCategory::FalsePositives);
if !failures.is_empty() {
let mut msg = format!(
"\n{}/{} false positive test(s) failed:\n",
failures.len(),
total
);
for failure in &failures {
let _ = writeln!(msg, " {failure}");
}
panic!("{msg}");
}
println!("All {total} false positive tests passed with full isomorphism check");
}
#[test]
fn corpus_bypass_attempts_isomorphism() {
let (total, failures) = run_category_tests(CorpusCategory::BypassAttempts);
if !failures.is_empty() {
let mut msg = format!(
"\n{}/{} bypass attempt test(s) failed:\n",
failures.len(),
total
);
for failure in &failures {
let _ = writeln!(msg, " {failure}");
}
panic!("{msg}");
}
println!("All {total} bypass attempt tests passed with full isomorphism check");
}
#[test]
fn corpus_edge_cases_isomorphism() {
let (total, failures) = run_category_tests(CorpusCategory::EdgeCases);
if !failures.is_empty() {
let mut msg = format!("\n{}/{} edge case test(s) failed:\n", failures.len(), total);
for failure in &failures {
let _ = writeln!(msg, " {failure}");
}
panic!("{msg}");
}
println!("All {total} edge case tests passed with full isomorphism check");
}
#[test]
fn corpus_full_summary() {
let all_cases = load_all_cases();
let mut passed = 0;
let mut failed = 0;
let mut failures = Vec::new();
for (category, file, case) in &all_cases {
match verify_corpus_case(case, *category) {
Ok(()) => passed += 1,
Err(msg) => {
failed += 1;
failures.push(format!("[{file}] {msg}"));
}
}
}
println!("\n=== Corpus Isomorphism Test Summary ===");
println!(
"Total: {} tests ({} passed, {} failed)",
all_cases.len(),
passed,
failed
);
let mut by_category: std::collections::HashMap<CorpusCategory, (usize, usize)> =
std::collections::HashMap::new();
for (category, _file, case) in &all_cases {
let entry = by_category.entry(*category).or_insert((0, 0));
entry.0 += 1;
if verify_corpus_case(case, *category).is_ok() {
entry.1 += 1;
}
}
println!();
for (category, (total, cat_passed)) in by_category {
let status = if cat_passed == total { "OK" } else { "FAIL" };
println!(" {category:?}: {cat_passed}/{total} [{status}]");
}
if !failures.is_empty() {
println!("\nFailures (with reproduction commands):");
for failure in &failures {
println!(" {failure}");
}
panic!("\n{} corpus test(s) failed", failures.len());
}
}
#[test]
fn scenario_fixtures_are_valid() {
let scenario_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/scenarios");
let mut entries: Vec<_> = fs::read_dir(&scenario_dir)
.expect("Failed to read scenario fixtures directory")
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("yaml"))
.collect();
entries.sort();
assert!(
!entries.is_empty(),
"No scenario fixtures found in {}",
scenario_dir.display()
);
let mut ids = HashSet::new();
let mut failures = Vec::new();
for path in entries {
match parse_scenario_fixture(&path) {
Ok(fixture) => {
let id = fixture.id.expect("id should be present");
if !ids.insert(id.clone()) {
failures.push(format!("{}: duplicate id '{id}'", path.display()));
}
}
Err(err) => failures.push(err),
}
}
if !failures.is_empty() {
let mut msg = String::from("\nScenario fixture validation failed:\n");
for failure in &failures {
let _ = writeln!(msg, " {failure}");
}
panic!("{msg}");
}
}