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