1use serde::Serialize;
65use std::collections::HashMap;
66use std::sync::Mutex;
67use std::time::Instant;
68
69use crate::log_context::LogContextResult;
70use crate::scanner::{MatchLocation, ScanStats};
71
72#[derive(Debug, Clone, Serialize)]
81pub struct SanitizeReport {
82 pub metadata: ReportMetadata,
84 pub summary: ReportSummary,
86 pub files: Vec<FileReport>,
89}
90
91impl SanitizeReport {
92 pub fn to_json(&self) -> serde_json::Result<String> {
98 serde_json::to_string(self)
99 }
100
101 pub fn to_json_pretty(&self) -> serde_json::Result<String> {
107 serde_json::to_string_pretty(self)
108 }
109
110 pub fn to_sarif(&self) -> serde_json::Result<String> {
121 use serde_json::json;
122
123 let rules = sarif_rules(self);
124 let results = sarif_results(self);
125 let artifacts: Vec<serde_json::Value> = self
126 .files
127 .iter()
128 .map(|f| {
129 let uri = path_to_sarif_uri(&f.path);
130 json!({ "location": { "uri": uri, "uriBaseId": "%SRCROOT%" } })
131 })
132 .collect();
133
134 let sarif = json!({
135 "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
136 "version": "2.1.0",
137 "runs": [{
138 "tool": {
139 "driver": {
140 "name": "rust-sanitize",
141 "version": self.metadata.version,
142 "informationUri": "https://github.com/kayelohbyte/rust-sanitize",
143 "rules": rules
144 }
145 },
146 "invocations": [{
147 "executionSuccessful": true,
148 "endTimeUtc": self.metadata.timestamp
149 }],
150 "results": results,
151 "artifacts": artifacts
152 }]
153 });
154
155 serde_json::to_string_pretty(&sarif)
156 }
157
158 #[must_use]
164 pub fn to_html(&self) -> String {
165 let s = &self.summary;
166 let m = &self.metadata;
167 let has_locations = self.files.iter().any(|f| f.match_locations.is_some());
168
169 let cards = html_summary_cards(s);
170 let patterns_section = html_patterns_section(s);
171 let file_rows: String = self
172 .files
173 .iter()
174 .map(|f| html_file_row(f, has_locations))
175 .collect();
176 let first_line_header = if has_locations {
177 "<th>First match</th>"
178 } else {
179 ""
180 };
181
182 format!(
183 r#"<!DOCTYPE html>
184<html lang="en">
185<head>
186<meta charset="utf-8">
187<meta name="viewport" content="width=device-width,initial-scale=1">
188<title>rust-sanitize report</title>
189<style>
190:root{{--bg:#f8f9fa;--surface:#fff;--border:#dee2e6;--text:#212529;--muted:#6c757d;--accent:#0d6efd;--danger:#dc3545;--warn-col:#fd7e14;--success:#198754;--badge:#e9ecef;--code-bg:#f1f3f4}}
191@media(prefers-color-scheme:dark){{:root{{--bg:#0d1117;--surface:#161b22;--border:#30363d;--text:#e6edf3;--muted:#8b949e;--accent:#58a6ff;--danger:#f85149;--warn-col:#d29922;--success:#3fb950;--badge:#21262d;--code-bg:#1c2128}}}}
192*,*::before,*::after{{box-sizing:border-box;margin:0;padding:0}}
193body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;font-size:14px}}
194.container{{max-width:1100px;margin:0 auto;padding:24px 16px}}
195header{{margin-bottom:24px;padding-bottom:16px;border-bottom:1px solid var(--border)}}
196h1{{font-size:1.4rem;font-weight:600}}
197.meta{{font-size:.8rem;color:var(--muted);margin-top:4px}}
198.section{{margin-bottom:28px}}
199h2{{font-size:.95rem;font-weight:600;margin-bottom:10px}}
200.cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:24px}}
201.card{{background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:14px}}
202.card-label{{font-size:.7rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}}
203.card-value{{font-size:1.4rem;font-weight:600;margin-top:2px}}
204.table-wrap{{overflow-x:auto}}
205table{{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--border);border-radius:6px;font-size:.85rem}}
206th{{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);font-weight:600;color:var(--muted);white-space:nowrap}}
207td{{padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}}
208tr:last-child td{{border-bottom:none}}
209tr:hover td{{background:var(--badge)}}
210code{{background:var(--code-bg);border-radius:3px;padding:1px 4px;font-size:.8rem;word-break:break-all}}
211.badge{{display:inline-block;padding:1px 7px;border-radius:12px;font-size:.72rem;font-weight:500;background:var(--badge);margin:1px}}
212.badge-pii{{background:rgba(220,53,69,.12);color:var(--danger)}}
213.badge-warn{{background:rgba(253,126,20,.12);color:var(--warn-col)}}
214.count-zero{{color:var(--muted)}}
215.count-positive{{font-weight:600}}
216footer{{margin-top:40px;padding-top:16px;border-top:1px solid var(--border);font-size:.75rem;color:var(--muted)}}
217</style>
218</head>
219<body>
220<div class="container">
221<header>
222<h1>rust-sanitize report</h1>
223<div class="meta">version {version} · {timestamp} · {duration_ms} ms total</div>
224</header>
225{cards}
226{patterns_section}
227<div class="section">
228<h2>Files</h2>
229<div class="table-wrap"><table>
230<thead><tr><th>Path</th><th>Matches</th><th>Method</th>{first_line_header}<th>Patterns</th></tr></thead>
231<tbody>{file_rows}</tbody>
232</table></div></div>
233<footer>Generated by <strong>rust-sanitize {version}</strong> on {timestamp}</footer>
234</div>
235</body>
236</html>"#,
237 version = html_escape(&m.version),
238 timestamp = html_escape(&m.timestamp),
239 duration_ms = s.duration_ms,
240 cards = cards,
241 patterns_section = patterns_section,
242 first_line_header = first_line_header,
243 file_rows = file_rows,
244 )
245 }
246}
247
248fn sarif_rules(report: &SanitizeReport) -> Vec<serde_json::Value> {
255 use serde_json::json;
256
257 let needs_generic = report
258 .files
259 .iter()
260 .any(|f| f.matches > 0 && f.pattern_counts.is_empty());
261
262 let mut rule_ids: Vec<&str> = report
263 .summary
264 .pattern_counts
265 .keys()
266 .map(String::as_str)
267 .collect();
268 rule_ids.sort_unstable();
269 if needs_generic {
270 rule_ids.push("sensitive_value");
271 }
272
273 rule_ids
274 .iter()
275 .map(|&id| {
276 let (short, full) = if id == "sensitive_value" {
277 (
278 "Sensitive value detected".to_owned(),
279 "One or more sensitive values were detected during sanitization and \
280 replaced with safe substitutes. No original values are stored. \
281 Run with a secrets file for per-pattern breakdown."
282 .to_owned(),
283 )
284 } else {
285 (
286 format!("Sensitive value of type '{}' detected", id),
287 format!(
288 "A sensitive value of type '{}' was detected during sanitization \
289 and replaced with a safe substitute. No original value is stored.",
290 id
291 ),
292 )
293 };
294 json!({
295 "id": id,
296 "name": sarif_rule_name(id),
297 "shortDescription": { "text": short },
298 "fullDescription": { "text": full },
299 "defaultConfiguration": { "level": sarif_level(id) },
300 "properties": { "tags": ["security"] }
301 })
302 })
303 .collect()
304}
305
306fn sarif_results(report: &SanitizeReport) -> Vec<serde_json::Value> {
309 use serde_json::json;
310
311 let mut results = Vec::new();
312 for f in &report.files {
313 let uri = path_to_sarif_uri(&f.path);
314 let file_location = json!([{
315 "physicalLocation": {
316 "artifactLocation": { "uri": &uri, "uriBaseId": "%SRCROOT%" }
317 }
318 }]);
319
320 if f.matches > 0 && f.pattern_counts.is_empty() {
321 results.push(json!({
322 "ruleId": "sensitive_value",
323 "level": "warning",
324 "message": {
325 "text": format!("{} sensitive value(s) detected and sanitized.", f.matches)
326 },
327 "locations": file_location
328 }));
329 continue;
330 }
331
332 for (pattern, &count) in &f.pattern_counts {
333 if count == 0 {
334 continue;
335 }
336 let first_line = f.match_locations.as_ref().and_then(|ml| {
338 ml.locations
339 .iter()
340 .find(|loc| loc.pattern == *pattern)
341 .map(|loc| loc.line)
342 });
343 let loc = match first_line {
344 Some(line) => json!([{
345 "physicalLocation": {
346 "artifactLocation": { "uri": &uri, "uriBaseId": "%SRCROOT%" },
347 "region": { "startLine": line }
348 }
349 }]),
350 None => file_location.clone(),
351 };
352 results.push(json!({
353 "ruleId": pattern,
354 "level": sarif_level(pattern),
355 "message": {
356 "text": format!(
357 "{} sensitive value(s) of type '{}' detected and sanitized.",
358 count, pattern
359 )
360 },
361 "locations": loc
362 }));
363 }
364 }
365 results
366}
367
368fn html_summary_cards(s: &crate::report::ReportSummary) -> String {
374 format!(
375 r#"<div class="cards">
376 <div class="card"><div class="card-label">Files</div><div class="card-value">{}</div></div>
377 <div class="card"><div class="card-label">Matches</div><div class="card-value">{}</div></div>
378 <div class="card"><div class="card-label">Replacements</div><div class="card-value">{}</div></div>
379 <div class="card"><div class="card-label">Input</div><div class="card-value">{}</div></div>
380 <div class="card"><div class="card-label">Duration</div><div class="card-value">{} ms</div></div>
381</div>"#,
382 s.total_files,
383 s.total_matches,
384 s.total_replacements,
385 fmt_bytes(s.total_bytes_processed),
386 s.duration_ms,
387 )
388}
389
390fn html_patterns_section(s: &crate::report::ReportSummary) -> String {
393 if s.total_matches == 0 {
394 return String::new();
395 }
396 let mut sorted: Vec<(&String, &u64)> = s.pattern_counts.iter().collect();
397 sorted.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
398 let rows: String = sorted.iter().fold(String::new(), |mut s, (pat, count)| {
399 use std::fmt::Write;
400 let _ = writeln!(
401 s,
402 "<tr><td>{}</td><td>{}</td></tr>",
403 html_escape(pat),
404 count
405 );
406 s
407 });
408 format!(
409 r#"<div class="section">
410<h2>Patterns detected</h2>
411<div class="table-wrap"><table>
412<thead><tr><th>Pattern</th><th>Total matches</th></tr></thead>
413<tbody>{}</tbody>
414</table></div></div>"#,
415 rows
416 )
417}
418
419fn html_file_row(f: &FileReport, has_locations: bool) -> String {
422 let badges: String = {
423 let mut pairs: Vec<(&String, &u64)> = f.pattern_counts.iter().collect();
424 pairs.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
425 pairs
426 .iter()
427 .filter(|(_, &c)| c > 0)
428 .fold(String::new(), |mut s, (pat, count)| {
429 use std::fmt::Write;
430 let _ = write!(
431 s,
432 r#"<span class="badge {}">{}: {}</span>"#,
433 sarif_badge_class(pat),
434 html_escape(pat),
435 count,
436 );
437 s
438 })
439 };
440 let match_class = if f.matches > 0 {
441 "count-positive"
442 } else {
443 "count-zero"
444 };
445 let first_line_cell = if has_locations {
446 match f
447 .match_locations
448 .as_ref()
449 .and_then(|ml| ml.locations.first())
450 {
451 Some(loc) => {
452 let truncated = if f.match_locations.as_ref().is_some_and(|ml| ml.truncated) {
453 r#"<span title="more matches not shown">…</span>"#
454 } else {
455 ""
456 };
457 format!(
458 "<td class=\"count-positive\">L{}{}</td>",
459 loc.line, truncated
460 )
461 }
462 None => "<td class=\"count-zero\">—</td>".to_owned(),
463 }
464 } else {
465 String::new()
466 };
467 format!(
468 "<tr><td><code>{}</code></td><td class=\"{}\">{}</td><td>{}</td>{}<td>{}</td></tr>\n",
469 html_escape(&f.path),
470 match_class,
471 f.matches,
472 html_escape(&f.method),
473 first_line_cell,
474 badges,
475 )
476}
477
478fn is_pii_category(pattern: &str) -> bool {
483 matches!(
484 pattern,
485 "email" | "name" | "phone" | "credit_card" | "ssn" | "auth_token" | "jwt"
486 )
487}
488
489fn sarif_level(pattern: &str) -> &'static str {
492 if is_pii_category(pattern) {
493 "error"
494 } else {
495 "warning"
496 }
497}
498
499fn sarif_rule_name(pattern: &str) -> String {
502 pattern
503 .split(['_', ':', '-'])
504 .map(|word| {
505 let mut chars = word.chars();
506 match chars.next() {
507 None => String::new(),
508 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
509 }
510 })
511 .collect()
512}
513
514fn path_to_sarif_uri(path: &str) -> String {
516 path.replace('\\', "/")
517}
518
519fn sarif_badge_class(pattern: &str) -> &'static str {
521 if is_pii_category(pattern) {
522 "badge-pii"
523 } else {
524 "badge-warn"
525 }
526}
527
528#[allow(clippy::cast_precision_loss)]
530fn fmt_bytes(bytes: u64) -> String {
531 const KIB: u64 = 1024;
532 const MIB: u64 = 1024 * KIB;
533 const GIB: u64 = 1024 * MIB;
534 if bytes >= GIB {
535 format!("{:.1} GiB", bytes as f64 / GIB as f64)
536 } else if bytes >= MIB {
537 format!("{:.1} MiB", bytes as f64 / MIB as f64)
538 } else if bytes >= KIB {
539 format!("{:.1} KiB", bytes as f64 / KIB as f64)
540 } else {
541 format!("{bytes} B")
542 }
543}
544
545fn html_escape(s: &str) -> String {
547 s.replace('&', "&")
548 .replace('<', "<")
549 .replace('>', ">")
550 .replace('"', """)
551}
552
553#[derive(Debug, Clone, Serialize)]
555pub struct ReportMetadata {
556 pub version: String,
558 pub timestamp: String,
560 pub deterministic: bool,
562 pub dry_run: bool,
564 pub strict: bool,
566 pub chunk_size: usize,
568 pub threads: Option<usize>,
570 pub secrets_file: Option<String>,
572}
573
574#[derive(Debug, Clone, Serialize)]
576pub struct ReportSummary {
577 pub total_files: u64,
579 pub total_matches: u64,
581 pub total_replacements: u64,
583 pub total_bytes_processed: u64,
585 pub total_bytes_output: u64,
587 pub duration_ms: u64,
589 pub pattern_counts: HashMap<String, u64>,
591}
592
593#[derive(Debug, Clone, Serialize)]
596pub struct MatchLocationsResult {
597 pub locations: Vec<MatchLocation>,
599 pub truncated: bool,
602}
603
604#[derive(Debug, Clone, Serialize)]
609pub struct FileReport {
610 pub path: String,
612 pub matches: u64,
614 pub replacements: u64,
616 pub bytes_processed: u64,
618 pub bytes_output: u64,
620 pub pattern_counts: HashMap<String, u64>,
622 pub method: String,
624 #[serde(skip_serializing_if = "Option::is_none")]
627 pub log_context: Option<LogContextResult>,
628 #[serde(skip_serializing_if = "Option::is_none")]
632 pub match_locations: Option<MatchLocationsResult>,
633}
634
635impl FileReport {
636 #[must_use]
638 pub fn from_scan_stats(
639 path: impl Into<String>,
640 stats: &ScanStats,
641 method: impl Into<String>,
642 ) -> Self {
643 Self {
644 path: path.into(),
645 matches: stats.matches_found,
646 replacements: stats.replacements_applied,
647 bytes_processed: stats.bytes_processed,
648 bytes_output: stats.bytes_output,
649 pattern_counts: stats.pattern_counts.clone(),
650 method: method.into(),
651 log_context: None,
652 match_locations: None,
653 }
654 }
655
656 #[must_use]
662 pub fn with_match_locations(mut self, locations: Vec<MatchLocation>, truncated: bool) -> Self {
663 if !locations.is_empty() || truncated {
664 self.match_locations = Some(MatchLocationsResult {
665 locations,
666 truncated,
667 });
668 }
669 self
670 }
671}
672
673#[derive(Debug)]
684pub struct ReportBuilder {
685 metadata: ReportMetadata,
686 files: Mutex<Vec<FileReport>>,
687 start: Instant,
688}
689
690const _: fn() = || {
693 fn assert_send<T: Send>() {}
694 fn assert_sync<T: Sync>() {}
695 assert_send::<ReportBuilder>();
696 assert_sync::<ReportBuilder>();
697};
698
699impl ReportBuilder {
700 #[must_use]
704 pub fn new(metadata: ReportMetadata) -> Self {
705 Self {
706 metadata,
707 files: Mutex::new(Vec::new()),
708 start: Instant::now(),
709 }
710 }
711
712 pub fn set_file_log_context(&self, path: &str, result: LogContextResult) {
716 let mut files = self.files.lock().expect("report mutex poisoned");
717 if let Some(file) = files.iter_mut().find(|f| f.path == path) {
718 file.log_context = Some(result);
719 }
720 }
721
722 pub fn record_file(&self, file_report: FileReport) {
724 let mut files = self.files.lock().expect("report mutex poisoned");
725 files.push(file_report);
726 }
727
728 pub fn record_files(&self, reports: impl IntoIterator<Item = FileReport>) {
730 let mut files = self.files.lock().expect("report mutex poisoned");
731 files.extend(reports);
732 }
733
734 pub fn finish(self) -> SanitizeReport {
738 #[allow(clippy::cast_possible_truncation)] let duration_ms = self.start.elapsed().as_millis() as u64;
740 let files = self.files.into_inner().expect("report mutex poisoned");
741
742 let mut total_matches: u64 = 0;
744 let mut total_replacements: u64 = 0;
745 let mut total_bytes_processed: u64 = 0;
746 let mut total_bytes_output: u64 = 0;
747 let mut pattern_counts: HashMap<String, u64> = HashMap::new();
748
749 for f in &files {
750 total_matches += f.matches;
751 total_replacements += f.replacements;
752 total_bytes_processed += f.bytes_processed;
753 total_bytes_output += f.bytes_output;
754 for (pat, count) in &f.pattern_counts {
755 *pattern_counts.entry(pat.clone()).or_insert(0) += count;
756 }
757 }
758
759 let summary = ReportSummary {
760 total_files: files.len() as u64,
761 total_matches,
762 total_replacements,
763 total_bytes_processed,
764 total_bytes_output,
765 duration_ms,
766 pattern_counts,
767 };
768
769 SanitizeReport {
770 metadata: self.metadata,
771 summary,
772 files,
773 }
774 }
775}
776
777#[cfg(test)]
782mod tests {
783 use super::*;
784
785 fn sample_metadata() -> ReportMetadata {
786 ReportMetadata {
787 version: "0.2.0".into(),
788 timestamp: "2026-03-01T00:00:00Z".into(),
789 deterministic: false,
790 dry_run: false,
791 strict: false,
792 chunk_size: 1_048_576,
793 threads: None,
794 secrets_file: None,
795 }
796 }
797
798 fn sample_file_report(path: &str, matches: u64, pattern: &str) -> FileReport {
799 FileReport {
800 path: path.into(),
801 matches,
802 replacements: matches,
803 bytes_processed: matches * 100,
804 bytes_output: matches * 110,
805 pattern_counts: HashMap::from([(pattern.into(), matches)]),
806 method: "scanner".into(),
807 log_context: None,
808 match_locations: None,
809 }
810 }
811
812 #[test]
815 fn empty_report() {
816 let builder = ReportBuilder::new(sample_metadata());
817 let report = builder.finish();
818 assert_eq!(report.summary.total_files, 0);
819 assert_eq!(report.summary.total_matches, 0);
820 assert!(report.files.is_empty());
821 }
822
823 #[test]
824 fn single_file_report() {
825 let builder = ReportBuilder::new(sample_metadata());
826 builder.record_file(sample_file_report("data.log", 10, "email"));
827 let report = builder.finish();
828
829 assert_eq!(report.summary.total_files, 1);
830 assert_eq!(report.summary.total_matches, 10);
831 assert_eq!(report.summary.total_replacements, 10);
832 assert_eq!(report.summary.total_bytes_processed, 1000);
833 assert_eq!(report.summary.total_bytes_output, 1100);
834 assert_eq!(*report.summary.pattern_counts.get("email").unwrap(), 10);
835 assert_eq!(report.files[0].path, "data.log");
836 }
837
838 #[test]
839 fn multiple_files_aggregated() {
840 let builder = ReportBuilder::new(sample_metadata());
841 builder.record_file(sample_file_report("a.log", 5, "email"));
842 builder.record_file(sample_file_report("b.log", 3, "ipv4"));
843 builder.record_file(sample_file_report("c.log", 7, "email"));
844 let report = builder.finish();
845
846 assert_eq!(report.summary.total_files, 3);
847 assert_eq!(report.summary.total_matches, 15);
848 assert_eq!(*report.summary.pattern_counts.get("email").unwrap(), 12);
849 assert_eq!(*report.summary.pattern_counts.get("ipv4").unwrap(), 3);
850 }
851
852 #[test]
855 fn json_serialization_no_secrets() {
856 let builder = ReportBuilder::new(sample_metadata());
857 builder.record_file(FileReport {
858 path: "config.yaml".into(),
859 matches: 2,
860 replacements: 2,
861 bytes_processed: 500,
862 bytes_output: 520,
863 pattern_counts: HashMap::from([("hostname".into(), 2)]),
864 method: "structured:yaml".into(),
865 log_context: None,
866 match_locations: None,
867 });
868 let report = builder.finish();
869 let json = report.to_json_pretty().unwrap();
870
871 assert!(json.contains("\"total_matches\": 2"));
873 assert!(json.contains("\"version\": \"0.2.0\""));
874 assert!(json.contains("\"hostname\": 2"));
875 assert!(json.contains("\"method\": \"structured:yaml\""));
876 assert!(json.contains("\"duration_ms\""));
877
878 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
883 assert!(parsed["files"][0]["path"].as_str() == Some("config.yaml"));
884 let flat = json.to_lowercase();
886 assert!(!flat.contains("\"original\""));
887 assert!(!flat.contains("\"secret_value\""));
888 }
889
890 #[test]
891 fn compact_json() {
892 let builder = ReportBuilder::new(sample_metadata());
893 let report = builder.finish();
894 let json = report.to_json().unwrap();
895 assert!(!json.contains(" "));
897 }
898
899 #[test]
902 fn metadata_flags_preserved() {
903 let meta = ReportMetadata {
904 version: "0.8.0".into(),
905 timestamp: "2026-06-15T12:00:00Z".into(),
906 deterministic: true,
907 dry_run: true,
908 strict: true,
909 chunk_size: 262_144,
910 threads: Some(8),
911 secrets_file: Some("secrets.enc".into()),
912 };
913 let builder = ReportBuilder::new(meta);
914 let report = builder.finish();
915 assert!(report.metadata.deterministic);
916 assert!(report.metadata.dry_run);
917 assert!(report.metadata.strict);
918 assert_eq!(report.metadata.chunk_size, 262_144);
919 assert_eq!(report.metadata.threads, Some(8));
920 assert_eq!(report.metadata.secrets_file.as_deref(), Some("secrets.enc"));
921 }
922
923 #[test]
926 fn duration_is_positive() {
927 let builder = ReportBuilder::new(sample_metadata());
928 builder.record_file(sample_file_report("x.txt", 1, "email"));
930 let report = builder.finish();
931 assert!(report.summary.duration_ms < 5_000); }
934
935 #[test]
938 fn concurrent_recording() {
939 use std::sync::Arc;
940 use std::thread;
941
942 let builder = Arc::new(ReportBuilder::new(sample_metadata()));
943 let mut handles = Vec::new();
944
945 for i in 0_u64..16 {
946 let b = Arc::clone(&builder);
947 handles.push(thread::spawn(move || {
948 b.record_file(sample_file_report(&format!("file_{i}.log"), i + 1, "email"));
949 }));
950 }
951
952 for h in handles {
953 h.join().unwrap();
954 }
955
956 let builder = Arc::try_unwrap(builder).expect("other refs still held");
958 let report = builder.finish();
959
960 assert_eq!(report.summary.total_files, 16);
961 assert_eq!(report.summary.total_matches, 136);
963 }
964
965 #[test]
968 fn file_report_from_scan_stats() {
969 let stats = ScanStats {
970 bytes_processed: 2048,
971 bytes_output: 2100,
972 matches_found: 5,
973 replacements_applied: 5,
974 pattern_counts: HashMap::from([("email".into(), 3), ("ipv4".into(), 2)]),
975 };
976 let fr = FileReport::from_scan_stats("test.log", &stats, "scanner");
977 assert_eq!(fr.path, "test.log");
978 assert_eq!(fr.matches, 5);
979 assert_eq!(fr.bytes_processed, 2048);
980 assert_eq!(*fr.pattern_counts.get("email").unwrap(), 3);
981 assert_eq!(fr.method, "scanner");
982 }
983
984 #[test]
987 fn large_file_report() {
988 let builder = ReportBuilder::new(sample_metadata());
989 builder.record_file(FileReport {
991 path: "huge.log".into(),
992 matches: 1_000_000,
993 replacements: 1_000_000,
994 bytes_processed: 10_737_418_240, bytes_output: 10_900_000_000,
996 pattern_counts: HashMap::from([("email".into(), 600_000), ("ipv4".into(), 400_000)]),
997 method: "scanner".into(),
998 log_context: None,
999 match_locations: None,
1000 });
1001 let report = builder.finish();
1002 assert_eq!(report.summary.total_matches, 1_000_000);
1003 assert_eq!(report.summary.total_bytes_processed, 10_737_418_240);
1004
1005 let json = report.to_json().unwrap();
1007 assert!(json.contains("10737418240"));
1008 }
1009
1010 #[test]
1013 fn record_files_bulk() {
1014 let builder = ReportBuilder::new(sample_metadata());
1015 let files: Vec<FileReport> = (0..5)
1016 .map(|i| sample_file_report(&format!("entry_{i}.txt"), 2, "ssn"))
1017 .collect();
1018 builder.record_files(files);
1019 let report = builder.finish();
1020 assert_eq!(report.summary.total_files, 5);
1021 assert_eq!(report.summary.total_matches, 10);
1022 }
1023
1024 fn rich_report() -> SanitizeReport {
1027 let builder = ReportBuilder::new(sample_metadata());
1028 builder.record_file(FileReport {
1029 path: "config.yaml".into(),
1030 matches: 3,
1031 replacements: 3,
1032 bytes_processed: 1024,
1033 bytes_output: 1100,
1034 pattern_counts: HashMap::from([("auth_token".into(), 2u64), ("email".into(), 1u64)]),
1035 method: "structured:yaml".into(),
1036 log_context: None,
1037 match_locations: None,
1038 });
1039 builder.record_file(FileReport {
1040 path: "logs/app.log".into(),
1041 matches: 0,
1042 replacements: 0,
1043 bytes_processed: 512,
1044 bytes_output: 512,
1045 pattern_counts: HashMap::new(),
1046 method: "scanner".into(),
1047 log_context: None,
1048 match_locations: None,
1049 });
1050 builder.finish()
1051 }
1052
1053 #[test]
1054 fn sarif_is_valid_json() {
1055 let sarif = rich_report().to_sarif().unwrap();
1056 let v: serde_json::Value = serde_json::from_str(&sarif).unwrap();
1057 assert_eq!(v["version"], "2.1.0");
1058 assert_eq!(
1059 v["$schema"],
1060 "https://json.schemastore.org/sarif-2.1.0.json"
1061 );
1062 }
1063
1064 #[test]
1065 fn sarif_contains_one_run() {
1066 let v: serde_json::Value =
1067 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1068 assert_eq!(v["runs"].as_array().unwrap().len(), 1);
1069 }
1070
1071 #[test]
1072 fn sarif_driver_name_and_version() {
1073 let v: serde_json::Value =
1074 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1075 let driver = &v["runs"][0]["tool"]["driver"];
1076 assert_eq!(driver["name"], "rust-sanitize");
1077 assert_eq!(driver["version"], "0.2.0");
1078 }
1079
1080 #[test]
1081 fn sarif_rules_one_per_pattern() {
1082 let v: serde_json::Value =
1083 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1084 let rules = v["runs"][0]["tool"]["driver"]["rules"].as_array().unwrap();
1085 assert_eq!(rules.len(), 2);
1087 let ids: Vec<&str> = rules.iter().map(|r| r["id"].as_str().unwrap()).collect();
1088 assert!(ids.contains(&"auth_token"));
1089 assert!(ids.contains(&"email"));
1090 }
1091
1092 #[test]
1093 fn sarif_results_only_for_nonzero_counts() {
1094 let v: serde_json::Value =
1095 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1096 let results = v["runs"][0]["results"].as_array().unwrap();
1097 assert_eq!(results.len(), 2);
1099 }
1100
1101 #[test]
1102 fn sarif_result_level_pii_is_error() {
1103 let v: serde_json::Value =
1104 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1105 let results = v["runs"][0]["results"].as_array().unwrap();
1106 let email_result = results
1107 .iter()
1108 .find(|r| r["ruleId"] == "email")
1109 .expect("email result missing");
1110 assert_eq!(email_result["level"], "error");
1111 }
1112
1113 #[test]
1114 fn sarif_result_has_file_uri() {
1115 let v: serde_json::Value =
1116 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1117 let results = v["runs"][0]["results"].as_array().unwrap();
1118 for result in results {
1119 let uri = result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
1120 .as_str()
1121 .unwrap();
1122 assert_eq!(uri, "config.yaml");
1123 }
1124 }
1125
1126 #[test]
1127 fn sarif_artifacts_all_files() {
1128 let v: serde_json::Value =
1129 serde_json::from_str(&rich_report().to_sarif().unwrap()).unwrap();
1130 let artifacts = v["runs"][0]["artifacts"].as_array().unwrap();
1131 assert_eq!(artifacts.len(), 2);
1132 let uris: Vec<&str> = artifacts
1133 .iter()
1134 .map(|a| a["location"]["uri"].as_str().unwrap())
1135 .collect();
1136 assert!(uris.contains(&"config.yaml"));
1137 assert!(uris.contains(&"logs/app.log"));
1138 }
1139
1140 #[test]
1141 fn sarif_windows_paths_use_forward_slash() {
1142 let builder = ReportBuilder::new(sample_metadata());
1143 builder.record_file(FileReport {
1144 path: r"src\secrets\config.json".into(),
1145 matches: 1,
1146 replacements: 1,
1147 bytes_processed: 100,
1148 bytes_output: 110,
1149 pattern_counts: HashMap::from([("auth_token".into(), 1u64)]),
1150 method: "structured:json".into(),
1151 log_context: None,
1152 match_locations: None,
1153 });
1154 let report = builder.finish();
1155 let v: serde_json::Value = serde_json::from_str(&report.to_sarif().unwrap()).unwrap();
1156 let uri = v["runs"][0]["results"][0]["locations"][0]["physicalLocation"]
1157 ["artifactLocation"]["uri"]
1158 .as_str()
1159 .unwrap();
1160 assert_eq!(uri, "src/secrets/config.json");
1161 }
1162
1163 #[test]
1166 fn html_is_valid_document() {
1167 let html = rich_report().to_html();
1168 assert!(html.starts_with("<!DOCTYPE html>"));
1169 assert!(html.contains("</html>"));
1170 assert!(html.contains("<title>rust-sanitize report</title>"));
1171 }
1172
1173 #[test]
1174 fn html_contains_summary_stats() {
1175 let html = rich_report().to_html();
1176 assert!(html.contains(">2<"), "file count missing");
1178 assert!(html.contains(">3<"), "match count missing");
1180 }
1181
1182 #[test]
1183 fn html_contains_file_paths() {
1184 let html = rich_report().to_html();
1185 assert!(html.contains("config.yaml"));
1186 assert!(html.contains("logs/app.log"));
1187 }
1188
1189 #[test]
1190 fn html_escapes_special_chars() {
1191 let builder = ReportBuilder::new(sample_metadata());
1192 builder.record_file(FileReport {
1193 path: "<script>alert(1)</script>".into(),
1194 matches: 0,
1195 replacements: 0,
1196 bytes_processed: 0,
1197 bytes_output: 0,
1198 pattern_counts: HashMap::new(),
1199 method: "scanner".into(),
1200 log_context: None,
1201 match_locations: None,
1202 });
1203 let html = builder.finish().to_html();
1204 assert!(!html.contains("<script>alert(1)</script>"));
1205 assert!(html.contains("<script>"));
1206 }
1207
1208 #[test]
1209 fn html_no_external_resources() {
1210 let html = rich_report().to_html();
1211 assert!(!html.contains("http://") || !html.contains("https://json.schemastore.org"));
1213 assert!(!html.contains("cdn."));
1214 assert!(!html.contains("src=\"http"));
1215 assert!(!html.contains("href=\"http"));
1216 }
1217
1218 #[test]
1221 fn sarif_rule_name_camel_case() {
1222 assert_eq!(sarif_rule_name("auth_token"), "AuthToken");
1223 assert_eq!(sarif_rule_name("email"), "Email");
1224 assert_eq!(sarif_rule_name("custom:password"), "CustomPassword");
1225 assert_eq!(sarif_rule_name("aws_arn"), "AwsArn");
1226 }
1227
1228 #[test]
1229 fn fmt_bytes_human_readable() {
1230 assert_eq!(fmt_bytes(512), "512 B");
1231 assert_eq!(fmt_bytes(1024), "1.0 KiB");
1232 assert_eq!(fmt_bytes(1536), "1.5 KiB");
1233 assert_eq!(fmt_bytes(1024 * 1024), "1.0 MiB");
1234 assert_eq!(fmt_bytes(1024 * 1024 * 1024), "1.0 GiB");
1235 }
1236
1237 #[test]
1238 fn html_escape_special_chars() {
1239 assert_eq!(html_escape("a&b"), "a&b");
1240 assert_eq!(html_escape("<tag>"), "<tag>");
1241 assert_eq!(html_escape("\"quote\""), ""quote"");
1242 assert_eq!(html_escape("normal"), "normal");
1243 }
1244}