1use 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
25pub const REPORT_SCHEMA: &str = "roteiro.findings/v1";
29
30pub const MAX_REPORT_FINDINGS: usize = 100_000;
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ReportFinding {
48 pub identity: Vec<String>,
50 pub rule: String,
52 pub severity: Severity,
54 pub title: String,
56 #[serde(default)]
58 pub message: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub path: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub span: Option<Span>,
65 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
67 pub meta: serde_json::Value,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct NormalizedReport {
78 pub schema: String,
80 pub analyzer: String,
82 pub analyzer_version: String,
84 pub started_at: String,
86 pub ended_at: String,
88 #[serde(default)]
90 pub exit_status: i32,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub rules_digest: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub image_digest: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub advisory_db: Option<AdvisoryDb>,
100 #[serde(default)]
102 pub source: SourceIdentity,
103 #[serde(default)]
105 pub findings: Vec<ReportFinding>,
106}
107
108#[derive(Debug, Clone)]
115pub struct IngestRunner {
116 report: Vec<u8>,
117}
118
119impl IngestRunner {
120 #[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 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 command_policy: CommandPolicy {
161 network: request.network,
162 worktree: request.worktree.access,
163 environment: EnvironmentPolicy::Scrubbed,
164 },
165 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
177fn 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
188fn 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 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
225fn 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 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 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 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}