Skip to main content

keyhog_core/
report.rs

1//! Reporting logic for scan results.
2
3pub(crate) mod csv;
4pub(crate) mod escape;
5pub(crate) mod github_annotations;
6pub(crate) mod gitlab_sast;
7pub(crate) mod html;
8pub(crate) mod json;
9pub(crate) mod junit;
10pub(crate) mod sarif;
11mod style;
12pub(crate) mod text;
13
14#[path = "report/sarif_uri.rs"]
15pub(crate) mod sarif_uri;
16
17use std::collections::BTreeMap;
18use std::io::Write;
19
20use crate::VerifiedFinding;
21
22/// Serialize redacted companion values deterministically for report formats
23/// that expose a scalar details field instead of the native JSON object.
24pub(crate) fn companions_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
25    let companions: BTreeMap<&str, &str> = finding
26        .companions_redacted
27        .iter()
28        .map(|(key, value)| (key.as_str(), value.as_str()))
29        .collect();
30    Ok(serde_json::to_string(&companions)?)
31}
32
33/// Serialize the canonical Tier-B remediation projection for scalar report
34/// formats. Keeping this beside companion serialization prevents CSV and other
35/// adapters from inventing provider-specific remediation logic.
36pub(crate) fn remediation_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
37    let remediation =
38        crate::auto_fix::remediation_for(&finding.detector_id, &finding.service, finding.severity);
39    Ok(serde_json::to_string(&remediation)?)
40}
41
42/// Common error type used by all reporters.
43pub use anyhow::Error as ReportError;
44
45/// Terminal state carried by detached scan artifacts.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ScanCompletionStatus {
49    /// The requested input completed without coverage gaps.
50    Success,
51    /// Every requested byte completed, with one or more backend ranges replayed
52    /// after a visible recoverable runtime fault.
53    CompleteAfterRecovery,
54    /// The artifact completed, but one or more requested inputs were not fully scanned.
55    Partial,
56    /// The operator or host interrupted the scan before completion.
57    Cancelled,
58    /// The scan failed before it could produce a trustworthy complete result.
59    Failed,
60}
61
62impl Default for ScanCompletionStatus {
63    fn default() -> Self {
64        Self::Success
65    }
66}
67
68impl ScanCompletionStatus {
69    /// Derive the normal terminal state from the coverage summary.
70    #[must_use]
71    pub fn from_coverage_gaps(has_gaps: bool) -> Self {
72        if has_gaps {
73            Self::Partial
74        } else {
75            Self::Success
76        }
77    }
78
79    /// Resolve metadata and observed coverage into one terminal state.
80    ///
81    /// A non-empty coverage summary upgrades an optimistic `success` metadata
82    /// value to `partial`, while explicit `cancelled` and `failed` states are
83    /// preserved even when no gap counter was recorded.
84    #[must_use]
85    pub fn resolve(metadata: Option<Self>, has_gaps: bool) -> Self {
86        match metadata {
87            Some(Self::Cancelled) => Self::Cancelled,
88            Some(Self::Failed) => Self::Failed,
89            Some(Self::Partial) => Self::Partial,
90            Some(Self::CompleteAfterRecovery) if has_gaps => Self::Partial,
91            Some(Self::CompleteAfterRecovery) => Self::CompleteAfterRecovery,
92            Some(Self::Success) if has_gaps => Self::Partial,
93            Some(status) => status,
94            None => Self::from_coverage_gaps(has_gaps),
95        }
96    }
97}
98
99/// Stable, machine-diffable description of the resolved detection mode.
100///
101/// The preset names the shipped base (`default`, `fast`, `deep`, or
102/// `precision`). `effective` contains the scalar and list-identity values the
103/// scanner actually used, while `overrides` names values that refine that
104/// preset. Maps are ordered so equivalent scans serialize byte-for-byte
105/// identically across report formats and benchmark runs.
106#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107pub struct ResolvedScanManifest {
108    /// Version of this manifest contract. Additive fields require a new minor
109    /// report schema, while a breaking manifest change increments this value.
110    pub schema_version: u16,
111    /// Shipped base preset selected for the scan.
112    pub preset: String,
113    /// Resolved detection settings encoded as stable string values.
114    pub effective: BTreeMap<String, String>,
115    /// Settings that differ from the selected preset base.
116    pub overrides: Vec<String>,
117}
118
119/// Bounded, non-secret summary of one completed exact recovery.
120///
121/// Exact dispatch-local ranges remain available on the daemon wire. Detached
122/// reports carry stable aggregates because scanner chunk indices restart for
123/// each batch and therefore are not durable source identities. Admission-plan
124/// recovery uses the selected backend in both backend fields and identifies
125/// the rejected identity in `reason`.
126#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
127pub struct ScanBackendRecoverySummary {
128    /// Number of recovery events represented by this summary row.
129    pub events: usize,
130    /// Selected backend whose work required recovery, or `autoroute-invalid`
131    /// when recovery was required before a trustworthy route was selected.
132    pub failed_backend: String,
133    /// Backend that completed the stable input ranges after exact recovery.
134    pub recovery_backend: String,
135    /// Number of canonical disjoint ranges recovered in this event.
136    pub recovered_ranges: usize,
137    /// Number of distinct scanner chunks containing those ranges.
138    pub recovered_chunks: usize,
139    /// Total recovered source bytes across the canonical ranges.
140    pub recovered_bytes: u64,
141    /// Non-secret recovery diagnostic.
142    pub reason: String,
143    /// Canonical operator action for the represented recovery condition.
144    pub repair_command: String,
145}
146
147/// Schema generation for exact bounded static-recovery telemetry.
148pub const STATIC_RECOVERY_METRICS_SCHEMA_VERSION: &str = "static-recovery-v1";
149
150/// Exact, non-secret bounded static-recovery telemetry for one scan.
151///
152/// `reasons` contains rejection counts only. Its unsupported reasons conserve
153/// to `unsupported`; all remaining reasons conserve to `erroneous`.
154#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155pub struct StaticRecoveryMetrics {
156    /// Version of this nested telemetry contract.
157    pub schema_version: String,
158    /// Recognized expressions successfully evaluated within the static bounds.
159    pub supported: u64,
160    /// Recognized expressions requiring unsupported dynamic behavior.
161    pub unsupported: u64,
162    /// Recognized expressions rejected because their bounded evaluation failed.
163    pub erroneous: u64,
164    /// Exact rejection counts keyed by the stable scanner reason vocabulary.
165    pub reasons: BTreeMap<String, u64>,
166}
167
168/// Format-neutral operator-visible metadata for a scan report.
169///
170/// The metadata belongs to the report, not to one renderer. Individual output
171/// formats project the fields they can represent: HTML renders the complete
172/// object, while schema-constrained formats retain their established fields.
173/// Keeping this model in `keyhog-core` prevents the CLI and a single reporter
174/// from growing competing definitions of scan identity and timing.
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct ScanReportMetadata {
177    /// Stable non-secret identifier shared by artifacts from one scan run.
178    /// Missing values deserialize as empty for reports produced before this
179    /// field was introduced; current producers always populate it.
180    #[serde(default)]
181    pub scan_id: String,
182    /// Terminal state for detached artifacts. Older reports default to success
183    /// because they predate this explicit field and have no state to recover.
184    #[serde(default)]
185    pub scan_status: ScanCompletionStatus,
186    /// Completed exact recovery events. Empty means no backend fault or
187    /// admission-plan identity mismatch required recovery.
188    #[serde(default, skip_serializing_if = "Vec::is_empty")]
189    pub backend_recoveries: Vec<ScanBackendRecoverySummary>,
190    /// Exact bounded static-recovery telemetry. Absent only in legacy reports
191    /// produced before JSON report schema 1.8.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub static_recovery: Option<StaticRecoveryMetrics>,
194    /// KeyHog crate/binary version that produced the report.
195    pub keyhog_version: String,
196    /// Git identity of the binary that produced the report.
197    pub git_hash: String,
198    /// Digest of the embedded detector set compiled into the binary.
199    pub detector_digest: String,
200    /// Digest of the effective scan configuration, when the orchestrator had
201    /// a resolved configuration identity available.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub config_digest: Option<String>,
204    /// Stable resolved preset and override manifest for mode comparisons.
205    /// Absent only for reports produced by callers that do not have a CLI scan
206    /// configuration (for example a library-created report).
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub resolved_scan: Option<ResolvedScanManifest>,
209    /// UTC generation timestamp formatted as `YYYY-MM-DDTHH:MM:SS`.
210    pub generated_at: String,
211    /// UTC scan start timestamp formatted as `YYYY-MM-DDTHH:MM:SS`.
212    pub scan_started_at: String,
213    /// UTC scan finish timestamp formatted as `YYYY-MM-DDTHH:MM:SS`.
214    pub scan_finished_at: String,
215    /// Wall-clock scan duration in milliseconds.
216    pub duration_ms: u128,
217    /// Redacted operator-visible target labels for the requested scan sources.
218    pub targets: Vec<String>,
219    /// Number of source chunks the scanner consumed for this report.
220    pub source_chunks_scanned: usize,
221    /// Number of source bytes the scanner consumed for this report.
222    pub source_bytes_scanned: u64,
223    /// Number of loaded detector specs used by this scan.
224    pub detector_count: usize,
225}
226
227/// Current major version for the versioned JSON report envelope.
228pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 1;
229/// Current minor version for the versioned JSON report envelope.
230pub const JSON_REPORT_SCHEMA_MINOR: u16 = 8;
231/// Current minor version for the versioned JSONL stream contract.
232pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 9;
233
234/// Version marker carried by every versioned JSON report.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
236pub struct JsonReportSchemaVersion {
237    /// Incompatible schema generation.
238    pub major: u16,
239    /// Backward-compatible additive revision.
240    pub minor: u16,
241}
242
243/// Versioned machine-readable JSON report.
244///
245/// A reader must reject an unsupported `major` and may accept any `minor`
246/// under a supported major because minor revisions only add optional fields.
247#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
248pub struct JsonReportEnvelope {
249    /// Version marker used to select the reader contract.
250    pub schema_version: JsonReportSchemaVersion,
251    /// Terminal state for the detached artifact, independent of process exit status.
252    #[serde(default)]
253    pub scan_status: ScanCompletionStatus,
254    /// Optional scan-wide metadata supplied by the producer.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub metadata: Option<ScanReportMetadata>,
257    /// Non-zero source or scanner coverage gaps observed during the scan.
258    #[serde(default)]
259    pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
260    /// Findings in the same redacted shape used by the legacy array.
261    pub findings: Vec<VerifiedFinding>,
262}
263
264/// One scan-wide coverage gap preserved in a versioned JSON report.
265#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
266pub struct JsonReportCoverageGap {
267    /// Stable machine-readable reason shared with SARIF/HTML projections.
268    pub reason: String,
269    /// Number of affected files, chunks, or invariant events.
270    pub count: usize,
271}
272
273impl JsonReportEnvelope {
274    /// Parse and validate a versioned JSON report.
275    pub fn parse(input: &str) -> Result<Self, ReportError> {
276        let report: Self = serde_json::from_str(input)?;
277        if report.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
278            anyhow::bail!(
279                "unsupported JSON report schema major {}; this reader supports major {}",
280                report.schema_version.major,
281                JSON_REPORT_SCHEMA_MAJOR
282            );
283        }
284        Ok(report)
285    }
286}
287
288/// Header written as the first record of a versioned JSONL stream.
289#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
290pub struct JsonlStreamHeader {
291    /// Distinguishes the stream header from a finding record.
292    pub record_type: String,
293    /// Version marker used to select the JSONL reader contract.
294    pub schema_version: JsonReportSchemaVersion,
295    /// Optional scan-wide metadata supplied by the producer.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub metadata: Option<ScanReportMetadata>,
298}
299
300impl JsonlStreamHeader {
301    /// Construct a stream header for the current schema.
302    #[must_use]
303    pub fn new(metadata: Option<&ScanReportMetadata>) -> Self {
304        Self {
305            record_type: "header".to_string(),
306            schema_version: JsonReportSchemaVersion {
307                major: JSON_REPORT_SCHEMA_MAJOR,
308                minor: JSONL_REPORT_SCHEMA_MINOR,
309            },
310            metadata: metadata.cloned(),
311        }
312    }
313
314    /// Parse and validate one JSONL header record.
315    pub fn parse(input: &str) -> Result<Self, ReportError> {
316        let header: Self = serde_json::from_str(input)?;
317        if header.record_type != "header" {
318            anyhow::bail!(
319                "invalid JSONL stream header record_type {:?}",
320                header.record_type
321            );
322        }
323        if header.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
324            anyhow::bail!(
325                "unsupported JSONL report schema major {}; this reader supports major {}",
326                header.schema_version.major,
327                JSON_REPORT_SCHEMA_MAJOR
328            );
329        }
330        Ok(header)
331    }
332}
333
334/// One validated segment of a JSONL input. Concatenated streams produce one
335/// segment per header, so boundaries remain explicit instead of being inferred
336/// from finding content.
337#[derive(Debug, Clone)]
338pub struct JsonlStream {
339    /// Header that governed this segment.
340    pub header: JsonlStreamHeader,
341    /// Terminal summary when the producer completed normally. None means
342    /// the input ended before completion and must not be treated as complete.
343    pub summary: Option<JsonlStreamSummary>,
344    /// Findings following the header until the next header or end of input.
345    pub findings: Vec<VerifiedFinding>,
346}
347
348/// Terminal record written when a versioned JSONL stream completes.
349#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
350pub struct JsonlStreamSummary {
351    /// Distinguishes the terminal record from headers and findings.
352    pub record_type: String,
353    /// Completion state for the stream.
354    pub status: String,
355    /// Terminal scan state; `status` remains the transport completion marker.
356    #[serde(default)]
357    pub scan_status: ScanCompletionStatus,
358    /// Number of finding records written before this summary.
359    pub finding_count: usize,
360    /// Coverage gaps observed during the stream.
361    #[serde(default)]
362    pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
363}
364
365impl JsonlStreamSummary {
366    /// Construct a complete summary for a stream.
367    #[must_use]
368    pub fn complete(finding_count: usize, coverage_gap_summary: &[(String, usize)]) -> Self {
369        Self::complete_with_status(
370            finding_count,
371            ScanCompletionStatus::from_coverage_gaps(!coverage_gap_summary.is_empty()),
372            coverage_gap_summary,
373        )
374    }
375
376    /// Construct a complete summary using an explicitly recorded terminal
377    /// state from the scan metadata.
378    #[must_use]
379    pub fn complete_with_status(
380        finding_count: usize,
381        scan_status: ScanCompletionStatus,
382        coverage_gap_summary: &[(String, usize)],
383    ) -> Self {
384        Self {
385            record_type: "summary".to_string(),
386            status: "complete".to_string(),
387            scan_status,
388            finding_count,
389            coverage_gap_summary: coverage_gap_summary
390                .iter()
391                .map(|(reason, count)| JsonReportCoverageGap {
392                    reason: reason.clone(),
393                    count: *count,
394                })
395                .collect(),
396        }
397    }
398
399    fn parse(input: &str) -> Result<Self, ReportError> {
400        let summary: Self = serde_json::from_str(input)?;
401        if summary.record_type != "summary" || summary.status != "complete" {
402            anyhow::bail!(
403                "invalid JSONL stream summary: record_type={:?}, status={:?}",
404                summary.record_type,
405                summary.status
406            );
407        }
408        Ok(summary)
409    }
410}
411
412impl JsonlStream {
413    /// Whether the stream has a validated terminal summary.
414    #[must_use]
415    pub fn is_complete(&self) -> bool {
416        self.summary.is_some()
417    }
418}
419
420/// Parse one or more concatenated, versioned JSONL streams.
421pub fn parse_jsonl_stream(input: &str) -> Result<Vec<JsonlStream>, ReportError> {
422    let mut streams = Vec::new();
423    let mut current: Option<JsonlStream> = None;
424
425    for (index, line) in input.lines().enumerate() {
426        let line_number = index + 1;
427        if line.trim().is_empty() {
428            anyhow::bail!("JSONL line {line_number} is empty; remove blank records");
429        }
430        let value: serde_json::Value = serde_json::from_str(line)
431            .map_err(|error| anyhow::anyhow!("invalid JSONL line {line_number}: {error}"))?;
432        let is_header =
433            value.get("record_type").and_then(serde_json::Value::as_str) == Some("header");
434        if is_header {
435            if let Some(stream) = current.take() {
436                streams.push(stream);
437            }
438            current = Some(JsonlStream {
439                header: JsonlStreamHeader::parse(line)?,
440                summary: None,
441                findings: Vec::new(),
442            });
443            continue;
444        }
445
446        let stream = current.as_mut().ok_or_else(|| {
447            anyhow::anyhow!("JSONL line {line_number} precedes its stream header")
448        })?;
449        let is_summary =
450            value.get("record_type").and_then(serde_json::Value::as_str) == Some("summary");
451        if is_summary {
452            if stream.summary.is_some() {
453                anyhow::bail!("JSONL line {line_number} repeats the terminal summary");
454            }
455            let summary = JsonlStreamSummary::parse(line)?;
456            if summary.finding_count != stream.findings.len() {
457                anyhow::bail!(
458                    "JSONL summary count {} does not match {} finding records",
459                    summary.finding_count,
460                    stream.findings.len()
461                );
462            }
463            stream.summary = Some(summary);
464            continue;
465        }
466        if stream.summary.is_some() {
467            anyhow::bail!("JSONL line {line_number} follows the terminal summary");
468        }
469        let finding = serde_json::from_value(value).map_err(|error| {
470            anyhow::anyhow!("invalid finding on JSONL line {line_number}: {error}")
471        })?;
472        stream.findings.push(finding);
473    }
474
475    if let Some(stream) = current {
476        streams.push(stream);
477    }
478    if streams.is_empty() {
479        anyhow::bail!("JSONL stream is empty; expected a versioned header record");
480    }
481    Ok(streams)
482}
483
484/// Compatibility name for callers that used the original HTML-only type.
485///
486/// New code should use [`ScanReportMetadata`]. The alias is intentionally kept
487/// so a report-format migration does not break library consumers.
488pub type HtmlScanMetadata = ScanReportMetadata;
489
490/// The format-neutral input shared by every report renderer.
491///
492/// Renderers borrow findings so constructing a report does not copy a large
493/// finding set. Metadata is optional for the legacy [`write_report`] wrapper;
494/// production scan paths should pass it through [`write_scan_report`].
495#[derive(Debug, Clone, Copy)]
496pub struct ScanReport<'a> {
497    /// Findings after all scan filtering, suppression, and verification.
498    pub findings: &'a [VerifiedFinding],
499    /// Common scan identity and timing metadata, when the caller has it.
500    pub metadata: Option<&'a ScanReportMetadata>,
501}
502
503impl<'a> ScanReport<'a> {
504    /// Create a report without optional metadata.
505    pub fn new(findings: &'a [VerifiedFinding]) -> Self {
506        Self {
507            findings,
508            metadata: None,
509        }
510    }
511
512    /// Attach the common scan metadata used by format projections.
513    #[must_use]
514    pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
515        self.metadata = Some(metadata);
516        self
517    }
518}
519
520/// Output format and formatter options for [`write_report`].
521pub enum ReportFormat {
522    /// Human-oriented terminal output.
523    Text {
524        /// Emit ANSI color escapes.
525        color: bool,
526        /// Number of example suppression hints to include.
527        example_suppressions: usize,
528        /// Include dogfood telemetry hints in the text report.
529        dogfood_active: bool,
530    },
531    /// JSON array output.
532    Json,
533    /// Versioned JSON envelope output and its scan-wide coverage summary.
534    JsonEnvelope {
535        /// Non-zero source or scanner coverage gaps observed during the scan.
536        coverage_gap_summary: Vec<(String, usize)>,
537    },
538    /// Newline-delimited JSON output.
539    Jsonl,
540    /// Versioned newline-delimited JSON output with a stream header.
541    JsonlEnvelope {
542        /// Non-zero source or scanner coverage gaps observed during the scan.
543        coverage_gap_summary: Vec<(String, usize)>,
544    },
545    /// SARIF output.
546    Sarif {
547        /// Operator-visible scan coverage-gap summary entries.
548        skip_summary: Vec<(String, usize)>,
549    },
550    /// CSV output.
551    Csv,
552    /// GitHub Actions workflow command annotations.
553    GithubAnnotations,
554    /// GitHub Actions annotations with a terminal scan coverage notice.
555    GithubAnnotationsCoverage {
556        /// Non-zero source or scanner coverage gaps observed during the scan.
557        skip_summary: Vec<(String, usize)>,
558    },
559    /// GitLab SAST security report JSON.
560    GitlabSast {
561        /// UTC scan start time formatted as `YYYY-MM-DDTHH:MM:SS`.
562        scan_started_at: String,
563        /// UTC scan end time formatted as `YYYY-MM-DDTHH:MM:SS`.
564        scan_finished_at: String,
565    },
566    /// GitLab SAST output with scan-wide coverage status.
567    GitlabSastCoverage {
568        /// UTC scan start time formatted as `YYYY-MM-DDTHH:MM:SS`.
569        scan_started_at: String,
570        /// UTC scan end time formatted as `YYYY-MM-DDTHH:MM:SS`.
571        scan_finished_at: String,
572        /// Non-zero source or scanner coverage gaps observed during the scan.
573        skip_summary: Vec<(String, usize)>,
574    },
575    /// Self-contained HTML output.
576    Html {
577        /// Operator-visible scan coverage-gap summary entries (same data the
578        /// SARIF report surfaces), rendered as a "coverage" panel so the report
579        /// never reads as a clean bill of health when files went unscanned.
580        skip_summary: Vec<(String, usize)>,
581        /// Scan identity, timing, target, and size metadata for the report hero.
582        metadata: Option<HtmlScanMetadata>,
583    },
584    /// JUnit XML output.
585    Junit,
586    /// JUnit XML output with deterministic scan coverage properties.
587    JunitCoverage {
588        /// Non-zero source or scanner coverage gaps observed during the scan.
589        skip_summary: Vec<(String, usize)>,
590    },
591}
592
593/// Write a complete findings report in the requested format.
594pub fn write_report<W: Write + Send>(
595    writer: W,
596    format: ReportFormat,
597    findings: &[VerifiedFinding],
598) -> Result<(), ReportError> {
599    write_scan_report(writer, format, ScanReport::new(findings))
600}
601
602/// Write a complete report from the shared scan model.
603///
604/// [`write_report`] remains as a compatibility wrapper for callers that only
605/// have findings. New scan paths should use this entrypoint so every renderer
606/// receives the same report object and metadata cannot be wired only to HTML.
607pub fn write_scan_report<W: Write + Send>(
608    writer: W,
609    format: ReportFormat,
610    report: ScanReport<'_>,
611) -> Result<(), ReportError> {
612    let findings = report.findings;
613    let report_metadata = report.metadata;
614    match format {
615        ReportFormat::Text {
616            color,
617            example_suppressions,
618            dogfood_active,
619        } => {
620            let mut reporter = text::TextReporter::with_color(writer, color);
621            reporter.set_example_suppressions(example_suppressions);
622            reporter.set_dogfood_active(dogfood_active);
623            finish_reporter(reporter, findings)
624        }
625        ReportFormat::Json => finish_reporter(json::JsonArrayReporter::new(writer)?, findings),
626        ReportFormat::JsonEnvelope {
627            coverage_gap_summary,
628        } => finish_reporter(
629            json::JsonEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
630            findings,
631        ),
632        ReportFormat::Jsonl => finish_reporter(json::JsonlReporter::new(writer), findings),
633        ReportFormat::JsonlEnvelope {
634            coverage_gap_summary,
635        } => finish_reporter(
636            json::JsonlEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
637            findings,
638        ),
639        ReportFormat::Sarif { skip_summary } => finish_reporter(
640            sarif::SarifReporter::new(writer)
641                .with_skip_summary(skip_summary.clone())
642                .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
643                .with_backend_recoveries(report_recoveries(report_metadata)),
644            findings,
645        ),
646        ReportFormat::Csv => finish_reporter(csv::CsvReporter::new(writer)?, findings),
647        ReportFormat::GithubAnnotations => finish_reporter(
648            github_annotations::GithubAnnotationsReporter::new(writer)
649                .with_backend_recoveries(report_recoveries(report_metadata)),
650            findings,
651        ),
652        ReportFormat::GithubAnnotationsCoverage { skip_summary } => finish_reporter(
653            github_annotations::GithubAnnotationsReporter::new(writer)
654                .with_skip_summary(skip_summary.clone())
655                .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
656                .with_backend_recoveries(report_recoveries(report_metadata)),
657            findings,
658        ),
659        ReportFormat::GitlabSast {
660            scan_started_at,
661            scan_finished_at,
662        } => finish_reporter(
663            gitlab_sast::GitlabSastReporter::new(
664                writer,
665                report_time(
666                    report_metadata,
667                    scan_started_at,
668                    |metadata| &metadata.scan_started_at,
669                    "scan_started_at",
670                )?,
671                report_time(
672                    report_metadata,
673                    scan_finished_at,
674                    |metadata| &metadata.scan_finished_at,
675                    "scan_finished_at",
676                )?,
677            )
678            .with_backend_recoveries(report_recoveries(report_metadata)),
679            findings,
680        ),
681        ReportFormat::GitlabSastCoverage {
682            scan_started_at,
683            scan_finished_at,
684            skip_summary,
685        } => finish_reporter(
686            gitlab_sast::GitlabSastReporter::new(
687                writer,
688                report_time(
689                    report_metadata,
690                    scan_started_at,
691                    |metadata| &metadata.scan_started_at,
692                    "scan_started_at",
693                )?,
694                report_time(
695                    report_metadata,
696                    scan_finished_at,
697                    |metadata| &metadata.scan_finished_at,
698                    "scan_finished_at",
699                )?,
700            )
701            .with_skip_summary(skip_summary.clone())
702            .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
703            .with_backend_recoveries(report_recoveries(report_metadata)),
704            findings,
705        ),
706        ReportFormat::Html {
707            skip_summary,
708            metadata,
709        } => finish_reporter(
710            html::HtmlReporter::new(writer)
711                .with_skip_summary(skip_summary)
712                .with_metadata(merge_html_metadata(metadata, report_metadata)?),
713            findings,
714        ),
715        ReportFormat::Junit => finish_reporter(
716            junit::JunitReporter::new(writer)
717                .with_backend_recoveries(report_recoveries(report_metadata)),
718            findings,
719        ),
720        ReportFormat::JunitCoverage { skip_summary } => finish_reporter(
721            junit::JunitReporter::new(writer)
722                .with_skip_summary(skip_summary.clone())
723                .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
724                .with_backend_recoveries(report_recoveries(report_metadata)),
725            findings,
726        ),
727    }
728}
729
730fn report_recoveries(metadata: Option<&ScanReportMetadata>) -> Vec<ScanBackendRecoverySummary> {
731    metadata
732        .map(|value| value.backend_recoveries.clone())
733        .unwrap_or_default() // LAW10: absent report metadata means no recovery rows; findings and coverage status are unchanged
734}
735
736fn resolve_report_status(
737    metadata: Option<&ScanReportMetadata>,
738    coverage_gap_summary: &[(String, usize)],
739) -> ScanCompletionStatus {
740    ScanCompletionStatus::resolve(
741        metadata.map(|value| value.scan_status),
742        !coverage_gap_summary.is_empty(),
743    )
744}
745
746/// Write a CSV scan artifact with a self-describing scan-status preamble.
747///
748/// This dedicated entrypoint keeps the legacy [`ReportFormat::Csv`] enum
749/// variant and its header-first byte contract unchanged for library callers,
750/// while CLI scan artifacts can retain coverage state even when no finding row
751/// exists.
752pub fn write_csv_coverage_report<W: Write + Send>(
753    writer: W,
754    report: ScanReport<'_>,
755    coverage_gap_summary: &[(String, usize)],
756) -> Result<(), ReportError> {
757    finish_reporter(
758        csv::CsvReporter::with_scan_metadata(writer, report.metadata, coverage_gap_summary)?,
759        report.findings,
760    )
761}
762
763fn report_time(
764    metadata: Option<&ScanReportMetadata>,
765    explicit: String,
766    select: fn(&ScanReportMetadata) -> &String,
767    field: &str,
768) -> Result<String, ReportError> {
769    let Some(metadata) = metadata else {
770        return Ok(explicit);
771    };
772    let canonical = select(metadata);
773    if explicit != *canonical {
774        anyhow::bail!(
775            "report metadata conflict for {field}: format options and ScanReport disagree; pass one canonical value"
776        );
777    }
778    Ok(explicit)
779}
780
781fn merge_html_metadata(
782    explicit: Option<ScanReportMetadata>,
783    report: Option<&ScanReportMetadata>,
784) -> Result<Option<ScanReportMetadata>, ReportError> {
785    match (explicit, report) {
786        (Some(explicit), Some(report)) if explicit != *report => {
787            anyhow::bail!(
788                "report metadata conflict for HTML: format options and ScanReport disagree; pass one canonical value"
789            );
790        }
791        (Some(explicit), _) => Ok(Some(explicit)),
792        (None, report) => Ok(report.cloned()),
793    }
794}
795
796fn finish_reporter<R: Reporter>(
797    mut reporter: R,
798    findings: &[VerifiedFinding],
799) -> Result<(), ReportError> {
800    for finding in findings {
801        reporter.report(finding)?;
802    }
803    reporter.finish()?;
804    Ok(())
805}
806
807/// Common trait for all finding reporters.
808pub(crate) trait Reporter: Send {
809    /// Report a single finding.
810    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
811
812    /// Finalize the report and flush buffered bytes.
813    fn finish(&mut self) -> Result<(), ReportError>;
814}
815
816trait WriterBackedReporter {
817    type Writer: Write;
818
819    fn writer_mut(&mut self) -> &mut Self::Writer;
820
821    fn flush_writer(&mut self) -> Result<(), ReportError> {
822        self.writer_mut().flush()?;
823        Ok(())
824    }
825}
826
827/// Implements [`WriterBackedReporter`] for a reporter whose only state behind
828/// the trait is a single `writer: W` field. Every reporter in this module is
829/// generic over `W: Write + Send` and exposes its writer identically, so the
830/// impl is purely mechanical, the macro keeps all nine reporters from drifting
831/// to nine subtly different spellings of the same three lines. Invoked as
832/// `impl_writer_backed!(CsvReporter);` inside each reporter's module, where both
833/// `Write` and `WriterBackedReporter` are already in scope.
834macro_rules! impl_writer_backed {
835    ($reporter:ident) => {
836        impl<W: Write + Send> WriterBackedReporter for $reporter<W> {
837            type Writer = W;
838            fn writer_mut(&mut self) -> &mut Self::Writer {
839                &mut self.writer
840            }
841        }
842    };
843}
844pub(crate) use impl_writer_backed;
845
846// `BufferedFindingReporter` was the legacy buffer-everything trait. The
847// SARIF reporter now streams results directly to its writer (audit
848// 2026-04-26 audit), so the trait has no callers and is removed. Other
849// reporters that still buffer (text, JSON-array) keep their state inline.