Skip to main content

rust_sanitize/
report.rs

1//! Structured reporting for sanitization runs.
2//!
3//! Generates a JSON report summarising what the sanitization tool did
4//! without ever including original secret values. The report captures:
5//!
6//! - **Metadata**: tool version, CLI flags, timestamp.
7//! - **Per-file details**: matches found, replacements applied, bytes
8//!   processed, and per-pattern match counts.
9//! - **Aggregated summary**: totals across all files plus wall-clock
10//!   duration.
11//! - **Log context** (optional): keyword-matched lines with surrounding
12//!   context windows, populated when `--extract-context` is used.
13//!
14//! # Thread Safety
15//!
16//! [`ReportBuilder`] is `Send + Sync`. Multiple threads can record file
17//! results concurrently via [`ReportBuilder::record_file`], which takes
18//! an internal `Mutex` only long enough to push a single entry.
19//!
20//! # Example
21//!
22//! ```rust
23//! use rust_sanitize::log_context::{extract_context, LogContextConfig};
24//! use rust_sanitize::report::{FileReport, ReportBuilder, ReportMetadata};
25//! use std::collections::HashMap;
26//!
27//! let meta = ReportMetadata {
28//!     version: "0.4.0".into(),
29//!     timestamp: "2026-03-01T00:00:00Z".into(),
30//!     deterministic: true,
31//!     dry_run: false,
32//!     strict: false,
33//!     chunk_size: 1_048_576,
34//!     threads: Some(4),
35//!     secrets_file: Some("secrets.enc".into()),
36//! };
37//!
38//! let builder = ReportBuilder::new(meta);
39//!
40//! builder.record_file(FileReport {
41//!     path: "data.log".into(),
42//!     matches: 42,
43//!     replacements: 42,
44//!     bytes_processed: 10_000,
45//!     bytes_output: 10_200,
46//!     pattern_counts: HashMap::from([("email".into(), 30), ("ipv4".into(), 12)]),
47//!     method: "scanner".into(),
48//!     log_context: None,
49//!     match_locations: None,
50//! });
51//!
52//! // Optionally attach per-file log context (populated by --extract-context).
53//! let sanitized_output = "INFO ok\nERROR disk full\nINFO retrying";
54//! let ctx = extract_context(sanitized_output, &LogContextConfig::new().with_context_lines(1));
55//! builder.set_file_log_context("data.log", ctx);
56//!
57//! let report = builder.finish();
58//! let json = report.to_json_pretty().unwrap();
59//! assert!(json.contains("\"total_matches\": 42"));
60//! assert!(json.contains("\"log_context\""));
61//! assert!(json.contains("\"keyword\": \"error\""));
62//! ```
63
64use 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// ---------------------------------------------------------------------------
73// Report structures
74// ---------------------------------------------------------------------------
75
76/// Top-level sanitization report.
77///
78/// Serialized to JSON via [`Self::to_json`] / [`Self::to_json_pretty`].
79/// Never contains original secret values.
80#[derive(Debug, Clone, Serialize)]
81pub struct SanitizeReport {
82    /// Tool metadata and flags.
83    pub metadata: ReportMetadata,
84    /// Aggregated summary across all files.
85    pub summary: ReportSummary,
86    /// Per-file details. Each entry may include `log_context` when
87    /// `--extract-context` was used.
88    pub files: Vec<FileReport>,
89}
90
91impl SanitizeReport {
92    /// Serialize the report as compact JSON.
93    ///
94    /// # Errors
95    ///
96    /// Returns [`serde_json::Error`] if serialization fails.
97    pub fn to_json(&self) -> serde_json::Result<String> {
98        serde_json::to_string(self)
99    }
100
101    /// Serialize the report as pretty-printed JSON.
102    ///
103    /// # Errors
104    ///
105    /// Returns [`serde_json::Error`] if serialization fails.
106    pub fn to_json_pretty(&self) -> serde_json::Result<String> {
107        serde_json::to_string_pretty(self)
108    }
109
110    /// Serialize the report as SARIF 2.1.0 JSON.
111    ///
112    /// SARIF (Static Analysis Results Interchange Format) is consumed natively
113    /// by GitHub Advanced Security, VS Code Problems panel, and most SIEM
114    /// tooling. Results are file-level (no line numbers — the sanitize engine
115    /// operates on byte streams and does not record source positions).
116    ///
117    /// # Errors
118    ///
119    /// Returns [`serde_json::Error`] if serialization fails.
120    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    /// Render the report as a self-contained HTML document.
159    ///
160    /// The output has no external dependencies (no CDN, no external fonts).
161    /// Includes a summary dashboard, per-pattern totals, and a per-file table.
162    /// Dark mode is supported via `prefers-color-scheme`.
163    #[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}&nbsp;·&nbsp;{timestamp}&nbsp;·&nbsp;{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
248// ---------------------------------------------------------------------------
249// SARIF helpers
250// ---------------------------------------------------------------------------
251
252/// Build the SARIF `rules` array: one entry per named pattern plus an optional
253/// synthetic `"sensitive_value"` rule for files whose matches have no named breakdown.
254fn 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
306/// Build the SARIF `results` array: one entry per (file, pattern) pair with a
307/// non-zero count, including the first known line number when available.
308fn 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            // Use startLine when we have location data for this pattern.
337            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
368// ---------------------------------------------------------------------------
369// HTML helpers
370// ---------------------------------------------------------------------------
371
372/// Render the five summary metric cards (files, matches, replacements, input, duration).
373fn 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
390/// Render the per-pattern breakdown table, sorted by match count descending.
391/// Returns an empty string when there are no matches (section is omitted).
392fn 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
419/// Render a single `<tr>` for the per-file table.
420/// `has_locations` controls whether the "First match" column is included.
421fn 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
478// ---------------------------------------------------------------------------
479// Private helpers
480// ---------------------------------------------------------------------------
481
482fn is_pii_category(pattern: &str) -> bool {
483    matches!(
484        pattern,
485        "email" | "name" | "phone" | "credit_card" | "ssn" | "auth_token" | "jwt"
486    )
487}
488
489/// Map a pattern name to a SARIF severity level.
490/// PII and credential categories → "error"; everything else → "warning".
491fn sarif_level(pattern: &str) -> &'static str {
492    if is_pii_category(pattern) {
493        "error"
494    } else {
495        "warning"
496    }
497}
498
499/// Convert a pattern name to a CamelCase SARIF rule name.
500/// e.g. "auth_token" → "AuthToken", "custom:password" → "CustomPassword"
501fn 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
514/// Convert a file path to a SARIF URI (forward slashes, no percent-encoding).
515fn path_to_sarif_uri(path: &str) -> String {
516    path.replace('\\', "/")
517}
518
519/// CSS badge class for a pattern in the HTML report.
520fn 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/// Format a byte count as a human-readable string.
529#[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
545/// Escape HTML special characters to prevent injection in the HTML report.
546fn html_escape(s: &str) -> String {
547    s.replace('&', "&amp;")
548        .replace('<', "&lt;")
549        .replace('>', "&gt;")
550        .replace('"', "&quot;")
551}
552
553/// Tool metadata embedded in every report.
554#[derive(Debug, Clone, Serialize)]
555pub struct ReportMetadata {
556    /// Crate / binary version (from `Cargo.toml`).
557    pub version: String,
558    /// ISO-8601 timestamp when the run started.
559    pub timestamp: String,
560    /// Whether `--deterministic` was used.
561    pub deterministic: bool,
562    /// Whether `--dry-run` was used.
563    pub dry_run: bool,
564    /// Whether `--strict` was used.
565    pub strict: bool,
566    /// Chunk size in bytes (`--chunk-size`).
567    pub chunk_size: usize,
568    /// Thread count (`--threads`), if specified.
569    pub threads: Option<usize>,
570    /// Path to the secrets file, if provided.
571    pub secrets_file: Option<String>,
572}
573
574/// Aggregated summary across all processed files.
575#[derive(Debug, Clone, Serialize)]
576pub struct ReportSummary {
577    /// Number of files processed.
578    pub total_files: u64,
579    /// Total pattern matches found.
580    pub total_matches: u64,
581    /// Total replacements applied.
582    pub total_replacements: u64,
583    /// Total bytes read from input(s).
584    pub total_bytes_processed: u64,
585    /// Total bytes written to output(s).
586    pub total_bytes_output: u64,
587    /// Wall-clock duration of processing in milliseconds.
588    pub duration_ms: u64,
589    /// Aggregate per-pattern match counts.
590    pub pattern_counts: HashMap<String, u64>,
591}
592
593/// Per-match line-number results for a file, populated when
594/// `--max-match-locations` is non-zero and the scanner path is used.
595#[derive(Debug, Clone, Serialize)]
596pub struct MatchLocationsResult {
597    /// Individual match locations in document order.
598    pub locations: Vec<MatchLocation>,
599    /// `true` when the cap was hit and additional matches exist beyond
600    /// what is listed in `locations`.
601    pub truncated: bool,
602}
603
604/// Per-file result details.
605///
606/// Does **not** contain any original secret values — only counts,
607/// byte sizes, pattern labels, and the processing method used.
608#[derive(Debug, Clone, Serialize)]
609pub struct FileReport {
610    /// File path (relative or archive entry name).
611    pub path: String,
612    /// Number of matches found in this file.
613    pub matches: u64,
614    /// Number of replacements applied.
615    pub replacements: u64,
616    /// Bytes read from this file.
617    pub bytes_processed: u64,
618    /// Bytes written for this file.
619    pub bytes_output: u64,
620    /// Per-pattern match counts for this file.
621    pub pattern_counts: HashMap<String, u64>,
622    /// Processing method: `"scanner"`, `"structured:json"`, etc.
623    pub method: String,
624    /// Log context extraction results for this file, present when
625    /// `--extract-context` was used.
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub log_context: Option<LogContextResult>,
628    /// Per-match line numbers and byte offsets, present when
629    /// `--max-match-locations` is non-zero and the scanner path is used.
630    /// Structured-processor paths do not populate this field.
631    #[serde(skip_serializing_if = "Option::is_none")]
632    pub match_locations: Option<MatchLocationsResult>,
633}
634
635impl FileReport {
636    /// Build a `FileReport` from scanner [`ScanStats`].
637    #[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    /// Attach per-match location data collected via
657    /// [`crate::scanner::StreamScanner::scan_reader_with_callbacks`].
658    ///
659    /// No-ops when `locations` is empty and `truncated` is false, keeping
660    /// the JSON output clean for files with no scanner matches.
661    #[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// ---------------------------------------------------------------------------
674// Thread-safe report builder
675// ---------------------------------------------------------------------------
676
677/// Thread-safe builder that accumulates per-file results and produces
678/// a final [`SanitizeReport`].
679///
680/// Designed for concurrent use: wrap in `Arc` and share across threads.
681/// The internal `Mutex` is held only for the duration of a single
682/// `Vec::push`, so contention is negligible even at high thread counts.
683#[derive(Debug)]
684pub struct ReportBuilder {
685    metadata: ReportMetadata,
686    files: Mutex<Vec<FileReport>>,
687    start: Instant,
688}
689
690// All fields are Send + Sync natively (Mutex<Vec<_>>, Instant, owned structs),
691// so ReportBuilder auto-derives Send + Sync without unsafe.
692const _: 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    /// Create a new builder with the given metadata.
701    ///
702    /// The wall-clock timer starts now.
703    #[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    /// Attach log context extraction results to the [`FileReport`] identified
713    /// by `path`. The file must already have been recorded via
714    /// [`Self::record_file`]. Thread-safe.
715    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    /// Record the result for a single file. Thread-safe.
723    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    /// Record multiple file results at once (e.g., from archive processing).
729    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    /// Consume the builder and produce the final report.
735    ///
736    /// The duration is measured from builder creation to this call.
737    pub fn finish(self) -> SanitizeReport {
738        #[allow(clippy::cast_possible_truncation)] // duration in ms won't exceed u64
739        let duration_ms = self.start.elapsed().as_millis() as u64;
740        let files = self.files.into_inner().expect("report mutex poisoned");
741
742        // Aggregate summary.
743        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// ---------------------------------------------------------------------------
778// Unit tests
779// ---------------------------------------------------------------------------
780
781#[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    // ---- Basic construction ----
813
814    #[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    // ---- JSON serialization ----
853
854    #[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        // Must contain expected fields.
872        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        // Must NOT contain any original secret values — we only ever
879        // store counts and labels, never pattern text or matched text.
880        // This is a structural guarantee; verify that deserializing
881        // back produces the same data without secret leakage.
882        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
883        assert!(parsed["files"][0]["path"].as_str() == Some("config.yaml"));
884        // No field named "secret", "original", or "value" at any level.
885        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        // Compact JSON has no pretty indentation.
896        assert!(!json.contains("  "));
897    }
898
899    // ---- Metadata flags ----
900
901    #[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    // ---- Duration tracking ----
924
925    #[test]
926    fn duration_is_positive() {
927        let builder = ReportBuilder::new(sample_metadata());
928        // Do a tiny amount of work.
929        builder.record_file(sample_file_report("x.txt", 1, "email"));
930        let report = builder.finish();
931        // Duration should be ≥ 0 (it will be 0 or 1 on fast machines).
932        assert!(report.summary.duration_ms < 5_000); // sanity ceiling
933    }
934
935    // ---- Thread-safe concurrent recording ----
936
937    #[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        // We need to unwrap the Arc to call finish().
957        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        // Sum of 1..=16 = 136.
962        assert_eq!(report.summary.total_matches, 136);
963    }
964
965    // ---- FileReport::from_scan_stats ----
966
967    #[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    // ---- Large-file simulation ----
985
986    #[test]
987    fn large_file_report() {
988        let builder = ReportBuilder::new(sample_metadata());
989        // Simulate a 10 GB file processed in chunks.
990        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, // 10 GiB
995            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        // JSON serialization still works for large numbers.
1006        let json = report.to_json().unwrap();
1007        assert!(json.contains("10737418240"));
1008    }
1009
1010    // ---- record_files bulk insert ----
1011
1012    #[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    // ---- SARIF output ----
1025
1026    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        // Two patterns: auth_token, email.
1086        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        // logs/app.log has 0 matches → 0 results for it; config.yaml has 2 patterns.
1098        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    // ---- HTML output ----
1164
1165    #[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        // 1 file with matches + 1 clean file = 2 files total.
1177        assert!(html.contains(">2<"), "file count missing");
1178        // 3 total matches.
1179        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("&lt;script&gt;"));
1206    }
1207
1208    #[test]
1209    fn html_no_external_resources() {
1210        let html = rich_report().to_html();
1211        // No CDN links, no external stylesheets, no external scripts.
1212        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    // ---- helpers ----
1219
1220    #[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&amp;b");
1240        assert_eq!(html_escape("<tag>"), "&lt;tag&gt;");
1241        assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
1242        assert_eq!(html_escape("normal"), "normal");
1243    }
1244}