1use std::collections::BTreeMap;
57
58use rto_graph::{Finding, FindingsLayer, Severity};
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Correspondence {
63 pub advisory: String,
67 pub aliases: Vec<String>,
69 pub package: String,
71 pub version: String,
73 pub reports: Vec<Report>,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct Report {
80 pub analyzer: String,
82 pub key: String,
85 pub rule: String,
89 pub severity: Severity,
91}
92
93impl Correspondence {
94 #[must_use]
100 pub fn confirmed_by(&self) -> usize {
101 let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
102 analyzers.sort_unstable();
103 analyzers.dedup();
104 analyzers.len()
105 }
106
107 #[must_use]
109 pub fn analyzers(&self) -> Vec<&str> {
110 let mut analyzers: Vec<&str> = self.reports.iter().map(|r| r.analyzer.as_str()).collect();
111 analyzers.sort_unstable();
112 analyzers.dedup();
113 analyzers
114 }
115
116 #[must_use]
119 pub fn keys(&self) -> Vec<&str> {
120 self.reports.iter().map(|r| r.key.as_str()).collect()
121 }
122}
123
124#[must_use]
135pub fn cross_reference(layers: &[FindingsLayer]) -> Vec<Correspondence> {
136 let candidates: Vec<Candidate<'_>> = layers
137 .iter()
138 .flat_map(|layer| {
139 layer
140 .findings
141 .iter()
142 .filter_map(|finding| Candidate::of(&layer.run.analyzer, finding))
143 })
144 .collect();
145
146 let mut buckets: BTreeMap<(&str, &str), Vec<&Candidate<'_>>> = BTreeMap::new();
149 for candidate in &candidates {
150 buckets
151 .entry((candidate.package, candidate.version))
152 .or_default()
153 .push(candidate);
154 }
155
156 let mut out = Vec::new();
157 for ((package, version), members) in buckets {
158 for group in group_by_shared_identifier(&members) {
159 out.push(assemble(package, version, &group));
160 }
161 }
162 out.sort_by(|a, b| {
163 (&a.package, &a.version, &a.advisory).cmp(&(&b.package, &b.version, &b.advisory))
164 });
165 out
166}
167
168fn group_by_shared_identifier<'a>(members: &[&'a Candidate<'a>]) -> Vec<Vec<&'a Candidate<'a>>> {
176 let mut parent: Vec<usize> = (0..members.len()).collect();
177 for (i, a) in members.iter().enumerate() {
178 for (j, b) in members.iter().enumerate().skip(i + 1) {
179 if a.shares_identifier(b) {
180 union(&mut parent, i, j);
181 }
182 }
183 }
184 let mut groups: BTreeMap<usize, Vec<&Candidate<'_>>> = BTreeMap::new();
185 for (i, member) in members.iter().enumerate() {
186 groups.entry(find(&mut parent, i)).or_default().push(member);
187 }
188 groups.into_values().collect()
189}
190
191fn find(parent: &mut [usize], mut node: usize) -> usize {
192 while parent[node] != node {
193 parent[node] = parent[parent[node]];
194 node = parent[node];
195 }
196 node
197}
198
199fn union(parent: &mut [usize], a: usize, b: usize) {
200 let (a, b) = (find(parent, a), find(parent, b));
201 if a != b {
202 parent[b.max(a)] = b.min(a);
203 }
204}
205
206fn assemble(package: &str, version: &str, group: &[&Candidate<'_>]) -> Correspondence {
208 let mut aliases: Vec<String> = group
209 .iter()
210 .flat_map(|c| c.identifiers.iter())
211 .map(String::clone)
212 .collect();
213 aliases.sort();
214 aliases.dedup();
215
216 let mut reports: Vec<Report> = group
217 .iter()
218 .map(|c| Report {
219 analyzer: c.analyzer.to_owned(),
220 key: c.key.clone(),
221 rule: c.rule.to_owned(),
222 severity: c.severity.clone(),
223 })
224 .collect();
225 reports.sort_by(|a, b| (&a.analyzer, &a.key).cmp(&(&b.analyzer, &b.key)));
226
227 Correspondence {
228 advisory: canonical(&reports),
229 aliases,
230 package: package.to_owned(),
231 version: version.to_owned(),
232 reports,
233 }
234}
235
236fn canonical(reports: &[Report]) -> String {
250 let mut fired: Vec<&str> = reports.iter().map(|r| r.rule.as_str()).collect();
251 fired.sort_unstable();
252 fired.dedup();
253 fired
254 .iter()
255 .find(|id| id.starts_with("RUSTSEC-"))
256 .or_else(|| fired.first())
257 .map(|id| (*id).to_owned())
258 .unwrap_or_default()
259}
260
261struct Candidate<'a> {
263 analyzer: &'a str,
264 key: String,
265 rule: &'a str,
266 severity: Severity,
267 package: &'a str,
268 version: &'a str,
269 identifiers: Vec<String>,
272}
273
274impl<'a> Candidate<'a> {
275 fn of(analyzer: &'a str, finding: &'a Finding) -> Option<Self> {
277 let package = finding.meta.get("package")?.as_str()?;
278 let version = finding.meta.get("version")?.as_str()?;
279 if package.is_empty() || version.is_empty() {
280 return None;
281 }
282 let mut identifiers = vec![finding.rule.clone()];
283 for field in ["aliases", "related", "ids"] {
288 if let Some(values) = finding.meta.get(field).and_then(|v| v.as_array()) {
289 identifiers.extend(values.iter().filter_map(|v| v.as_str()).map(str::to_owned));
290 }
291 }
292 identifiers.retain(|id| !id.trim().is_empty());
293 identifiers.sort();
294 identifiers.dedup();
295 Some(Self {
296 analyzer,
297 key: finding.key.render(),
298 rule: &finding.rule,
299 severity: finding.severity.clone(),
300 package,
301 version,
302 identifiers,
303 })
304 }
305
306 fn shares_identifier(&self, other: &Self) -> bool {
308 self.identifiers
309 .iter()
310 .any(|id| other.identifiers.binary_search(id).is_ok())
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::{Correspondence, cross_reference};
317 use rto_graph::{
318 AnalysisRun, CommandPolicy, EnvironmentPolicy, Finding, FindingKey, FindingsLayer,
319 Isolation, NetworkPolicy, RunnerKind, Severity, SourceIdentity, WorktreeAccess,
320 };
321
322 fn run(analyzer: &str) -> AnalysisRun {
323 AnalysisRun {
324 layer: format!("security:{analyzer}:ab12cd34"),
325 analyzer: analyzer.to_owned(),
326 analyzer_version: "1.0.0".to_owned(),
327 runner: RunnerKind::Ingested,
328 isolation: Isolation::Ingested,
329 image_digest: None,
330 rules_digest: None,
331 advisory_db: None,
332 command_policy: CommandPolicy {
333 network: NetworkPolicy::Deny,
334 worktree: WorktreeAccess::ReadOnly,
335 environment: EnvironmentPolicy::Scrubbed,
336 },
337 source: SourceIdentity::default(),
338 started_at: "2026-08-16T09:00:00Z".to_owned(),
339 ended_at: "2026-08-16T09:00:01Z".to_owned(),
340 exit_status: 1,
341 report_digest: "0".repeat(64),
342 }
343 }
344
345 fn finding(analyzer: &str, rule: &str, meta: serde_json::Value) -> Finding {
346 Finding {
347 key: FindingKey::new(analyzer, &[rule.to_owned()]).expect("key"),
348 rule: rule.to_owned(),
349 severity: Severity::High,
350 title: format!("{rule} is a problem"),
351 message: String::new(),
352 path: None,
353 span: None,
354 meta,
355 }
356 }
357
358 fn layer(analyzer: &str, findings: Vec<Finding>) -> FindingsLayer {
359 FindingsLayer {
360 run: run(analyzer),
361 findings,
362 }
363 }
364
365 fn lint_layer() -> FindingsLayer {
368 use crate::adapter::NativeContext;
369 use crate::adapter::clippy::Clippy;
370
371 let source = SourceIdentity::default();
372 let ctx = NativeContext {
373 started_at: "2026-08-18T09:00:00Z".to_owned(),
374 ended_at: "2026-08-18T09:01:00Z".to_owned(),
375 analyzer_version: Some("0.1.94".to_owned()),
376 exit_status: 0,
377 source: &source,
378 rules_digest: None,
379 advisory_db: None,
380 worktree: None,
381 snippets: &crate::snippet::NoSnippets,
382 };
383 let native = concat!(
384 r#"{"reason":"compiler-message","message":{"message":"unused import: `time`","#,
385 r#""code":{"code":"unused_imports"},"level":"warning","spans":[{"#,
386 r#""file_name":"src/lib.rs","byte_start":0,"byte_end":11,"line_start":1,"#,
387 r#""line_end":1,"column_start":1,"is_primary":true}]}}"#,
388 "\n",
389 r#"{"reason":"build-finished","success":true}"#
390 );
391 let (report, _) = Clippy::parse(native.as_bytes(), &ctx).expect("parse");
392 let findings = report
393 .findings
394 .iter()
395 .map(|f| Finding {
396 key: FindingKey::new("clippy", &f.identity).expect("key"),
397 rule: f.rule.clone(),
398 severity: f.severity.clone(),
399 title: f.title.clone(),
400 message: f.message.clone(),
401 path: f.path.clone(),
402 span: f.span,
403 meta: f.meta.clone(),
404 })
405 .collect();
406 layer("clippy", findings)
407 }
408
409 #[test]
417 fn a_lint_finding_cannot_enter_the_dependency_join() {
418 assert!(
419 cross_reference(&[lint_layer()]).is_empty(),
420 "a linter is not on the dependency axis at all"
421 );
422
423 let advisory = layer(
426 "cargo-audit",
427 vec![finding(
428 "cargo-audit",
429 "RUSTSEC-2020-0071",
430 serde_json::json!({
431 "package": "time", "version": "0.2.22",
432 "aliases": ["CVE-2020-26235"], "related": []
433 }),
434 )],
435 );
436 let joined = cross_reference(&[lint_layer(), advisory]);
437 assert_eq!(joined.len(), 1, "only the advisory takes part");
438 assert_eq!(joined[0].analyzers(), vec!["cargo-audit"]);
439 for correspondence in &joined {
440 for report in &correspondence.reports {
441 assert_ne!(report.analyzer, "clippy");
442 }
443 }
444 }
445
446 #[test]
450 fn the_same_advisory_from_two_analyzers_is_one_confirmed_correspondence() {
451 let layers = vec![
452 layer(
453 "cargo-audit",
454 vec![finding(
455 "cargo-audit",
456 "RUSTSEC-2020-0071",
457 serde_json::json!({
458 "package": "time", "version": "0.2.22",
459 "aliases": ["CVE-2020-26235"], "related": []
460 }),
461 )],
462 ),
463 layer(
464 "osv-scanner",
465 vec![finding(
466 "osv-scanner",
467 "GHSA-wcg3-cvx6-7396",
468 serde_json::json!({
469 "package": "time", "version": "0.2.22",
470 "aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"],
471 "ids": ["GHSA-wcg3-cvx6-7396", "RUSTSEC-2020-0071"]
472 }),
473 )],
474 ),
475 ];
476
477 let crossref = cross_reference(&layers);
478 assert_eq!(crossref.len(), 1, "one advisory, not two problems");
479 let one = &crossref[0];
480 assert_eq!(one.confirmed_by(), 2);
481 assert_eq!(one.analyzers(), vec!["cargo-audit", "osv-scanner"]);
482 assert_eq!(one.advisory, "RUSTSEC-2020-0071");
484 assert_eq!(one.keys().len(), 2);
486 assert!(one.keys().iter().any(|k| k.contains("cargo-audit")));
487 assert!(one.keys().iter().any(|k| k.contains("osv-scanner")));
488 let rules: Vec<&str> = one.reports.iter().map(|r| r.rule.as_str()).collect();
491 assert!(rules.contains(&"RUSTSEC-2020-0071"));
492 assert!(rules.contains(&"GHSA-wcg3-cvx6-7396"));
493 }
494
495 #[test]
498 fn two_findings_join_through_a_shared_cve_neither_names_directly() {
499 let layers = vec![
500 layer(
501 "cargo-audit",
502 vec![finding(
503 "cargo-audit",
504 "RUSTSEC-2021-0001",
505 serde_json::json!({
506 "package": "widget", "version": "1.0.0",
507 "aliases": [], "related": ["CVE-2021-9999"]
508 }),
509 )],
510 ),
511 layer(
512 "osv-scanner",
513 vec![finding(
514 "osv-scanner",
515 "GHSA-aaaa-bbbb-cccc",
516 serde_json::json!({
517 "package": "widget", "version": "1.0.0",
518 "aliases": ["CVE-2021-9999"]
519 }),
520 )],
521 ),
522 ];
523 let crossref = cross_reference(&layers);
524 assert_eq!(crossref.len(), 1);
525 assert_eq!(crossref[0].confirmed_by(), 2);
526 }
527
528 #[test]
532 fn a_shared_identifier_on_different_packages_does_not_merge() {
533 let layers = vec![layer(
534 "osv-scanner",
535 vec![
536 finding(
537 "osv-scanner",
538 "GHSA-1",
539 serde_json::json!({
540 "package": "alpha", "version": "1.0.0", "aliases": ["CVE-2026-1"]
541 }),
542 ),
543 finding(
544 "osv-scanner",
545 "GHSA-2",
546 serde_json::json!({
547 "package": "beta", "version": "1.0.0", "aliases": ["CVE-2026-1"]
548 }),
549 ),
550 ],
551 )];
552 let crossref = cross_reference(&layers);
553 assert_eq!(crossref.len(), 2, "different packages stay different rows");
554 }
555
556 #[test]
563 fn the_same_advisory_at_two_versions_does_not_merge() {
564 let layers = vec![layer(
565 "osv-scanner",
566 vec![
567 finding(
568 "osv-scanner",
569 "GHSA-1",
570 serde_json::json!({
571 "package": "lodash", "version": "4.17.15", "aliases": ["CVE-2020-8203"]
572 }),
573 ),
574 finding(
575 "osv-scanner",
576 "GHSA-1b",
577 serde_json::json!({
578 "package": "lodash", "version": "4.17.20", "aliases": ["CVE-2020-8203"]
579 }),
580 ),
581 ],
582 )];
583 let crossref = cross_reference(&layers);
584 assert_eq!(crossref.len(), 2, "each pinned version is its own fix");
585 assert_eq!(crossref[0].version, "4.17.15");
586 assert_eq!(crossref[1].version, "4.17.20");
587 }
588
589 #[test]
593 fn an_advisory_only_one_analyzer_reports_is_a_normal_single_source_row() {
594 let layers = vec![
595 layer(
596 "cargo-audit",
597 vec![finding(
598 "cargo-audit",
599 "yanked",
600 serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
601 )],
602 ),
603 layer(
604 "osv-scanner",
605 vec![finding(
606 "osv-scanner",
607 "GHSA-new",
608 serde_json::json!({"package": "fresh", "version": "1.0.0"}),
609 )],
610 ),
611 ];
612 let crossref = cross_reference(&layers);
613 assert_eq!(crossref.len(), 2);
614 assert!(crossref.iter().all(|c| c.confirmed_by() == 1));
615 assert_eq!(crossref[0].package, "fresh");
617 assert_eq!(crossref[0].analyzers(), vec!["osv-scanner"]);
618 assert_eq!(crossref[1].package, "half-baked");
619 assert_eq!(crossref[1].analyzers(), vec!["cargo-audit"]);
620 }
621
622 #[test]
626 fn no_finding_is_lost_or_double_counted_by_the_join() {
627 let layers = vec![
628 layer(
629 "cargo-audit",
630 vec![
631 finding(
632 "cargo-audit",
633 "RUSTSEC-2020-0071",
634 serde_json::json!({
635 "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
636 }),
637 ),
638 finding(
639 "cargo-audit",
640 "yanked",
641 serde_json::json!({"package": "half-baked", "version": "0.3.1"}),
642 ),
643 ],
644 ),
645 layer(
646 "osv-scanner",
647 vec![finding(
648 "osv-scanner",
649 "RUSTSEC-2020-0071",
650 serde_json::json!({
651 "package": "time", "version": "0.2.22", "aliases": ["CVE-2020-26235"]
652 }),
653 )],
654 ),
655 ];
656 let total: usize = layers.iter().map(|l| l.findings.len()).sum();
657 let crossref = cross_reference(&layers);
658 let reported: usize = crossref.iter().map(|c| c.reports.len()).sum();
659 assert_eq!(reported, total, "every finding appears exactly once");
660 assert_eq!(total, 3);
661 assert_eq!(crossref.len(), 2, "…across two advisories");
662 }
663
664 #[test]
667 fn sast_findings_are_not_cross_referenced() {
668 let layers = vec![layer(
669 "semgrep",
670 vec![finding(
671 "semgrep",
672 "roteiro.python.eval-of-input",
673 serde_json::json!({"engine": "python"}),
674 )],
675 )];
676 assert!(cross_reference(&layers).is_empty());
677 }
678
679 #[test]
680 fn nothing_ingested_cross_references_to_nothing() {
681 assert!(cross_reference(&[]).is_empty());
682 }
683
684 #[test]
686 fn the_order_is_stable_and_does_not_depend_on_layer_order() {
687 let a = layer(
688 "cargo-audit",
689 vec![finding(
690 "cargo-audit",
691 "R-1",
692 serde_json::json!({"package": "zeta", "version": "1.0.0"}),
693 )],
694 );
695 let b = layer(
696 "osv-scanner",
697 vec![finding(
698 "osv-scanner",
699 "G-1",
700 serde_json::json!({"package": "alpha", "version": "1.0.0"}),
701 )],
702 );
703 let forwards = cross_reference(&[a.clone(), b.clone()]);
704 let backwards = cross_reference(&[b, a]);
705 assert_eq!(forwards, backwards);
706 let packages: Vec<&str> = forwards.iter().map(|c| c.package.as_str()).collect();
707 assert_eq!(packages, vec!["alpha", "zeta"]);
708 }
709
710 #[test]
713 fn an_advisory_always_has_a_name() {
714 let layers = vec![layer(
715 "osv-scanner",
716 vec![finding(
717 "osv-scanner",
718 "OSV-1",
719 serde_json::json!({"package": "x", "version": "1.0.0"}),
720 )],
721 )];
722 let crossref: Vec<Correspondence> = cross_reference(&layers);
723 assert_eq!(crossref[0].advisory, "OSV-1");
724 }
725}