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