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