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    #[allow(clippy::too_many_lines)]
121    pub fn to_sarif(&self) -> serde_json::Result<String> {
122        use serde_json::json;
123
124        // Collect unique named pattern IDs in sorted order → one SARIF rule each.
125        // When structured-processor-only runs produce matches without named patterns,
126        // add the synthetic "sensitive_value" rule to cover those results.
127        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        // One SARIF result per (file, pattern) pair where count > 0.
176        // Files with matches but no named breakdown emit a single generic result.
177        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                    // Emit startLine when we have location data for this pattern.
203                    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    /// Render the report as a self-contained HTML document.
268    ///
269    /// The output has no external dependencies (no CDN, no external fonts).
270    /// Includes a summary dashboard, per-pattern totals, and a per-file table.
271    /// Dark mode is supported via `prefers-color-scheme`.
272    #[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        // --- summary cards ---------------------------------------------------
279        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        // --- pattern breakdown table (only when there are matches) -----------
295        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        // --- per-file table --------------------------------------------------
318        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}&nbsp;·&nbsp;{timestamp}&nbsp;·&nbsp;{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
446// ---------------------------------------------------------------------------
447// Private helpers
448// ---------------------------------------------------------------------------
449
450fn is_pii_category(pattern: &str) -> bool {
451    matches!(
452        pattern,
453        "email" | "name" | "phone" | "credit_card" | "ssn" | "auth_token" | "jwt"
454    )
455}
456
457/// Map a pattern name to a SARIF severity level.
458/// PII and credential categories → "error"; everything else → "warning".
459fn sarif_level(pattern: &str) -> &'static str {
460    if is_pii_category(pattern) {
461        "error"
462    } else {
463        "warning"
464    }
465}
466
467/// Convert a pattern name to a CamelCase SARIF rule name.
468/// e.g. "auth_token" → "AuthToken", "custom:password" → "CustomPassword"
469fn 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
482/// Convert a file path to a SARIF URI (forward slashes, no percent-encoding).
483fn path_to_sarif_uri(path: &str) -> String {
484    path.replace('\\', "/")
485}
486
487/// CSS badge class for a pattern in the HTML report.
488fn 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/// Format a byte count as a human-readable string.
497#[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
513/// Escape HTML special characters to prevent injection in the HTML report.
514fn html_escape(s: &str) -> String {
515    s.replace('&', "&amp;")
516        .replace('<', "&lt;")
517        .replace('>', "&gt;")
518        .replace('"', "&quot;")
519}
520
521/// Tool metadata embedded in every report.
522#[derive(Debug, Clone, Serialize)]
523pub struct ReportMetadata {
524    /// Crate / binary version (from `Cargo.toml`).
525    pub version: String,
526    /// ISO-8601 timestamp when the run started.
527    pub timestamp: String,
528    /// Whether `--deterministic` was used.
529    pub deterministic: bool,
530    /// Whether `--dry-run` was used.
531    pub dry_run: bool,
532    /// Whether `--strict` was used.
533    pub strict: bool,
534    /// Chunk size in bytes (`--chunk-size`).
535    pub chunk_size: usize,
536    /// Thread count (`--threads`), if specified.
537    pub threads: Option<usize>,
538    /// Path to the secrets file, if provided.
539    pub secrets_file: Option<String>,
540}
541
542/// Aggregated summary across all processed files.
543#[derive(Debug, Clone, Serialize)]
544pub struct ReportSummary {
545    /// Number of files processed.
546    pub total_files: u64,
547    /// Total pattern matches found.
548    pub total_matches: u64,
549    /// Total replacements applied.
550    pub total_replacements: u64,
551    /// Total bytes read from input(s).
552    pub total_bytes_processed: u64,
553    /// Total bytes written to output(s).
554    pub total_bytes_output: u64,
555    /// Wall-clock duration of processing in milliseconds.
556    pub duration_ms: u64,
557    /// Aggregate per-pattern match counts.
558    pub pattern_counts: HashMap<String, u64>,
559}
560
561/// Per-match line-number results for a file, populated when
562/// `--max-match-locations` is non-zero and the scanner path is used.
563#[derive(Debug, Clone, Serialize)]
564pub struct MatchLocationsResult {
565    /// Individual match locations in document order.
566    pub locations: Vec<MatchLocation>,
567    /// `true` when the cap was hit and additional matches exist beyond
568    /// what is listed in `locations`.
569    pub truncated: bool,
570}
571
572/// Per-file result details.
573///
574/// Does **not** contain any original secret values — only counts,
575/// byte sizes, pattern labels, and the processing method used.
576#[derive(Debug, Clone, Serialize)]
577pub struct FileReport {
578    /// File path (relative or archive entry name).
579    pub path: String,
580    /// Number of matches found in this file.
581    pub matches: u64,
582    /// Number of replacements applied.
583    pub replacements: u64,
584    /// Bytes read from this file.
585    pub bytes_processed: u64,
586    /// Bytes written for this file.
587    pub bytes_output: u64,
588    /// Per-pattern match counts for this file.
589    pub pattern_counts: HashMap<String, u64>,
590    /// Processing method: `"scanner"`, `"structured:json"`, etc.
591    pub method: String,
592    /// Log context extraction results for this file, present when
593    /// `--extract-context` was used.
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub log_context: Option<LogContextResult>,
596    /// Per-match line numbers and byte offsets, present when
597    /// `--max-match-locations` is non-zero and the scanner path is used.
598    /// Structured-processor paths do not populate this field.
599    #[serde(skip_serializing_if = "Option::is_none")]
600    pub match_locations: Option<MatchLocationsResult>,
601}
602
603impl FileReport {
604    /// Build a `FileReport` from scanner [`ScanStats`].
605    #[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    /// Attach per-match location data collected via
625    /// [`crate::scanner::StreamScanner::scan_reader_with_callbacks`].
626    ///
627    /// No-ops when `locations` is empty and `truncated` is false, keeping
628    /// the JSON output clean for files with no scanner matches.
629    #[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// ---------------------------------------------------------------------------
642// Thread-safe report builder
643// ---------------------------------------------------------------------------
644
645/// Thread-safe builder that accumulates per-file results and produces
646/// a final [`SanitizeReport`].
647///
648/// Designed for concurrent use: wrap in `Arc` and share across threads.
649/// The internal `Mutex` is held only for the duration of a single
650/// `Vec::push`, so contention is negligible even at high thread counts.
651#[derive(Debug)]
652pub struct ReportBuilder {
653    metadata: ReportMetadata,
654    files: Mutex<Vec<FileReport>>,
655    start: Instant,
656}
657
658// All fields are Send + Sync natively (Mutex<Vec<_>>, Instant, owned structs),
659// so ReportBuilder auto-derives Send + Sync without unsafe.
660const _: 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    /// Create a new builder with the given metadata.
669    ///
670    /// The wall-clock timer starts now.
671    #[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    /// Attach log context extraction results to the [`FileReport`] identified
681    /// by `path`. The file must already have been recorded via
682    /// [`Self::record_file`]. Thread-safe.
683    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    /// Record the result for a single file. Thread-safe.
691    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    /// Record multiple file results at once (e.g., from archive processing).
697    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    /// Consume the builder and produce the final report.
703    ///
704    /// The duration is measured from builder creation to this call.
705    pub fn finish(self) -> SanitizeReport {
706        #[allow(clippy::cast_possible_truncation)] // duration in ms won't exceed u64
707        let duration_ms = self.start.elapsed().as_millis() as u64;
708        let files = self.files.into_inner().expect("report mutex poisoned");
709
710        // Aggregate summary.
711        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// ---------------------------------------------------------------------------
746// Unit tests
747// ---------------------------------------------------------------------------
748
749#[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    // ---- Basic construction ----
781
782    #[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    // ---- JSON serialization ----
821
822    #[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        // Must contain expected fields.
840        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        // Must NOT contain any original secret values — we only ever
847        // store counts and labels, never pattern text or matched text.
848        // This is a structural guarantee; verify that deserializing
849        // back produces the same data without secret leakage.
850        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
851        assert!(parsed["files"][0]["path"].as_str() == Some("config.yaml"));
852        // No field named "secret", "original", or "value" at any level.
853        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        // Compact JSON has no pretty indentation.
864        assert!(!json.contains("  "));
865    }
866
867    // ---- Metadata flags ----
868
869    #[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    // ---- Duration tracking ----
892
893    #[test]
894    fn duration_is_positive() {
895        let builder = ReportBuilder::new(sample_metadata());
896        // Do a tiny amount of work.
897        builder.record_file(sample_file_report("x.txt", 1, "email"));
898        let report = builder.finish();
899        // Duration should be ≥ 0 (it will be 0 or 1 on fast machines).
900        assert!(report.summary.duration_ms < 5_000); // sanity ceiling
901    }
902
903    // ---- Thread-safe concurrent recording ----
904
905    #[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        // We need to unwrap the Arc to call finish().
925        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        // Sum of 1..=16 = 136.
930        assert_eq!(report.summary.total_matches, 136);
931    }
932
933    // ---- FileReport::from_scan_stats ----
934
935    #[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    // ---- Large-file simulation ----
953
954    #[test]
955    fn large_file_report() {
956        let builder = ReportBuilder::new(sample_metadata());
957        // Simulate a 10 GB file processed in chunks.
958        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, // 10 GiB
963            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        // JSON serialization still works for large numbers.
974        let json = report.to_json().unwrap();
975        assert!(json.contains("10737418240"));
976    }
977
978    // ---- record_files bulk insert ----
979
980    #[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    // ---- SARIF output ----
993
994    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        // Two patterns: auth_token, email.
1054        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        // logs/app.log has 0 matches → 0 results for it; config.yaml has 2 patterns.
1066        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    // ---- HTML output ----
1132
1133    #[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        // 1 file with matches + 1 clean file = 2 files total.
1145        assert!(html.contains(">2<"), "file count missing");
1146        // 3 total matches.
1147        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("&lt;script&gt;"));
1174    }
1175
1176    #[test]
1177    fn html_no_external_resources() {
1178        let html = rich_report().to_html();
1179        // No CDN links, no external stylesheets, no external scripts.
1180        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    // ---- helpers ----
1187
1188    #[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&amp;b");
1208        assert_eq!(html_escape("<tag>"), "&lt;tag&gt;");
1209        assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
1210        assert_eq!(html_escape("normal"), "normal");
1211    }
1212}