Skip to main content

rto_exec/
ingest.rs

1//! The ingest backend: a normalized report produced elsewhere, read in as if it
2//! had been produced here.
3//!
4//! This is the zero-install default of ADR-0014, and the first implementation of
5//! the [`AnalyzerRunner`] contract. It performs no execution and opens no
6//! network connection: the analyzer already ran, in CI or in a developer's own
7//! tooling, and what arrives is its normalized output. What it *does* do is
8//! validate that output strictly — a report is untrusted input, and a malformed
9//! or hostile one must be refused with a clear error, before anything is written.
10
11use std::collections::HashSet;
12
13use rto_graph::{
14    AdvisoryDb, AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, Isolation,
15    RunnerKind, Severity, SourceIdentity, Span, is_valid_analyzer_id, layer_key,
16};
17use serde::{Deserialize, Serialize};
18
19use crate::adapter::{NativeContext, adapter_for, known_analyzers};
20use crate::runner::{
21    AnalysisRequest, AnalysisResponse, AnalyzerRunner, ExecError, check_reported_path,
22    check_request,
23};
24use crate::sha256_hex;
25
26/// Schema tag every normalized report must carry. Bump on a breaking change to
27/// the report format, exactly as [`rto_graph::ARTIFACT_SCHEMA`] does for the
28/// graph artifact.
29pub const REPORT_SCHEMA: &str = "roteiro.findings/v1";
30
31/// The most findings accepted from one report.
32///
33/// A ceiling, not a target: a report claiming more than this is a runaway or
34/// hostile producer, and refusing it up front is better than letting it bloat the
35/// store one row at a time.
36pub const MAX_REPORT_FINDINGS: usize = 100_000;
37
38/// One finding as it appears in a normalized report.
39///
40/// `identity` is the analyzer's **own** ordered identity recipe, not a fixed set
41/// of fields, which is what lets a new analyzer slot in without a schema change:
42///
43/// ```text
44/// semgrep:     ["<rule>", "<path>", "<start-byte>", "<snippet-hash>"]
45/// cargo-audit: ["<advisory>", "<pkg>", "<version>", "<lockfile-blob>"]
46/// ```
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ReportFinding {
49    /// The analyzer's ordered identity components for this finding.
50    pub identity: Vec<String>,
51    /// The rule, advisory or check id that fired.
52    pub rule: String,
53    /// The severity the analyzer assigned.
54    pub severity: Severity,
55    /// One-line summary.
56    pub title: String,
57    /// The analyzer's full message.
58    #[serde(default)]
59    pub message: String,
60    /// Repository-relative path the finding is about.
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub path: Option<String>,
63    /// Byte span within that path.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub span: Option<Span>,
66    /// Anything else the analyzer reported, kept verbatim.
67    #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
68    pub meta: serde_json::Value,
69}
70
71/// A normalized analyzer report — the interchange format `roteiro security
72/// ingest` consumes and every analyzer adapter emits.
73///
74/// Unknown fields are **not** rejected: the schema tag carries versioning, so a
75/// producer may add diagnostics without breaking older readers. Everything the
76/// evidence chain needs is required.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct NormalizedReport {
79    /// Schema tag ([`REPORT_SCHEMA`]).
80    pub schema: String,
81    /// The analyzer id.
82    pub analyzer: String,
83    /// The analyzer's version.
84    pub analyzer_version: String,
85    /// When the analyzer started, as the producer recorded it.
86    pub started_at: String,
87    /// When it finished.
88    pub ended_at: String,
89    /// Its process exit status.
90    #[serde(default)]
91    pub exit_status: i32,
92    /// Digest of the rule set it ran with.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub rules_digest: Option<String>,
95    /// Digest of the container image it ran in, where one was used.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub image_digest: Option<String>,
98    /// The pinned advisory database it consulted.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub advisory_db: Option<AdvisoryDb>,
101    /// The source identity it ran against.
102    #[serde(default)]
103    pub source: SourceIdentity,
104    /// The findings it produced.
105    #[serde(default)]
106    pub findings: Vec<ReportFinding>,
107}
108
109/// Consumes a normalized report and yields the same values any other backend
110/// would.
111///
112/// The report bytes are held verbatim rather than pre-parsed, because the run's
113/// `report_digest` must be the digest of exactly what arrived — the tie between
114/// the stored findings and the file they came from.
115#[derive(Debug, Clone)]
116pub struct IngestRunner {
117    report: Vec<u8>,
118}
119
120impl IngestRunner {
121    /// Build a runner over the raw bytes of a normalized report.
122    #[must_use]
123    pub fn new(report: impl Into<Vec<u8>>) -> Self {
124        Self {
125            report: report.into(),
126        }
127    }
128}
129
130impl AnalyzerRunner for IngestRunner {
131    fn kind(&self) -> RunnerKind {
132        RunnerKind::Ingested
133    }
134
135    fn isolation(&self) -> Isolation {
136        // Nothing executed locally, so there is no boundary to claim — and a
137        // report from an unknown CI job is exactly the case where an
138        // over-claimed isolation label would be misleading.
139        Isolation::Ingested
140    }
141
142    fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError> {
143        check_request(request)?;
144        let report: NormalizedReport = serde_json::from_slice(&self.report)?;
145        assemble(report, request, self.kind(), self.isolation(), &self.report)
146    }
147}
148
149/// Turn one analyzer's **native** output into a normalized report, using that
150/// analyzer's adapter.
151///
152/// This is the single conversion both execution paths go through: a subprocess
153/// run hands it the bytes it captured from the analyzer's stdout, and
154/// `roteiro security ingest` hands it the bytes of a report file produced by the
155/// same analyzer in CI. The resulting [`Finding`]s are equal because they came
156/// out of the same function, not because two implementations were checked
157/// against each other.
158///
159/// # Errors
160/// Returns [`ExecError::UnknownAnalyzer`] if this build has no adapter for
161/// `analyzer`, or whatever the adapter raises for output it cannot read.
162pub fn normalize_native(
163    analyzer: &str,
164    native: &[u8],
165    ctx: &NativeContext<'_>,
166) -> Result<NormalizedReport, ExecError> {
167    let adapter = adapter_for(analyzer).ok_or_else(|| ExecError::UnknownAnalyzer {
168        requested: analyzer.to_owned(),
169        known: known_analyzers().join(", "),
170    })?;
171    adapter.normalize(native, ctx)
172}
173
174/// Validate a normalized report and build the response a backend returns.
175///
176/// Shared by every backend so the validation, the identity keys, the ordering
177/// and the evidence chain are written once. `raw` is the exact bytes the report
178/// was derived from — the analyzer's stdout for a subprocess run, the file's
179/// contents for an ingest — because `report_digest` identifies *those bytes*,
180/// not the parsed value.
181pub(crate) fn assemble(
182    report: NormalizedReport,
183    request: &AnalysisRequest,
184    runner: RunnerKind,
185    isolation: Isolation,
186    raw: &[u8],
187) -> Result<AnalysisResponse, ExecError> {
188    validate_report(&report, &request.analyzer)?;
189    let findings = normalize_findings(&report)?;
190    let layer = layer_key(&request.analyzer, &request.worktree.id)?;
191    let run = AnalysisRun {
192        layer,
193        analyzer: report.analyzer,
194        analyzer_version: report.analyzer_version,
195        runner,
196        isolation,
197        image_digest: report.image_digest,
198        rules_digest: report.rules_digest,
199        advisory_db: report.advisory_db,
200        // The policy the run was executed under. For ingest that is trivially
201        // honoured — it opened no socket and did not write the tree. A backend
202        // that really executes something records what it enforced, and says so
203        // in its own documentation where "enforced" overstates the case.
204        command_policy: CommandPolicy {
205            network: request.network,
206            worktree: request.worktree.access,
207            environment: EnvironmentPolicy::Scrubbed,
208        },
209        // The caller's knowledge of the source identity wins where it has any;
210        // otherwise the report's own record stands.
211        source: merge_source(&request.source, report.source),
212        started_at: report.started_at,
213        ended_at: report.ended_at,
214        exit_status: report.exit_status,
215        report_digest: sha256_hex(raw),
216    };
217    Ok(AnalysisResponse { run, findings })
218}
219
220/// Prefer the caller's source identity component-by-component, falling back to
221/// the report's. A producer knows the lockfile blob it resolved; a caller knows
222/// which checkout it is standing in.
223fn merge_source(requested: &SourceIdentity, reported: SourceIdentity) -> SourceIdentity {
224    SourceIdentity {
225        commit: requested.commit.clone().or(reported.commit),
226        tree: requested.tree.clone().or(reported.tree),
227        lockfile_blob: requested.lockfile_blob.clone().or(reported.lockfile_blob),
228    }
229}
230
231/// Check everything about a report that must hold before any of it is trusted.
232fn validate_report(report: &NormalizedReport, requested: &str) -> Result<(), ExecError> {
233    if report.schema != REPORT_SCHEMA {
234        return Err(ExecError::UnsupportedSchema {
235            found: report.schema.clone(),
236            expected: REPORT_SCHEMA,
237        });
238    }
239    if !is_valid_analyzer_id(&report.analyzer) {
240        return Err(ExecError::InvalidAnalyzerId(report.analyzer.clone()));
241    }
242    if report.analyzer != requested {
243        return Err(ExecError::AnalyzerMismatch {
244            requested: requested.to_owned(),
245            reported: report.analyzer.clone(),
246        });
247    }
248    // The evidence chain is the reason this store exists; a run that cannot say
249    // what version ran, or when, is not evidence.
250    for (field, value) in [
251        ("analyzer_version", &report.analyzer_version),
252        ("started_at", &report.started_at),
253        ("ended_at", &report.ended_at),
254    ] {
255        if value.trim().is_empty() {
256            return Err(ExecError::MalformedReport(format!("{field} is empty")));
257        }
258    }
259    if report.findings.len() > MAX_REPORT_FINDINGS {
260        return Err(ExecError::TooManyFindings {
261            count: report.findings.len(),
262            max: MAX_REPORT_FINDINGS,
263        });
264    }
265    Ok(())
266}
267
268/// Turn a validated report's findings into normalized [`Finding`]s, ordered by
269/// their stable identity so an unchanged report always produces an identical
270/// sequence.
271fn normalize_findings(report: &NormalizedReport) -> Result<Vec<Finding>, ExecError> {
272    let mut seen: HashSet<String> = HashSet::with_capacity(report.findings.len());
273    let mut out = Vec::with_capacity(report.findings.len());
274    for reported in &report.findings {
275        if reported.rule.trim().is_empty() {
276            return Err(ExecError::MalformedReport(
277                "a finding has an empty rule id".to_owned(),
278            ));
279        }
280        if reported.title.trim().is_empty() {
281            return Err(ExecError::MalformedReport(format!(
282                "finding {:?} has an empty title",
283                reported.rule
284            )));
285        }
286        if let Some(path) = &reported.path {
287            check_reported_path(path)?;
288        }
289        if let Some(span) = reported.span
290            && span.end < span.start
291        {
292            return Err(ExecError::MalformedReport(format!(
293                "finding {:?} has a span that runs backwards ({}..{})",
294                reported.rule, span.start, span.end
295            )));
296        }
297        let key = FindingKey::new(&report.analyzer, &reported.identity)?;
298        let rendered = key.render();
299        if !seen.insert(rendered.clone()) {
300            return Err(ExecError::DuplicateFinding(rendered));
301        }
302        out.push(Finding {
303            key,
304            rule: reported.rule.clone(),
305            severity: reported.severity.clone(),
306            title: reported.title.clone(),
307            message: reported.message.clone(),
308            path: reported.path.clone(),
309            span: reported.span,
310            meta: reported.meta.clone(),
311        });
312    }
313    out.sort_by(|a, b| a.key.cmp(&b.key));
314    Ok(out)
315}
316
317#[cfg(test)]
318mod tests {
319    use super::{
320        IngestRunner, MAX_REPORT_FINDINGS, NormalizedReport, REPORT_SCHEMA, ReportFinding,
321    };
322    use crate::runner::{
323        AnalysisRequest, AnalysisResponse, AnalyzerRunner, Consent, ExecError, Worktree,
324    };
325    use rto_graph::{Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, Span};
326
327    fn request() -> AnalysisRequest {
328        AnalysisRequest {
329            analyzer: "cargo-audit".to_owned(),
330            worktree: Worktree::read_only("/repo".as_ref()).expect("worktree"),
331            network: NetworkPolicy::Deny,
332            consent: Consent::Granted,
333            source: SourceIdentity::default(),
334        }
335    }
336
337    fn report() -> NormalizedReport {
338        NormalizedReport {
339            schema: REPORT_SCHEMA.to_owned(),
340            analyzer: "cargo-audit".to_owned(),
341            analyzer_version: "0.21.0".to_owned(),
342            started_at: "2026-08-15T09:00:00Z".to_owned(),
343            ended_at: "2026-08-15T09:00:04Z".to_owned(),
344            exit_status: 1,
345            rules_digest: None,
346            image_digest: None,
347            advisory_db: None,
348            source: SourceIdentity::default(),
349            findings: vec![
350                ReportFinding {
351                    identity: vec![
352                        "RUSTSEC-2024-0002".to_owned(),
353                        "time".to_owned(),
354                        "0.1.44".to_owned(),
355                        "lock123".to_owned(),
356                    ],
357                    rule: "RUSTSEC-2024-0002".to_owned(),
358                    severity: Severity::Medium,
359                    title: "time is vulnerable".to_owned(),
360                    message: "segfault".to_owned(),
361                    path: Some("Cargo.lock".to_owned()),
362                    span: None,
363                    meta: serde_json::Value::Null,
364                },
365                ReportFinding {
366                    identity: vec![
367                        "RUSTSEC-2024-0001".to_owned(),
368                        "openssl".to_owned(),
369                        "0.10.5".to_owned(),
370                        "lock123".to_owned(),
371                    ],
372                    rule: "RUSTSEC-2024-0001".to_owned(),
373                    severity: Severity::High,
374                    title: "openssl is vulnerable".to_owned(),
375                    message: "upgrade".to_owned(),
376                    path: Some("Cargo.lock".to_owned()),
377                    span: Some(Span::new(10, 20)),
378                    meta: serde_json::json!({"cvss": 9.1}),
379                },
380            ],
381        }
382    }
383
384    fn ingest(report: &NormalizedReport) -> Result<AnalysisResponse, ExecError> {
385        let bytes = serde_json::to_vec(report).expect("serialize");
386        IngestRunner::new(bytes).run(&request())
387    }
388
389    #[test]
390    fn ingests_a_well_formed_report_deterministically() {
391        let response = ingest(&report()).expect("ingest");
392        assert_eq!(response.run.runner, RunnerKind::Ingested);
393        assert_eq!(response.run.isolation, Isolation::Ingested);
394        assert_eq!(response.run.analyzer_version, "0.21.0");
395        assert_eq!(response.run.exit_status, 1);
396        assert_eq!(response.run.command_policy.network, NetworkPolicy::Deny);
397        assert!(
398            response.run.layer.starts_with("security:cargo-audit:"),
399            "layer key was {}",
400            response.run.layer
401        );
402        // Findings come back ordered by identity, not in report order, so an
403        // unchanged report always produces an identical sequence.
404        let keys: Vec<String> = response.findings.iter().map(|f| f.key.render()).collect();
405        assert_eq!(
406            keys,
407            vec![
408                "finding:cargo-audit:RUSTSEC-2024-0001:openssl:0.10.5:lock123",
409                "finding:cargo-audit:RUSTSEC-2024-0002:time:0.1.44:lock123",
410            ]
411        );
412        assert_eq!(response.findings[0].span.map(|s| s.start), Some(10));
413    }
414
415    #[test]
416    fn the_report_digest_is_over_the_exact_bytes_received() {
417        let bytes = serde_json::to_vec(&report()).expect("serialize");
418        let digest = IngestRunner::new(bytes.clone())
419            .run(&request())
420            .expect("ingest")
421            .run
422            .report_digest;
423        assert_eq!(digest, crate::sha256_hex(&bytes));
424
425        // Whitespace changes the bytes, so it changes the digest — the digest
426        // identifies the file, not the parsed content.
427        let spaced = serde_json::to_vec_pretty(&report()).expect("serialize");
428        let other = IngestRunner::new(spaced)
429            .run(&request())
430            .expect("ingest")
431            .run
432            .report_digest;
433        assert_ne!(digest, other);
434    }
435
436    #[test]
437    fn a_run_carries_the_callers_source_identity_over_the_reports() {
438        let mut req = request();
439        req.source.commit = Some("c0ffee".to_owned());
440        let mut rep = report();
441        rep.source.commit = Some("stale".to_owned());
442        rep.source.lockfile_blob = Some("lock123".to_owned());
443        let bytes = serde_json::to_vec(&rep).expect("serialize");
444        let run = IngestRunner::new(bytes).run(&req).expect("ingest").run;
445        assert_eq!(run.source.commit.as_deref(), Some("c0ffee"));
446        // …but keeps what only the producer knew.
447        assert_eq!(run.source.lockfile_blob.as_deref(), Some("lock123"));
448    }
449
450    #[test]
451    fn rejects_a_report_with_the_wrong_schema_tag() {
452        let mut rep = report();
453        rep.schema = "roteiro.findings/v999".to_owned();
454        assert!(matches!(
455            ingest(&rep),
456            Err(ExecError::UnsupportedSchema { .. })
457        ));
458    }
459
460    #[test]
461    fn rejects_a_report_from_a_different_analyzer() {
462        let mut rep = report();
463        rep.analyzer = "semgrep".to_owned();
464        assert!(matches!(
465            ingest(&rep),
466            Err(ExecError::AnalyzerMismatch { .. })
467        ));
468    }
469
470    #[test]
471    fn rejects_a_report_missing_its_evidence() {
472        for mutate in [
473            (|r: &mut NormalizedReport| r.analyzer_version = String::new()) as fn(&mut _),
474            |r: &mut NormalizedReport| r.started_at = "  ".to_owned(),
475            |r: &mut NormalizedReport| r.ended_at = String::new(),
476        ] {
477            let mut rep = report();
478            mutate(&mut rep);
479            assert!(
480                matches!(ingest(&rep), Err(ExecError::MalformedReport(_))),
481                "a run with no evidence must be refused"
482            );
483        }
484    }
485
486    #[test]
487    fn rejects_a_finding_with_no_stable_identity() {
488        let mut rep = report();
489        rep.findings[0].identity.clear();
490        assert!(matches!(ingest(&rep), Err(ExecError::Identity(_))));
491    }
492
493    #[test]
494    fn rejects_duplicate_identities_within_one_report() {
495        let mut rep = report();
496        rep.findings[1].identity = rep.findings[0].identity.clone();
497        assert!(matches!(ingest(&rep), Err(ExecError::DuplicateFinding(_))));
498    }
499
500    #[test]
501    fn rejects_a_finding_claiming_a_path_outside_the_worktree() {
502        for hostile in ["/etc/shadow", "../../../etc/passwd"] {
503            let mut rep = report();
504            rep.findings[0].path = Some(hostile.to_owned());
505            assert!(
506                matches!(ingest(&rep), Err(ExecError::PathEscapesWorktree(_))),
507                "{hostile:?} should be refused"
508            );
509        }
510    }
511
512    #[test]
513    fn rejects_empty_rules_titles_and_backwards_spans() {
514        let mut rep = report();
515        rep.findings[0].rule = "  ".to_owned();
516        assert!(matches!(ingest(&rep), Err(ExecError::MalformedReport(_))));
517
518        let mut rep = report();
519        rep.findings[0].title = String::new();
520        assert!(matches!(ingest(&rep), Err(ExecError::MalformedReport(_))));
521
522        let mut rep = report();
523        rep.findings[0].span = Some(Span::new(90, 10));
524        assert!(matches!(ingest(&rep), Err(ExecError::MalformedReport(_))));
525    }
526
527    #[test]
528    fn rejects_a_runaway_report() {
529        let mut rep = report();
530        let template = rep.findings[0].clone();
531        rep.findings = (0..=MAX_REPORT_FINDINGS)
532            .map(|i| {
533                let mut f = template.clone();
534                f.identity[1] = format!("pkg{i}");
535                f
536            })
537            .collect();
538        assert!(matches!(
539            ingest(&rep),
540            Err(ExecError::TooManyFindings { .. })
541        ));
542    }
543
544    #[test]
545    fn rejects_bytes_that_are_not_a_report_at_all() {
546        for junk in [
547            &b"not json at all"[..],
548            &b"[]"[..],
549            &b"null"[..],
550            &b"{\"schema\":\"roteiro.findings/v1\"}"[..],
551        ] {
552            assert!(
553                matches!(
554                    IngestRunner::new(junk.to_vec()).run(&request()),
555                    Err(ExecError::Json(_))
556                ),
557                "{:?} should be refused as JSON",
558                String::from_utf8_lossy(junk)
559            );
560        }
561    }
562
563    #[test]
564    fn refuses_to_run_without_consent() {
565        let mut req = request();
566        req.consent = Consent::Withheld;
567        let bytes = serde_json::to_vec(&report()).expect("serialize");
568        assert!(matches!(
569            IngestRunner::new(bytes).run(&req),
570            Err(ExecError::ConsentRequired)
571        ));
572    }
573
574    #[test]
575    fn a_report_with_no_findings_is_a_valid_clean_run() {
576        let mut rep = report();
577        rep.findings.clear();
578        rep.exit_status = 0;
579        let response = ingest(&rep).expect("ingest");
580        assert!(response.findings.is_empty());
581        assert_eq!(response.run.exit_status, 0);
582    }
583
584    #[test]
585    fn the_report_format_round_trips_through_json() {
586        let rep = report();
587        let json = serde_json::to_string(&rep).expect("serialize");
588        assert_eq!(
589            serde_json::from_str::<NormalizedReport>(&json).expect("deserialize"),
590            rep
591        );
592    }
593}