mod common;
use std::path::{Path, PathBuf};
use common::{rsigma, temp_file};
use predicates::prelude::*;
const REPORT_GOLDEN: &str = include_str!("golden/coverage_report.json");
const LAYER_GOLDEN: &str = include_str!("golden/coverage_layer.json");
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/coverage")
}
fn fixture(name: &str) -> String {
fixtures().join(name).to_string_lossy().into_owned()
}
fn normalize_eol(s: &str) -> String {
s.replace("\r\n", "\n")
}
#[test]
fn coverage_report_matches_golden() {
let output = rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--atomics",
&fixture("atomics.yaml"),
"--baseline",
&fixture("baseline.json"),
"--targets",
&fixture("targets.txt"),
"--output-format",
"json",
])
.assert()
.success()
.get_output()
.stdout
.clone();
let actual: serde_json::Value = serde_json::from_slice(&output).expect("stdout is valid JSON");
let expected: serde_json::Value =
serde_json::from_str(REPORT_GOLDEN).expect("golden is valid JSON");
assert_eq!(actual, expected, "coverage report drifted from golden");
}
#[test]
fn coverage_navigator_layer_matches_golden() {
let layer = tempfile::Builder::new().suffix(".json").tempfile().unwrap();
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--navigator",
layer.path().to_str().unwrap(),
"--output-format",
"json",
])
.assert()
.success();
let actual = std::fs::read_to_string(layer.path()).unwrap();
assert_eq!(
normalize_eol(&actual).trim_end(),
normalize_eol(LAYER_GOLDEN).trim_end(),
"Navigator layer drifted from golden"
);
}
#[test]
fn coverage_fail_on_gaps_exits_one_with_uncovered_target() {
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--targets",
&fixture("targets.txt"),
"--fail-on-gaps",
"--output-format",
"table",
])
.assert()
.code(1)
.stdout(predicate::str::contains("uncovered"));
}
#[test]
fn coverage_fail_on_gaps_clean_when_all_targets_covered() {
let targets = temp_file(".txt", "T1059\nT1047\n");
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--targets",
targets.path().to_str().unwrap(),
"--fail-on-gaps",
])
.assert()
.success();
}
#[test]
fn coverage_table_is_human_readable() {
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--output-format",
"table",
])
.assert()
.success()
.stdout(predicate::str::contains("Coverage summary"))
.stdout(predicate::str::contains("T1059.001"));
}
#[test]
fn coverage_atomics_directory_clone() {
let dir = tempfile::tempdir().unwrap();
let atomics = dir.path().join("atomics");
std::fs::create_dir_all(atomics.join("T1059")).unwrap();
std::fs::write(
atomics.join("T1059").join("T1059.yaml"),
"attack_technique: T1059\ndisplay_name: Command and Scripting Interpreter\n",
)
.unwrap();
std::fs::create_dir_all(atomics.join("T1566")).unwrap();
std::fs::write(
atomics.join("T1566").join("T1566.yaml"),
"attack_technique: T1566\ndisplay_name: Phishing\n",
)
.unwrap();
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--atomics",
atomics.to_str().unwrap(),
"--output-format",
"json",
])
.assert()
.success()
.stdout(predicate::str::contains(
"\"atomics_without_rule\":[\"T1566\"]",
));
}
#[test]
fn coverage_warns_on_parse_errors_but_still_reports_valid_rules() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("good.yml"),
"title: Good\nid: 00000000-0000-0000-0000-0000000000e1\n\
logsource: {category: process_creation, product: windows}\n\
detection: {sel: {Image: a}, condition: sel}\ntags: [attack.t1059]\n",
)
.unwrap();
std::fs::write(
dir.path().join("bad.yml"),
"title: Missing Detection\nid: 00000000-0000-0000-0000-0000000000e2\n\
logsource: {category: process_creation, product: windows}\n",
)
.unwrap();
rsigma()
.args([
"rule",
"coverage",
"--rules",
dir.path().to_str().unwrap(),
"--output-format",
"json",
])
.assert()
.success()
.stderr(predicate::str::contains("parse errors"))
.stdout(predicate::str::contains("\"id\":\"T1059\""));
}
#[test]
fn coverage_missing_rules_is_config_error() {
rsigma()
.args(["rule", "coverage"])
.assert()
.code(3)
.stderr(predicate::str::contains("no rules path"));
}
#[test]
fn coverage_bad_rules_path_is_rule_error() {
rsigma()
.args(["rule", "coverage", "--rules", "/no/such/rules/path.yml"])
.assert()
.code(2);
}
#[test]
fn coverage_unreadable_atomics_is_config_error() {
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--atomics",
"/no/such/atomics/index.yaml",
])
.assert()
.code(3);
}
#[test]
fn coverage_ndjson_emits_per_technique_rows() {
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--output-format",
"ndjson",
])
.assert()
.success()
.stdout(predicate::str::contains("\"id\":\"T1047\""))
.stdout(predicate::str::contains("\"id\":\"T1218.001\""));
}
#[test]
fn coverage_config_file_layering() {
let cfg = temp_file(
".yaml",
&format!(
"coverage:\n atomics: {}\n targets: {}\n fail_on_gaps: true\n",
fixture("atomics.yaml"),
fixture("targets.txt"),
),
);
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--config",
cfg.path().to_str().unwrap(),
"--output-format",
"table",
])
.assert()
.code(1)
.stdout(predicate::str::contains("Atomic Red Team"));
}
#[test]
fn coverage_reads_rules_from_config() {
let cfg = temp_file(
".yaml",
&format!("coverage:\n rules:\n - {}\n", fixture("rules.yml")),
);
rsigma()
.args([
"rule",
"coverage",
"--config",
cfg.path().to_str().unwrap(),
"--output-format",
"table",
])
.assert()
.success()
.stdout(predicate::str::contains("Coverage summary"));
}
#[test]
fn coverage_dry_run_prints_config_section() {
rsigma()
.args([
"rule",
"coverage",
"--rules",
&fixture("rules.yml"),
"--dry-run",
])
.assert()
.success();
}