use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn binary() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_keyhog"))
}
const AWS_KEY_FIXTURE: &str = concat!("AWS_ACCESS_KEY_ID = \"AKIA", "QYLPMN5HFIQR7XYA\"\n");
const CLEAN_FIXTURE: &str = "fn main() { println!(\"hello, world\"); }\n";
fn scan_with_format(content: &str, fmt: &str) -> (String, String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.env");
std::fs::write(&path, content).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg(fmt)
.arg(&path)
.output()
.unwrap_or_else(|e| panic!("spawn keyhog scan --format {fmt}: {e}"));
(
String::from_utf8_lossy(&output.stdout).into_owned(),
String::from_utf8_lossy(&output.stderr).into_owned(),
output.status.code(),
)
}
fn scan_to_output_file(content: &str, fmt: &str) -> (String, Option<i32>) {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.env");
std::fs::write(&path, content).expect("write fixture");
let out = dir.path().join("report.out");
let output = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg(fmt)
.arg("--output")
.arg(&out)
.arg(&path)
.output()
.unwrap_or_else(|e| panic!("spawn keyhog scan --format {fmt} --output: {e}"));
let bytes = std::fs::read_to_string(&out)
.unwrap_or_else(|e| panic!("read --output file for {fmt}: {e}"));
(bytes, output.status.code())
}
#[test]
fn every_format_exits_0_on_clean_corpus() {
for fmt in ["text", "json", "jsonl", "sarif", "csv", "html", "junit"] {
let (_stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, fmt);
assert_eq!(
code,
Some(0),
"format `{fmt}` on a clean corpus must exit 0; stderr={stderr}"
);
}
}
#[test]
fn every_format_exits_1_on_planted_finding() {
for fmt in ["text", "json", "jsonl", "sarif", "csv", "html", "junit"] {
let (_stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, fmt);
assert_eq!(
code,
Some(1),
"format `{fmt}` with a planted unverified finding must exit 1 \
(not 10/live, not 0/clean); stderr={stderr}"
);
}
}
#[test]
fn planted_finding_is_never_live_or_panic_exit() {
for fmt in ["text", "json", "jsonl", "sarif", "csv", "html", "junit"] {
let (_stdout, _stderr, code) = scan_with_format(AWS_KEY_FIXTURE, fmt);
assert_ne!(
code,
Some(10),
"format `{fmt}` must not report live (10) without --verify"
);
assert_ne!(code, Some(11), "format `{fmt}` must not report panic (11)");
assert_ne!(
code,
Some(2),
"format `{fmt}` must not report user/config error (2)"
);
assert_ne!(
code,
Some(3),
"format `{fmt}` must not report system error (3)"
);
}
}
#[test]
fn json_empty_corpus_is_exact_empty_array() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "json");
assert_eq!(
code,
Some(0),
"clean json scan must exit 0; stderr={stderr}"
);
assert_eq!(
stdout, "[]",
"empty JSON report must be exactly `[]` with no whitespace/newline; got {stdout:?}"
);
}
#[test]
fn json_empty_corpus_parses_to_empty_array() {
let (stdout, _stderr, _code) = scan_with_format(CLEAN_FIXTURE, "json");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("empty JSON parses");
assert_eq!(
v.as_array().map(Vec::len),
Some(0),
"empty JSON must parse to a zero-length array; got {v}"
);
}
#[test]
fn json_planted_finding_is_valid_array_with_contract_fields() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "json");
assert_eq!(
code,
Some(1),
"json planted scan must exit 1; stderr={stderr}"
);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("planted JSON parses");
let arr = v.as_array().expect("JSON report is an array");
assert!(
!arr.is_empty(),
"planted finding must produce >=1 JSON object"
);
for f in arr {
for field in [
"detector_id",
"detector_name",
"service",
"severity",
"credential_redacted",
"credential_hash",
"location",
"verification",
] {
assert!(
f.get(field).is_some(),
"JSON finding missing `{field}`: {f}"
);
}
}
}
#[test]
fn json_planted_finding_is_aws_detection() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "json");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("JSON parses");
let arr = v.as_array().expect("array");
let aws = arr.iter().any(|f| {
matches!(
f.get("detector_id").and_then(|x| x.as_str()),
Some("aws-access-key" | "hot-aws_key")
)
});
assert!(aws, "expected an AWS detection in JSON output; got {arr:?}");
}
#[test]
fn json_unverified_finding_serializes_skipped() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "json");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("JSON parses");
let arr = v.as_array().expect("array");
for f in arr {
let verification = f.get("verification").expect("verification field");
assert_eq!(
verification.as_str(),
Some("skipped"),
"unverified finding must serialize verification as \"skipped\"; got {verification}"
);
}
}
#[test]
fn json_credential_is_redacted_not_plaintext() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "json");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("JSON parses");
let arr = v.as_array().expect("array");
let aws = arr
.iter()
.find(|f| {
matches!(
f.get("detector_id").and_then(|x| x.as_str()),
Some("aws-access-key" | "hot-aws_key")
)
})
.expect("aws finding present");
let red = aws
.get("credential_redacted")
.and_then(|v| v.as_str())
.expect("credential_redacted string");
assert_eq!(
red, "AKIA...7XYA",
"redact() of the 20-char AKIA key must be first4...last4; got {red:?}"
);
assert!(
!stdout.contains("AKIAQYLPMN5HFIQR7XYA"),
"plaintext credential must not leak into JSON without --show-secrets"
);
}
#[test]
fn json_output_file_clean_is_exact_empty_array() {
let (bytes, code) = scan_to_output_file(CLEAN_FIXTURE, "json");
assert_eq!(code, Some(0));
assert_eq!(
bytes, "[]",
"JSON --output file for a clean scan must be exactly `[]`; got {bytes:?}"
);
}
#[test]
fn jsonl_empty_corpus_is_empty_output() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "jsonl");
assert_eq!(
code,
Some(0),
"clean jsonl scan must exit 0; stderr={stderr}"
);
assert_eq!(
stdout, "",
"empty JSONL report must be completely empty (no `[]`, no newline); got {stdout:?}"
);
}
#[test]
fn jsonl_planted_finding_is_one_object_per_line() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "jsonl");
assert_eq!(
code,
Some(1),
"jsonl planted scan must exit 1; stderr={stderr}"
);
assert!(
stdout.ends_with('\n'),
"JSONL output must end with a newline after the final object; got {stdout:?}"
);
let lines: Vec<&str> = stdout.lines().filter(|l| !l.trim().is_empty()).collect();
assert!(
!lines.is_empty(),
"planted finding must produce >=1 JSONL line"
);
for line in &lines {
let obj: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("each JSONL line is an object: {e}; line={line:?}"));
assert!(
obj.is_object(),
"each JSONL line must be a JSON object, not an array; got {obj}"
);
assert!(
obj.get("detector_id").is_some(),
"JSONL object missing detector_id: {obj}"
);
}
}
#[test]
fn jsonl_is_not_a_bracketed_array() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "jsonl");
assert!(
!stdout.trim_start().starts_with('['),
"JSONL must emit bare objects, never a `[...]` array; got {stdout:?}"
);
}
#[test]
fn sarif_empty_corpus_is_valid_empty_document() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "sarif");
assert_eq!(
code,
Some(0),
"clean sarif scan must exit 0; stderr={stderr}"
);
let v: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("empty SARIF must still be valid JSON");
assert_eq!(
v["version"], "2.1.0",
"SARIF version must be 2.1.0 even when empty"
);
assert!(
v["$schema"]
.as_str()
.is_some_and(|s| s.contains("sarif-2.1.0")),
"empty SARIF must carry the 2.1.0 $schema; got {}",
v["$schema"]
);
let run = &v["runs"][0];
assert_eq!(
run["results"].as_array().map(Vec::len),
Some(0),
"empty SARIF results array must be length 0; got {}",
run["results"]
);
assert_eq!(
run["tool"]["driver"]["rules"].as_array().map(Vec::len),
Some(0),
"empty SARIF rules array must be length 0; got {}",
run["tool"]["driver"]["rules"]
);
assert_eq!(
run["tool"]["driver"]["name"], "keyhog",
"SARIF tool.driver.name must be keyhog"
);
}
#[test]
fn sarif_empty_corpus_carries_taxonomies_and_trailing_newline() {
let (stdout, _stderr, _code) = scan_with_format(CLEAN_FIXTURE, "sarif");
assert!(
stdout.ends_with('\n'),
"SARIF doc must end with a trailing newline; got tail {:?}",
&stdout[stdout.len().saturating_sub(8)..]
);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("valid JSON");
assert!(
v["runs"][0]["taxonomies"].is_array(),
"SARIF run must carry a `taxonomies` array (CWE/OWASP); got {}",
v["runs"][0]["taxonomies"]
);
}
#[test]
fn sarif_planted_finding_result_resolves_into_rules() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "sarif");
assert_eq!(
code,
Some(1),
"sarif planted scan must exit 1; stderr={stderr}"
);
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("SARIF parses");
assert_eq!(v["version"], "2.1.0");
let run = &v["runs"][0];
let results = run["results"].as_array().expect("results array");
assert!(
!results.is_empty(),
"planted AKIA key must produce a SARIF result"
);
let rule_ids: std::collections::HashSet<String> = run["tool"]["driver"]["rules"]
.as_array()
.expect("rules array")
.iter()
.filter_map(|r| r["id"].as_str().map(str::to_string))
.collect();
assert!(
!rule_ids.is_empty(),
"non-empty SARIF must populate driver.rules"
);
for r in results {
let rid = r["ruleId"].as_str().expect("each result has a ruleId");
assert!(
rule_ids.contains(rid),
"ruleId {rid:?} not found in tool.driver.rules[]; GitHub would drop it"
);
assert!(
matches!(
r["level"].as_str(),
Some("error" | "warning" | "note" | "none")
),
"result.level must be a valid SARIF level; got {}",
r["level"]
);
assert_eq!(
r["properties"]["cwe"], "CWE-798",
"every SARIF result must carry CWE-798 (hard-coded credentials)"
);
assert_eq!(
r["properties"]["owasp"], "A07:2021",
"every SARIF result must carry OWASP A07:2021"
);
assert_eq!(
r["properties"]["verification"], "skipped",
"unverified SARIF result must carry verification=skipped; got {}",
r["properties"]["verification"]
);
}
}
#[test]
fn sarif_rules_carry_code_scanning_severity_props() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "sarif");
let v: serde_json::Value = serde_json::from_str(stdout.trim()).expect("SARIF parses");
let rules = v["runs"][0]["tool"]["driver"]["rules"]
.as_array()
.expect("rules array");
assert!(!rules.is_empty());
for rule in rules {
let props = &rule["properties"];
let sev = props["security-severity"]
.as_str()
.unwrap_or_else(|| panic!("rule {} missing security-severity", rule["id"]));
sev.parse::<f64>()
.unwrap_or_else(|_| panic!("security-severity must be numeric; got {sev:?}"));
let tags: Vec<&str> = props["tags"]
.as_array()
.map(|a| a.iter().filter_map(|t| t.as_str()).collect())
.unwrap_or_default();
assert!(
tags.contains(&"security"),
"rule {} must be tagged `security`; tags={tags:?}",
rule["id"]
);
}
}
const CSV_HEADER: &str = "detector_id,detector_name,service,severity,credential_redacted,credential_hash,source,file_path,line,offset,commit,author,date,verification,confidence";
#[test]
fn csv_empty_corpus_is_header_only() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "csv");
assert_eq!(code, Some(0), "clean csv scan must exit 0; stderr={stderr}");
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
1,
"empty CSV report must be header-only (1 line); got {lines:?}"
);
assert_eq!(
lines[0], CSV_HEADER,
"CSV header must match the reporter exactly"
);
}
#[test]
fn csv_planted_finding_has_header_plus_data_rows() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "csv");
assert_eq!(
code,
Some(1),
"csv planted scan must exit 1; stderr={stderr}"
);
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
assert!(
lines.len() >= 2,
"CSV must have header + >=1 data row; got {lines:?}"
);
assert_eq!(lines[0], CSV_HEADER, "first CSV line must be the header");
let header_cols = CSV_HEADER.split(',').count();
assert_eq!(header_cols, 15, "CSV header must declare 15 columns");
for row in &lines[1..] {
assert_eq!(
row.split(',').count(),
15,
"CSV data row must have 15 columns matching the header; row={row:?}"
);
}
}
#[test]
fn csv_data_row_carries_aws_detector_and_redacted_credential() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "csv");
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
let row = lines
.iter()
.skip(1)
.find(|r| {
let cols: Vec<&str> = r.split(',').collect();
matches!(
cols.first().copied(),
Some("aws-access-key" | "hot-aws_key")
)
})
.expect("a CSV data row for the AWS detection");
let cols: Vec<&str> = row.split(',').collect();
assert_eq!(
cols[4], "AKIA...7XYA",
"credential_redacted column must be the redacted key; row={row:?}"
);
assert_eq!(
cols[13], "skipped",
"verification column must be `skipped` without --verify; row={row:?}"
);
assert!(
!stdout.contains("AKIAQYLPMN5HFIQR7XYA"),
"CSV must not leak plaintext credential"
);
}
#[test]
fn html_empty_corpus_is_full_document_with_empty_findings() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "html");
assert_eq!(
code,
Some(0),
"clean html scan must exit 0; stderr={stderr}"
);
assert!(
stdout.starts_with("<!DOCTYPE html>"),
"HTML report must start with <!DOCTYPE html>; got {:?}",
&stdout[..stdout.len().min(40)]
);
assert!(
stdout.contains("<title>KeyHog Secret Scan Report</title>"),
"HTML must carry the KeyHog report title"
);
assert!(
stdout.contains("const rawFindings = [];"),
"empty HTML must inline `const rawFindings = [];`"
);
assert!(
stdout.trim_end().ends_with("</html>"),
"HTML report must close with </html>"
);
}
#[test]
fn html_planted_finding_inlines_nonempty_findings_array() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "html");
assert_eq!(
code,
Some(1),
"html planted scan must exit 1; stderr={stderr}"
);
assert!(
stdout.starts_with("<!DOCTYPE html>"),
"HTML must start with the doctype"
);
let marker = "const rawFindings = ";
let start = stdout
.find(marker)
.expect("HTML must contain the rawFindings assignment");
let after = &stdout[start + marker.len()..];
let end = after
.find(";\n")
.or_else(|| after.find(';'))
.expect("rawFindings terminator");
let literal = &after[..end];
assert!(
literal.starts_with('[') && literal.ends_with(']'),
"rawFindings must be a JSON array literal; got {literal:?}"
);
assert_ne!(
literal, "[]",
"planted finding must produce a non-empty rawFindings array"
);
assert!(
literal.contains("aws-access-key") || literal.contains("hot-aws_key"),
"rawFindings must reference the AWS detector id; got {literal}"
);
}
#[test]
fn html_inlined_findings_never_contain_raw_script_close() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "html");
let marker = "const rawFindings = ";
let start = stdout.find(marker).expect("rawFindings present");
let after = &stdout[start + marker.len()..];
let end = after.find(';').expect("terminator");
let literal = &after[..end];
assert!(
!literal.contains("</script"),
"inlined findings must not contain a raw </script close; got {literal}"
);
assert!(
!literal.contains('/'),
"escape_for_script must escape `/` to \\u002f inside rawFindings; got {literal}"
);
}
#[test]
fn junit_empty_corpus_is_empty_testsuite() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "junit");
assert_eq!(
code,
Some(0),
"clean junit scan must exit 0; stderr={stderr}"
);
assert!(
stdout.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"),
"JUnit must start with the XML declaration; got {:?}",
&stdout[..stdout.len().min(50)]
);
assert!(
stdout.contains("<testsuites>"),
"JUnit must contain <testsuites>"
);
assert!(
stdout.contains(
"<testsuite name=\"keyhog\" tests=\"0\" failures=\"0\" errors=\"0\" time=\"0.0\">"
),
"empty JUnit testsuite must declare tests=0 failures=0; got {stdout}"
);
assert!(
!stdout.contains("<testcase"),
"empty JUnit must contain no <testcase> elements"
);
assert!(
stdout.trim_end().ends_with("</testsuites>"),
"JUnit must close with </testsuites>"
);
}
#[test]
fn junit_planted_finding_counts_match_testcases() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "junit");
assert_eq!(
code,
Some(1),
"junit planted scan must exit 1; stderr={stderr}"
);
let testcase_count = stdout.matches("<testcase ").count();
let failure_count = stdout.matches("<failure ").count();
assert!(
testcase_count >= 1,
"planted finding must produce >=1 <testcase>"
);
assert_eq!(
testcase_count, failure_count,
"each JUnit <testcase> must carry exactly one <failure>; \
testcases={testcase_count} failures={failure_count}"
);
let expected_header = format!(
"<testsuite name=\"keyhog\" tests=\"{n}\" failures=\"{n}\" errors=\"0\" time=\"0.0\">",
n = testcase_count
);
assert!(
stdout.contains(&expected_header),
"JUnit testsuite header must report tests=failures={testcase_count}; \
expected line: {expected_header}\n--- got ---\n{stdout}"
);
}
#[test]
fn junit_failure_body_carries_detection_metadata() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "junit");
assert!(
stdout.contains("Secret detected:"),
"JUnit <failure> message must announce a detected secret"
);
assert!(
stdout.contains("<![CDATA["),
"JUnit failure detail must be wrapped in CDATA"
);
assert!(
stdout.contains("Detector ID:"),
"JUnit CDATA body must include the Detector ID label"
);
assert!(
stdout.contains("Verification: skipped"),
"unverified JUnit finding must report Verification: skipped; got {stdout}"
);
assert!(
stdout.contains("Redacted: AKIA...7XYA"),
"JUnit body must show the redacted credential; got {stdout}"
);
assert!(
!stdout.contains("AKIAQYLPMN5HFIQR7XYA"),
"JUnit must not leak plaintext credential"
);
}
#[test]
fn text_empty_corpus_prints_clean_summary() {
let (stdout, stderr, code) = scan_with_format(CLEAN_FIXTURE, "text");
assert_eq!(
code,
Some(0),
"clean text scan must exit 0; stderr={stderr}"
);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("No secrets found. Your code is clean."),
"clean text scan must print the clean-repo summary; \
stdout={stdout:?} stderr={stderr:?}"
);
assert!(
!stdout.trim_start().starts_with('['),
"text format must not emit a JSON `[`; got {stdout:?}"
);
}
#[test]
fn text_planted_finding_prints_results_summary() {
let (stdout, stderr, code) = scan_with_format(AWS_KEY_FIXTURE, "text");
assert_eq!(
code,
Some(1),
"text planted scan must exit 1; stderr={stderr}"
);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("secret found") || combined.contains("secrets found"),
"text scan with a finding must print a `secret(s) found` summary; \
stdout={stdout:?} stderr={stderr:?}"
);
assert!(
combined.contains("No secrets found. Your code is clean.") == false,
"text scan with a finding must NOT print the clean-repo summary"
);
assert!(
!stdout.trim_start().starts_with('['),
"text format must not emit a JSON `[`; got {stdout:?}"
);
}
#[test]
fn text_planted_finding_is_redacted() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "text");
assert!(
stdout.contains("AKIA...7XYA"),
"text finding box must show the redacted credential; got {stdout}"
);
assert!(
!stdout.contains("AKIAQYLPMN5HFIQR7XYA"),
"text mode must not leak the plaintext credential without --show-secrets"
);
}
#[test]
fn structured_formats_emit_no_ansi_escapes_when_piped() {
for fmt in ["json", "jsonl", "sarif", "csv"] {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, fmt);
assert!(
!stdout.contains('\u{1b}'),
"structured format `{fmt}` must not emit ANSI escape (\\x1b) on piped stdout; got {stdout:?}"
);
}
}
#[test]
fn json_and_jsonl_agree_on_finding_count() {
let (json_out, _e1, _c1) = scan_with_format(AWS_KEY_FIXTURE, "json");
let (jsonl_out, _e2, _c2) = scan_with_format(AWS_KEY_FIXTURE, "jsonl");
let json_v: serde_json::Value = serde_json::from_str(json_out.trim()).expect("json parses");
let json_count = json_v.as_array().expect("array").len();
let jsonl_count = jsonl_out.lines().filter(|l| !l.trim().is_empty()).count();
assert_eq!(
json_count, jsonl_count,
"JSON array length ({json_count}) must equal JSONL line count ({jsonl_count}) \
for the same corpus"
);
assert!(
json_count >= 1,
"both must report at least the planted finding"
);
}
#[test]
fn sarif_result_count_matches_json_finding_count() {
let (json_out, _e1, _c1) = scan_with_format(AWS_KEY_FIXTURE, "json");
let (sarif_out, _e2, _c2) = scan_with_format(AWS_KEY_FIXTURE, "sarif");
let json_count = serde_json::from_str::<serde_json::Value>(json_out.trim())
.expect("json")
.as_array()
.expect("array")
.len();
let sarif_v: serde_json::Value = serde_json::from_str(sarif_out.trim()).expect("sarif");
let sarif_count = sarif_v["runs"][0]["results"]
.as_array()
.expect("results array")
.len();
assert_eq!(
json_count, sarif_count,
"SARIF result count ({sarif_count}) must equal JSON finding count ({json_count})"
);
}
#[test]
fn csv_row_count_matches_json_finding_count() {
let (json_out, _e1, _c1) = scan_with_format(AWS_KEY_FIXTURE, "json");
let (csv_out, _e2, _c2) = scan_with_format(AWS_KEY_FIXTURE, "csv");
let json_count = serde_json::from_str::<serde_json::Value>(json_out.trim())
.expect("json")
.as_array()
.expect("array")
.len();
let csv_rows = csv_out
.lines()
.filter(|l| !l.is_empty())
.count()
.saturating_sub(1); assert_eq!(
csv_rows, json_count,
"CSV data-row count ({csv_rows}) must equal JSON finding count ({json_count})"
);
}
#[test]
fn junit_testcase_count_matches_json_finding_count() {
let (json_out, _e1, _c1) = scan_with_format(AWS_KEY_FIXTURE, "json");
let (junit_out, _e2, _c2) = scan_with_format(AWS_KEY_FIXTURE, "junit");
let json_count = serde_json::from_str::<serde_json::Value>(json_out.trim())
.expect("json")
.as_array()
.expect("array")
.len();
let junit_count = junit_out.matches("<testcase ").count();
assert_eq!(
junit_count, json_count,
"JUnit testcase count ({junit_count}) must equal JSON finding count ({json_count})"
);
}
#[test]
fn csv_no_unquoted_formula_trigger_cells() {
let (stdout, _stderr, _code) = scan_with_format(AWS_KEY_FIXTURE, "csv");
for (i, line) in stdout.lines().enumerate() {
if i == 0 || line.is_empty() {
continue; }
for cell in line.split(',') {
if let Some(first) = cell.as_bytes().first() {
let is_trigger = matches!(first, b'=' | b'+' | b'@');
assert!(
!is_trigger,
"CSV cell {cell:?} starts with an un-neutralized formula trigger; \
escape_csv must prefix it with a single quote"
);
}
}
}
}
#[test]
fn empty_file_is_clean_for_every_format() {
for fmt in ["text", "json", "jsonl", "sarif", "csv", "html", "junit"] {
let (_stdout, stderr, code) = scan_with_format("", fmt);
assert_eq!(
code,
Some(0),
"format `{fmt}` on a zero-byte file must exit 0 (clean); stderr={stderr}"
);
}
let (json_out, _e, _c) = scan_with_format("", "json");
assert_eq!(json_out, "[]", "zero-byte file JSON must be `[]`");
let (jsonl_out, _e, _c) = scan_with_format("", "jsonl");
assert_eq!(jsonl_out, "", "zero-byte file JSONL must be empty");
}
#[test]
fn unknown_format_value_is_clap_usage_error() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.env");
std::fs::write(&path, CLEAN_FIXTURE).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg("yaml") .arg(&path)
.output()
.expect("spawn keyhog scan --format yaml");
assert_eq!(
output.status.code(),
Some(2),
"an unknown --format value must be a clap usage error (exit 2); \
stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn default_format_is_text() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.env");
std::fs::write(&path, CLEAN_FIXTURE).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg(&path)
.output()
.expect("spawn keyhog scan (no --format)");
assert_eq!(output.status.code(), Some(0), "clean default scan exits 0");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
assert!(
!stdout.trim_start().starts_with('['),
"default (text) format must not emit JSON; got {stdout:?}"
);
assert!(
combined.contains("No secrets found. Your code is clean."),
"default-format clean scan must print the text clean summary; \
stdout={stdout:?} stderr={stderr:?}"
);
}
#[test]
fn format_value_is_case_sensitive() {
let dir = TempDir::new().expect("tempdir");
let path = dir.path().join("planted.env");
std::fs::write(&path, CLEAN_FIXTURE).expect("write fixture");
let output = Command::new(binary())
.arg("scan")
.arg("--no-daemon")
.arg("--format")
.arg("JSON") .arg(&path)
.output()
.expect("spawn keyhog scan --format JSON");
assert_eq!(
output.status.code(),
Some(2),
"uppercase `JSON` must be a clap usage error (exit 2) because value_enum \
matching is case-sensitive by default; stderr={}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn sarif_output_file_is_complete_for_planted_finding() {
let (bytes, code) = scan_to_output_file(AWS_KEY_FIXTURE, "sarif");
assert_eq!(code, Some(1), "sarif --output planted scan must exit 1");
let v: serde_json::Value =
serde_json::from_str(bytes.trim()).expect("on-disk SARIF must be valid JSON");
assert_eq!(v["version"], "2.1.0");
let results = v["runs"][0]["results"].as_array().expect("results array");
assert!(
!results.is_empty(),
"SARIF --output file must contain the planted result"
);
let rule_ids: std::collections::HashSet<String> = v["runs"][0]["tool"]["driver"]["rules"]
.as_array()
.expect("rules array")
.iter()
.filter_map(|r| r["id"].as_str().map(str::to_string))
.collect();
for r in results {
let rid = r["ruleId"].as_str().expect("ruleId");
assert!(
rule_ids.contains(rid),
"on-disk SARIF ruleId {rid:?} must resolve into rules"
);
}
}
#[test]
fn csv_output_file_roundtrips_header_and_rows() {
let (clean_bytes, clean_code) = scan_to_output_file(CLEAN_FIXTURE, "csv");
assert_eq!(clean_code, Some(0));
let clean_lines: Vec<&str> = clean_bytes.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
clean_lines,
vec![CSV_HEADER],
"clean CSV --output must be header-only"
);
let (planted_bytes, planted_code) = scan_to_output_file(AWS_KEY_FIXTURE, "csv");
assert_eq!(planted_code, Some(1));
let planted_lines: Vec<&str> = planted_bytes.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
planted_lines[0], CSV_HEADER,
"CSV --output first line is the header"
);
assert!(
planted_lines.len() >= 2,
"CSV --output must carry >=1 data row for the planted finding"
);
}