use std::sync::Arc;
use std::thread;
use crate::{emit, render, render_any, Format};
use crate::models::GenericFinding;
use secfinding::{Finding, Reportable, Severity};
#[test]
fn scale_0_findings_all_formats() {
let empty: Vec<Finding> = vec![];
for format in [
Format::Text,
Format::Json,
Format::Jsonl,
Format::Sarif,
Format::Markdown,
] {
let out = render(&empty, format, "scale-tool").unwrap();
assert!(
!out.is_empty() || format == Format::Jsonl,
"{:?} should produce output (or empty for jsonl)",
format
);
}
}
#[test]
fn scale_1_finding_all_formats() {
let f = Finding::new("s", "t", Severity::High, "T", "D").unwrap();
for format in [
Format::Text,
Format::Json,
Format::Jsonl,
Format::Sarif,
Format::Markdown,
] {
let out = render(&[f.clone()], format, "scale-tool").unwrap();
assert!(!out.is_empty(), "{:?} should produce output", format);
}
}
#[test]
fn scale_1_000_json() {
let findings: Vec<Finding> = (0..1_000)
.map(|i| {
Finding::new(
"stress",
format!("https://target-{}.com", i),
match i % 5 {
0 => Severity::Critical,
1 => Severity::High,
2 => Severity::Medium,
3 => Severity::Low,
_ => Severity::Info,
},
format!("Finding {}", i),
"detail",
)
.unwrap()
})
.collect();
let out = render(&findings, Format::Json, "tool").unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(parsed.len(), 1_000);
}
#[test]
fn scale_10_000_jsonl() {
let findings: Vec<Finding> = (0..10_000)
.map(|i| Finding::new("s", "t", Severity::Info, format!("T{}", i), "").unwrap())
.collect();
let out = render(&findings, Format::Jsonl, "tool").unwrap();
assert_eq!(out.lines().count(), 10_000);
}
#[test]
fn scale_100_000_jsonl() {
let findings: Vec<Finding> = (0..100_000)
.map(|i| Finding::new("s", "t", Severity::Info, format!("T{}", i), "").unwrap())
.collect();
let out = render(&findings, Format::Jsonl, "tool").unwrap();
assert_eq!(out.lines().count(), 100_000);
}
#[test]
fn scale_100_000_json() {
let findings: Vec<Finding> = (0..100_000)
.map(|i| Finding::new("s", "t", Severity::Info, format!("T{}", i), "").unwrap())
.collect();
let out = render(&findings, Format::Json, "tool").unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(parsed.len(), 100_000);
}
#[test]
fn scale_1_000_000_jsonl_generic() {
let findings: Vec<GenericFinding> = (0..1_000_000)
.map(|i| {
GenericFinding::builder("s", "t", Severity::Info)
.title(if i % 2 == 0 { "Even" } else { "Odd" })
.rule_id("RULE")
.build()
})
.collect();
let out = crate::render::json::render_jsonl_generic(&findings).unwrap();
assert_eq!(out.lines().count(), 1_000_000);
}
#[test]
fn scale_10_000_sarif() {
let findings: Vec<Finding> = (0..10_000)
.map(|i| Finding::new("s", "t", Severity::High, format!("T{}", i), "D").unwrap())
.collect();
let out = render(&findings, Format::Sarif, "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 10_000);
}
#[test]
fn scale_10_000_markdown() {
let findings: Vec<Finding> = (0..10_000)
.map(|i| Finding::new("s", "t", Severity::Medium, format!("T{}", i), "D").unwrap())
.collect();
let out = render(&findings, Format::Markdown, "tool").unwrap();
assert!(out.contains("10\u{2009}000 findings") || out.contains("10000 findings"));
}
#[test]
fn scale_10_000_text() {
let findings: Vec<Finding> = (0..10_000)
.map(|i| Finding::new("s", "t", Severity::Low, format!("T{}", i), "D").unwrap())
.collect();
let out = render(&findings, Format::Text, "tool").unwrap();
assert!(out.contains("Total: \u{001b}[1m10000\u{001b}[0m findings"));
}
#[test]
fn huge_detail_100kb_all_formats() {
let detail = "A".repeat(100_000);
let f = Finding::new("s", "t", Severity::High, "Title", &detail).unwrap();
for format in [
Format::Text,
Format::Json,
Format::Jsonl,
Format::Sarif,
Format::Markdown,
] {
let out = render(&[f.clone()], format, "tool").unwrap();
assert!(
out.len() > 90_000 || out.contains("AAAA"),
"{:?} should contain huge string",
format
);
}
}
#[test]
fn huge_title_1mb_rejected_by_finding_builder() {
let title = "X".repeat(1_048_577);
let result = Finding::new("s", "t", Severity::High, &title, "d");
assert!(result.is_err());
}
#[test]
fn unicode_in_all_fields_all_formats() {
let finding = Finding::builder("スキャナー", "https://例え.テスト/路径", Severity::Critical)
.title("CVE-2024-日本語の脆弱性 🚨")
.detail("詳細: これはテストです\nالعربية\nРусский\nעברית")
.tag("日本語タグ")
.tag("عربي")
.tag("русский")
.tag("emoji-🔒-🔑-🛡️")
.cve("CVE-2024-12345")
.reference("https://例え.テスト/参考")
.exploit_hint("curl -X POST https://例え.テスト/テスト")
.matched_value("マッチ値-🎯")
.build()
.unwrap();
for format in [Format::Text, Format::Json, Format::Sarif, Format::Markdown] {
let out = render(&[finding.clone()], format, "ユニコード ツール").unwrap();
let preserved = out.contains("スキャナー")
|| out.contains("例え")
|| out.contains("日本語")
|| out.contains("🚨");
assert!(preserved, "Unicode should be preserved in {:?}", format);
}
}
#[test]
fn unicode_tool_name_markdown() {
let f = Finding::new("s", "t", Severity::Info, "T", "D").unwrap();
let out = render(&[f], Format::Markdown, "ツール名").unwrap();
assert!(out.contains("ツール名"));
}
#[test]
fn right_to_left_text_in_fields() {
let f = Finding::new("سcanner", "target", Severity::High, "عنوان", "تفصيل").unwrap();
let out = render(&[f], Format::Json, "tool").unwrap();
assert!(out.contains("عنوان"));
assert!(out.contains("تفصيل"));
}
#[test]
fn emoji_only_title() {
let f = Finding::new("s", "t", Severity::Medium, "🙂🚀💥", "").unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains("🙂🚀💥"));
}
#[test]
fn null_bytes_in_title_and_detail_json() {
let f = Finding::new("s", "t", Severity::Medium, "a\x00b", "c\x00d").unwrap();
let out = render(&[f], Format::Json, "tool").unwrap();
assert!(out.contains("\\u0000"));
}
#[test]
fn control_characters_jsonl() {
let f = Finding::new("s", "t", Severity::Low, "\x01\x02\x03", "\x1f").unwrap();
let out = render(&[f], Format::Jsonl, "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["title"], "\u{0001}\u{0002}\u{0003}");
}
#[test]
fn carriage_return_in_detail() {
let f = Finding::new("s", "t", Severity::High, "T", "a\r\nb\rc").unwrap();
let out = render(&[f], Format::Json, "tool").unwrap();
assert!(out.contains("\\r"));
}
#[test]
fn tab_characters_preserved() {
let f = Finding::new("s", "t", Severity::Info, "T\tT", "D\tD").unwrap();
let out = render(&[f], Format::Json, "tool").unwrap();
assert!(out.contains("\\t"));
}
struct BadConfidenceReportable {
confidence: f64,
}
impl Reportable for BadConfidenceReportable {
fn scanner(&self) -> &str {
"s"
}
fn target(&self) -> &str {
"t"
}
fn severity(&self) -> Severity {
Severity::High
}
fn title(&self) -> &str {
"T"
}
fn confidence(&self) -> Option<f64> {
Some(self.confidence)
}
}
#[test]
fn render_any_rejects_nan_confidence() {
let r = BadConfidenceReportable {
confidence: f64::NAN,
};
let err = render_any(&[r], Format::Json, "tool").unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[test]
fn render_any_rejects_infinite_confidence() {
let r = BadConfidenceReportable {
confidence: f64::INFINITY,
};
let err = render_any(&[r], Format::Json, "tool").unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[test]
fn generic_finding_validate_catches_non_finite_confidence() {
struct Wrapper;
impl Reportable for Wrapper {
fn scanner(&self) -> &str { "s" }
fn target(&self) -> &str { "t" }
fn severity(&self) -> Severity { Severity::High }
fn title(&self) -> &str { "T" }
fn confidence(&self) -> Option<f64> { Some(f64::NAN) }
}
let err = GenericFinding::try_from_reportable(&Wrapper).unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[test]
fn markdown_escapes_backticks() {
let f = Finding::new("s", "t", Severity::High, "`rm -rf /`", "").unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains(r"\`rm \-rf /\`"));
}
#[test]
fn markdown_escapes_asterisks_and_underscores() {
let f = Finding::new("s", "t", Severity::High, "**bold**", "_underline_").unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains(r"\*\*bold\*\*") || out.contains(r"\*\*bold\*\*"));
assert!(out.contains(r"\_underline\_"));
}
#[test]
fn markdown_escapes_links() {
let f = Finding::new(
"s",
"t",
Severity::High,
"[click](javascript:alert(1))",
"",
)
.unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(!out.contains("[click](javascript:alert(1))"));
assert!(out.contains(r"\[click\]"));
}
#[test]
fn markdown_escapes_table_pipes() {
let f = Finding::new("s", "t", Severity::High, "A | B | C", "").unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains(r"A \| B \| C"));
}
#[test]
fn markdown_escapes_html_tags() {
let f = Finding::new("s", "t", Severity::High, "<script>alert(1)</script>", "").unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains(r"\<script\>alert\(1\)\</script\>"));
}
#[test]
fn markdown_tool_name_link_injection_blocked() {
let f = Finding::new("s", "t", Severity::Low, "T", "D").unwrap();
let tool = "evil](/)";
let out = render(&[f], Format::Markdown, tool).unwrap();
assert!(!out.contains(&format!("({})", tool)));
assert!(out.contains("https://github.com/santh-io/"));
}
#[test]
fn exploit_hint_backtick_fence_adaptive() {
let hint = "echo ok\n```\nmalicious\n```\nend";
let f = Finding::builder("s", "t", Severity::High)
.title("T")
.exploit_hint(hint)
.build()
.unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains("````bash") || out.contains("`````bash"));
assert!(out.contains("malicious"));
}
#[test]
fn exploit_hint_six_backticks() {
let hint = "``````";
let f = Finding::builder("s", "t", Severity::High)
.title("T")
.exploit_hint(hint)
.build()
.unwrap();
let out = render(&[f], Format::Markdown, "tool").unwrap();
assert!(out.contains("```````bash") || out.contains("````````bash"));
}
#[test]
fn sarif_special_chars_in_rule_ids() {
struct Custom {
scanner: String,
target: String,
title: String,
}
impl Reportable for Custom {
fn scanner(&self) -> &str { &self.scanner }
fn target(&self) -> &str { &self.target }
fn severity(&self) -> Severity { Severity::Critical }
fn title(&self) -> &str { &self.title }
}
let findings = vec![
Custom {
scanner: "scan<ner>/\\".into(),
target: "target".into(),
title: "SQL Injection <script>alert(1)</script>".into(),
},
Custom {
scanner: "scan\"ner\"".into(),
target: "target".into(),
title: "RCE $(whoami) `rm -rf /`".into(),
},
];
let out = render_any(&findings, Format::Sarif, "test-tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
let results = parsed["runs"][0]["results"].as_array().unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn sarif_fingerprint_is_stable_hex() {
let f = Finding::new("s", "t", Severity::High, "T", "D").unwrap();
let out1 = render(&[f.clone()], Format::Sarif, "tool").unwrap();
let out2 = render(&[f.clone()], Format::Sarif, "tool").unwrap();
let parsed1: serde_json::Value = serde_json::from_str(&out1).unwrap();
let parsed2: serde_json::Value = serde_json::from_str(&out2).unwrap();
let fp1 = parsed1["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
.as_str()
.unwrap();
let fp2 = parsed2["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
.as_str()
.unwrap();
assert_eq!(fp1, fp2);
assert_eq!(fp1.len(), 16);
assert!(
fp1.chars().all(|c| c.is_ascii_hexdigit()),
"fingerprint should be hex"
);
}
#[test]
fn sarif_fingerprint_changes_when_detail_changes() {
let f1 = Finding::new("s", "t", Severity::High, "T", "D1").unwrap();
let f2 = Finding::new("s", "t", Severity::High, "T", "D2").unwrap();
let out1 = render(&[f1], Format::Sarif, "tool").unwrap();
let out2 = render(&[f2], Format::Sarif, "tool").unwrap();
let parsed1: serde_json::Value = serde_json::from_str(&out1).unwrap();
let parsed2: serde_json::Value = serde_json::from_str(&out2).unwrap();
let fp1 = parsed1["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
.as_str()
.unwrap();
let fp2 = parsed2["runs"][0]["results"][0]["partialFingerprints"]["primaryLocationLineHash"]
.as_str()
.unwrap();
assert_ne!(fp1, fp2);
}
#[test]
fn text_strips_ansi_in_all_fields() {
let f = Finding::new(
"s\x1b[31mred\x1b[0m",
"t\x1b[32mgreen\x1b[0m",
Severity::High,
"\x1b[1mTitle\x1b[0m",
"\x1b[90mDetail\x1b[0m",
)
.unwrap();
let out = render(&[f], Format::Text, "tool").unwrap();
assert!(!out.contains("\x1b[31m"));
assert!(!out.contains("\x1b[32m"));
assert!(out.contains("sred"));
assert!(out.contains("tgreen"));
assert!(out.contains("Title"));
assert!(out.contains("Detail"));
assert!(out.contains("tgreen"));
assert!(out.contains("Title"));
assert!(out.contains("Detail"));
}
#[test]
fn text_ansi_escape_edge_cases() {
let f = Finding::new("s", "t", Severity::Critical, "T", "").unwrap();
let out = render(&[f], Format::Text, "tool").unwrap();
assert!(out.contains("\x1b["));
assert!(out.contains("CRIT"));
}
#[test]
fn concurrent_render_all_formats() {
let finding = Arc::new(Finding::new("s", "t", Severity::Critical, "T", "D").unwrap());
let mut handles = vec![];
for _ in 0..20 {
let f = finding.clone();
handles.push(thread::spawn(move || {
for format in [
Format::Text,
Format::Json,
Format::Jsonl,
Format::Sarif,
Format::Markdown,
] {
let out = render(std::slice::from_ref(&*f), format, "tool").unwrap();
assert!(!out.is_empty());
}
}));
}
for h in handles {
h.join().unwrap();
}
}
#[test]
fn concurrent_render_any_with_custom_reportable() {
#[derive(Clone)]
struct R;
impl Reportable for R {
fn scanner(&self) -> &str { "s" }
fn target(&self) -> &str { "t" }
fn severity(&self) -> Severity { Severity::High }
fn title(&self) -> &str { "T" }
}
let items: Vec<R> = (0..50).map(|_| R).collect();
let mut handles = vec![];
for _ in 0..10 {
let items = items.clone();
handles.push(thread::spawn(move || {
let out = render_any(&items, Format::Json, "tool").unwrap();
let parsed: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(parsed.len(), 50);
}));
}
for h in handles {
h.join().unwrap();
}
}
struct CrashWriter;
impl std::io::Write for CrashWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(std::io::ErrorKind::Other, "crash"))
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn emit_crash_writer_returns_error() {
let result = emit("data", &mut CrashWriter);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "crash");
}
struct PartialCrashWriter {
limit: usize,
written: usize,
}
impl std::io::Write for PartialCrashWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.written >= self.limit {
return Err(std::io::Error::new(std::io::ErrorKind::Other, "partial crash"));
}
let take = std::cmp::min(buf.len(), self.limit - self.written);
self.written += take;
Ok(take)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn emit_partial_crash_mid_write() {
let mut writer = PartialCrashWriter { limit: 3, written: 0 };
let result = emit("hello", &mut writer);
assert!(result.is_err());
assert_eq!(writer.written, 3);
}
#[test]
fn markdown_risk_summary_counts_are_exact() {
let findings = vec![
Finding::new("s", "t", Severity::Critical, "C1", "").unwrap(),
Finding::new("s", "t", Severity::Critical, "C2", "").unwrap(),
Finding::new("s", "t", Severity::High, "H1", "").unwrap(),
Finding::new("s", "t", Severity::Medium, "M1", "").unwrap(),
Finding::new("s", "t", Severity::Medium, "M2", "").unwrap(),
Finding::new("s", "t", Severity::Medium, "M3", "").unwrap(),
Finding::new("s", "t", Severity::Low, "L1", "").unwrap(),
Finding::new("s", "t", Severity::Info, "I1", "").unwrap(),
Finding::new("s", "t", Severity::Info, "I2", "").unwrap(),
];
let out = render(&findings, Format::Markdown, "tool").unwrap();
let crit_count = out.matches("| CRIT |").count();
let high_count = out.matches("| HIGH |").count();
let med_count = out.matches("| MED |").count();
let low_count = out.matches("| LOW |").count();
let info_count = out.matches("| INFO |").count();
assert_eq!(crit_count, 1);
assert_eq!(high_count, 1);
assert_eq!(med_count, 1);
assert_eq!(low_count, 1);
assert_eq!(info_count, 1);
assert!(out.contains("| CRIT | 2 |"));
assert!(out.contains("| HIGH | 1 |"));
assert!(out.contains("| MED | 3 |"));
assert!(out.contains("| LOW | 1 |"));
assert!(out.contains("| INFO | 2 |"));
}
#[test]
fn text_summary_counts_are_exact() {
let findings = vec![
Finding::new("s", "t", Severity::Critical, "C1", "").unwrap(),
Finding::new("s", "t", Severity::Critical, "C2", "").unwrap(),
Finding::new("s", "t", Severity::High, "H1", "").unwrap(),
Finding::new("s", "t", Severity::Medium, "M1", "").unwrap(),
Finding::new("s", "t", Severity::Low, "L1", "").unwrap(),
Finding::new("s", "t", Severity::Info, "I1", "").unwrap(),
];
let out = render(&findings, Format::Text, "tool").unwrap();
assert!(out.contains(" 2 critical"));
assert!(out.contains(" 1 high"));
assert!(out.contains(" 1 medium"));
assert!(out.contains(" 1 low"));
assert!(out.contains(" 1 info"));
assert!(out.contains("Total: \u{001b}[1m6\u{001b}[0m findings"));
}
#[test]
fn whitespace_only_title_is_allowed_by_generic_finding() {
let gf = GenericFinding::builder("s", "t", Severity::High)
.title(" ")
.rule_id("R")
.build();
assert_eq!(gf.title, " ");
}
#[test]
fn empty_rule_id_in_sarif_uses_fallback_key() {
let gf = GenericFinding::builder("my-scanner", "https://example.com", Severity::High)
.title("Empty Rule")
.rule_id("")
.build();
let out = crate::render::json::render_sarif_generic(&[gf], "tool").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
let rules = parsed["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[0]["id"], "my-scanner/empty-rule");
}
#[test]
fn empty_markdown_with_no_target_uses_unknown() {
let out = render(&[] as &[Finding], Format::Markdown, "tool").unwrap();
assert!(out.contains("unknown"));
}