mod common;
use common::{assert_all_formats_produce_output, finding};
use secreport::{Format, emit, render, render_any};
use secfinding::{Evidence, Finding, Reportable, Severity};
use std::sync::Arc;
use std::thread;
#[test]
fn scale_0_findings_all_formats() {
let empty: Vec<Finding> = vec![];
assert_all_formats_produce_output(&empty);
}
#[test]
fn scale_1_finding_all_formats() {
let f = finding("One", Severity::High);
assert_all_formats_produce_output(&[f]);
}
#[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_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: \x1b[1m10000\x1b[0m findings"));
}
#[test]
fn scale_1_000_000_jsonl_generic() {
let _findings: Vec<secreport::models::GenericFinding> = (0..1_000_000)
.map(|i| {
secreport::models::GenericFinding::builder("s", "t", Severity::Info)
.title(if i % 2 == 0 { "Even" } else { "Odd" })
.rule_id("RULE")
.build()
})
.collect();
struct Light {
title: &'static str,
}
impl Reportable for Light {
fn scanner(&self) -> &str { "s" }
fn target(&self) -> &str { "t" }
fn severity(&self) -> Severity { Severity::Info }
fn title(&self) -> &str { self.title }
}
let items: Vec<Light> = (0..1_000_000)
.map(|i| Light { title: if i % 2 == 0 { "Even" } else { "Odd" } })
.collect();
let out = render_any(&items, Format::Jsonl, "tool").unwrap();
assert_eq!(out.lines().count(), 1_000_000);
}
#[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("T", Severity::Info);
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"));
}
#[test]
fn empty_strings_allowed_in_generic_finding() {
let gf = secreport::models::GenericFinding::builder("s", "t", Severity::High)
.title("")
.detail("")
.rule_id("")
.build();
assert_eq!(gf.title, "");
assert_eq!(gf.detail, "");
assert_eq!(gf.rule_id, "");
}
#[test]
fn whitespace_only_title_is_allowed_by_generic_finding() {
let gf = secreport::models::GenericFinding::builder("s", "t", Severity::High)
.title(" ")
.rule_id("R")
.build();
assert_eq!(gf.title, " ");
}
#[test]
fn finding_rejects_empty_scanner() {
let result = Finding::new("", "target", Severity::High, "T", "D");
assert!(result.is_err());
}
#[test]
fn finding_rejects_empty_target() {
let result = Finding::new("scanner", "", Severity::High, "T", "D");
assert!(result.is_err());
}
#[test]
fn finding_rejects_empty_title() {
let result = Finding::new("scanner", "target", Severity::High, "", "D");
assert!(result.is_err());
}
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 render_any_rejects_neg_infinite_confidence() {
let r = BadConfidenceReportable { confidence: f64::NEG_INFINITY };
let err = render_any(&[r], Format::Json, "tool").unwrap_err();
assert!(err.to_string().contains("confidence must be finite"));
}
#[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 emit_to_temp_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("out.txt");
let mut file = std::fs::File::create(&path).unwrap();
emit("persisted", &mut file).unwrap();
drop(file);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "persisted");
}