1use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use clap::Parser;
10use fleetreach_core::{FleetReport, Occurrence, Provenance, ReachVerdict, Severity, VulnFinding};
11use fleetreach_go::SandboxPolicy;
12use fleetreach_report as report;
13use fleetreach_scan::AdvisoryDb;
14
15use crate::assemble::{assemble, Assembled, SuppressedOccurrence, Suppression};
16use crate::cli::{fail, usage_fail, BuildSandbox};
17use crate::config::{Config, Repo};
18use crate::db::{build_provenance, load_db_from};
19use crate::orchestrate::{
20 scan_fleet, GhActionsScan, GoScan, HexScan, JuliaScan, MavenScan, NpmScan, NuGetScan,
21 PackagistScan, PyPiScan, RubyGemsScan, SwiftScan,
22};
23use crate::static_reach;
24
25pub fn resolve_product_ids(config: &Config) -> BTreeMap<String, String> {
27 let base = config.vex.product_id_base.as_deref();
28 config
29 .repos
30 .iter()
31 .map(|repo| (repo.id.0.clone(), resolve_product_id(repo, base)))
32 .collect()
33}
34
35pub fn resolve_product_id(repo: &Repo, base: Option<&str>) -> String {
38 if let Some(id) = &repo.vex_product_id {
39 return id.clone();
40 }
41 if let Some(purl) = crate_purl(&repo.path) {
42 return purl;
43 }
44 match base {
45 Some(base) => format!("{base}{}", repo.id.0),
46 None => format!("urn:fleetreach:product:{}", repo.id.0),
47 }
48}
49
50fn crate_purl(repo_path: &Path) -> Option<String> {
53 let text = std::fs::read_to_string(repo_path.join("Cargo.toml")).ok()?;
54 let value: toml::Value = toml::from_str(&text).ok()?;
55 let pkg = value.get("package")?.as_table()?;
56 if pkg.get("publish").and_then(toml::Value::as_bool) == Some(false) {
57 return None;
58 }
59 let name = pkg.get("name")?.as_str()?;
60 let version = pkg.get("version")?.as_str()?;
62 Some(format!("pkg:cargo/{name}@{version}"))
63}
64
65pub fn build_human_assertions(
69 suppressed: &[SuppressedOccurrence],
70 product_ids: &BTreeMap<String, String>,
71 warn_free_text: bool,
72) -> Vec<report::HumanAssertion> {
73 let mut assertions = Vec::new();
74 let mut nudged: BTreeSet<&str> = BTreeSet::new();
75 for s in suppressed {
76 let Occurrence::InRepo {
77 repo,
78 package,
79 installed,
80 ..
81 } = &s.occurrence
82 else {
83 continue;
84 };
85 let Some(product_id) = product_ids.get(&repo.0) else {
86 continue;
87 };
88 if warn_free_text && s.justification.is_none() && nudged.insert(&s.advisory_id) {
89 eprintln!(
90 "warning: vex suppression for {} uses a free-text reason; \
91 prefer a `justification` label for machine consumers",
92 s.advisory_id
93 );
94 }
95 assertions.push(report::HumanAssertion {
96 advisory_id: s.advisory_id.clone(),
97 aliases: s.aliases.clone(),
98 product_id: product_id.clone(),
99 package: package.clone(),
100 version: installed.to_string(),
101 justification: s.justification.clone(),
102 impact_statement: s.impact_statement.clone(),
103 approved_by: s.approved_by.clone(),
104 });
105 }
106 assertions
107}
108
109pub fn projection_params(
112 product_ids: BTreeMap<String, String>,
113 assertions: Vec<report::HumanAssertion>,
114) -> report::VexParams {
115 report::VexParams {
116 author: String::new(),
117 role: None,
118 scope: report::VexScope::Runtime,
119 timestamp: String::new(),
120 doc_id: None,
121 product_id_base: None,
122 product_ids,
123 assertions,
124 only_sound: false,
125 alias_rustbinary: false,
126 include_fixed: false,
127 version: 1,
128 supersedes: None,
129 }
130}
131
132pub fn assemble_fresh(config: &Config, db: &AdvisoryDb, provenance: Provenance) -> Assembled {
135 let scan = scan_fleet(
139 db,
140 config,
141 None,
142 None,
143 &GoScan {
144 govulncheck: None,
145 sandbox: SandboxPolicy::Off,
146 vuln_db: None,
147 offline: false,
148 },
149 &NpmScan { vuln_db: None },
150 &PyPiScan { vuln_db: None },
151 &RubyGemsScan { vuln_db: None },
152 &PackagistScan { vuln_db: None },
153 &NuGetScan { vuln_db: None },
154 &JuliaScan { vuln_db: None },
155 &SwiftScan { vuln_db: None },
156 &HexScan { vuln_db: None },
157 &GhActionsScan { vuln_db: None },
158 &MavenScan { vuln_db: None },
159 );
160 let mut suppressions: Vec<Suppression> = config
161 .ignores
162 .iter()
163 .map(Suppression::from_ignore)
164 .collect();
165 suppressions.extend(
166 config
167 .vex_assertions
168 .iter()
169 .map(Suppression::from_assertion),
170 );
171 assemble(scan, &suppressions, None, provenance)
172}
173
174pub struct Drift {
178 pub errors: Vec<String>,
179 pub warnings: Vec<String>,
180}
181
182pub struct CommittedStatement {
185 pub vulnerability: String,
186 pub product: String,
187 pub subcomponent: String,
188 pub status: String,
189 pub status_notes: String,
193}
194
195impl CommittedStatement {
196 fn is_human(&self) -> bool {
201 self.status_notes.contains("Human Assertion")
202 }
203}
204
205pub fn check_drift(
216 current: &[report::StatementView],
217 committed: &[CommittedStatement],
218 severity: &BTreeMap<String, Severity>,
219) -> Drift {
220 let key = |v: &str, p: &str, s: &str| (v.to_string(), p.to_string(), s.to_string());
221 let current_keys: BTreeSet<(String, String, String)> = current
222 .iter()
223 .map(|s| key(&s.vulnerability, &s.product, &s.subcomponent))
224 .collect();
225 let current_status: BTreeMap<(String, String, String), &str> = current
228 .iter()
229 .map(|s| {
230 (
231 key(&s.vulnerability, &s.product, &s.subcomponent),
232 s.status.as_str(),
233 )
234 })
235 .collect();
236 let committed_keys: BTreeSet<(String, String, String)> = committed
237 .iter()
238 .map(|c| key(&c.vulnerability, &c.product, &c.subcomponent))
239 .collect();
240
241 let mut errors = Vec::new();
242 for s in current {
243 if !current_in(
244 &committed_keys,
245 &s.vulnerability,
246 &s.product,
247 &s.subcomponent,
248 ) {
249 errors.push(format!(
250 "untriaged: {} on {} has no statement in the committed document",
251 s.vulnerability, s.subcomponent
252 ));
253 }
254 }
255 for c in committed {
256 if c.status != "not_affected" {
257 continue;
258 }
259 let k = key(&c.vulnerability, &c.product, &c.subcomponent);
260 match current_status.get(&k) {
261 None => errors.push(format!(
263 "stale not_affected: {} on {} no longer appears in the fleet \
264 (the suppression pins a version that moved)",
265 c.vulnerability, c.subcomponent
266 )),
267 Some(&now) if now != "not_affected" && c.is_human() => errors.push(format!(
270 "dropped suppression: {} on {} is not_affected in the committed \
271 document (human assertion) but the current scan reports {now} — \
272 the assertion was removed or no longer applies",
273 c.vulnerability, c.subcomponent
274 )),
275 Some(_) => {}
276 }
277 }
278 let mut warnings: BTreeSet<String> = BTreeSet::new();
279 for c in committed {
280 if c.status == "under_investigation"
281 && current_in(¤t_keys, &c.vulnerability, &c.product, &c.subcomponent)
282 && is_gating_severity(severity.get(&c.vulnerability).copied())
283 {
284 warnings.insert(format!(
285 "{} is still under_investigation at gating severity; \
286 resolve with --reachability=static",
287 c.vulnerability
288 ));
289 }
290 }
291 Drift {
292 errors,
293 warnings: warnings.into_iter().collect(),
294 }
295}
296
297fn current_in(keys: &BTreeSet<(String, String, String)>, v: &str, p: &str, s: &str) -> bool {
298 keys.contains(&(v.to_string(), p.to_string(), s.to_string()))
299}
300
301pub fn parse_committed_statements(doc: &serde_json::Value) -> Vec<CommittedStatement> {
304 let Some(stmts) = doc.get("statements").and_then(|s| s.as_array()) else {
305 return Vec::new();
306 };
307 let field = |s: &serde_json::Value, ptr: &str| {
308 s.pointer(ptr)
309 .and_then(|v| v.as_str())
310 .unwrap_or_default()
311 .to_string()
312 };
313 stmts
314 .iter()
315 .map(|s| CommittedStatement {
316 vulnerability: field(s, "/vulnerability/name"),
317 product: field(s, "/products/0/@id"),
318 subcomponent: field(s, "/subcomponents/0/@id"),
319 status: field(s, "/status"),
320 status_notes: field(s, "/status_notes"),
321 })
322 .collect()
323}
324
325pub fn is_gating_severity(severity: Option<Severity>) -> bool {
328 matches!(
329 severity,
330 Some(Severity::High) | Some(Severity::Critical) | Some(Severity::Unknown) | None
331 )
332}
333
334pub fn committed_reachability_witnesses(doc: &serde_json::Value) -> Vec<(String, String)> {
339 fn str_at<'a>(s: &'a serde_json::Value, ptr: &str) -> &'a str {
340 s.pointer(ptr).and_then(|v| v.as_str()).unwrap_or_default()
341 }
342 let Some(stmts) = doc.get("statements").and_then(|s| s.as_array()) else {
343 return Vec::new();
344 };
345 stmts
346 .iter()
347 .filter(|s| {
348 str_at(s, "/status") == "not_affected"
349 && str_at(s, "/justification") == "vulnerable_code_not_in_execute_path"
350 && str_at(s, "/status_notes").contains("Automated Analysis")
353 })
354 .map(|s| {
355 (
356 str_at(s, "/vulnerability/name").to_string(),
357 str_at(s, "/subcomponents/0/@id").to_string(),
358 )
359 })
360 .collect()
361}
362
363pub fn failed_reachability_witnesses(
367 witnesses: &[(String, String)],
368 report: &FleetReport,
369) -> Vec<(String, String)> {
370 let by_advisory: BTreeMap<&str, &VulnFinding> = report
371 .vulnerabilities
372 .iter()
373 .map(|v| (v.advisory_id.as_str(), v))
374 .collect();
375 witnesses
376 .iter()
377 .filter(|(vuln, _)| match by_advisory.get(vuln.as_str()) {
378 None => false,
379 Some(finding) => !matches!(
380 finding.reachability.as_ref().map(|r| &r.verdict),
381 Some(ReachVerdict::NotReachable)
382 ),
383 })
384 .cloned()
385 .collect()
386}
387
388#[derive(Parser)]
391pub(crate) struct VexCheckArgs {
392 #[arg(short, long, default_value = "./fleet.toml")]
393 config: PathBuf,
394 #[arg(
395 long,
396 value_name = "PATH",
397 help = "the committed OpenVEX document to check against"
398 )]
399 against: PathBuf,
400
401 #[arg(long, help = "use a local advisory-db clone instead of fetching")]
403 db: Option<PathBuf>,
404 #[arg(long, help = "pin advisory DB to an exact commit (requires --db)")]
405 db_rev: Option<String>,
406 #[arg(long, help = "never fetch; require cache/--db")]
407 offline: bool,
408
409 #[arg(short, long, help = "suppress the summary line")]
410 quiet: bool,
411}
412
413#[derive(Parser)]
414pub(crate) struct VexVerifyArgs {
415 #[arg(value_name = "DOCUMENT")]
417 document: PathBuf,
418 #[arg(short, long, default_value = "./fleet.toml")]
419 config: PathBuf,
420
421 #[arg(long, help = "use a local advisory-db clone instead of fetching")]
423 db: Option<PathBuf>,
424 #[arg(long, help = "pin advisory DB to an exact commit (requires --db)")]
425 db_rev: Option<String>,
426 #[arg(long, help = "never fetch; require cache/--db")]
427 offline: bool,
428
429 #[arg(
431 long,
432 help = "REQUIRED: re-deriving witnesses compiles each repo, running its build scripts and proc-macros (arbitrary code). Only verify repos you trust."
433 )]
434 allow_untrusted_builds: bool,
435 #[arg(
436 long,
437 value_name = "PATH",
438 help = "path to the built fleetreach-reach-driver"
439 )]
440 reach_driver: Option<PathBuf>,
441 #[arg(long, value_enum, default_value = "auto", value_name = "MODE")]
442 build_sandbox: BuildSandbox,
443 #[arg(long, value_name = "FEATURES", value_delimiter = ',')]
444 features: Vec<String>,
445 #[arg(long)]
446 all_features: bool,
447 #[arg(long)]
448 no_default_features: bool,
449
450 #[arg(short, long, help = "suppress the summary line")]
451 quiet: bool,
452 #[arg(short, long, help = "per-repo progress to stderr")]
453 verbose: bool,
454}
455
456pub(crate) fn run_vex_check(args: VexCheckArgs) -> u8 {
460 let config = match Config::load(&args.config) {
461 Ok(config) => config,
462 Err(e) => return fail(&e.to_string()),
463 };
464 let db = match load_db_from(args.db.as_deref(), args.db_rev.as_deref(), args.offline) {
465 Ok(db) => db,
466 Err(e) => return fail(&e),
467 };
468 let committed_text = match std::fs::read_to_string(&args.against) {
469 Ok(text) => text,
470 Err(e) => {
471 return fail(&format!(
472 "reading committed VEX `{}`: {e}",
473 args.against.display()
474 ))
475 }
476 };
477 let committed: serde_json::Value = match serde_json::from_str(&committed_text) {
478 Ok(value) => value,
479 Err(e) => {
480 return fail(&format!(
481 "parsing committed VEX `{}`: {e}",
482 args.against.display()
483 ))
484 }
485 };
486
487 let provenance = build_provenance(&db.meta());
488 let Assembled { report, suppressed } = assemble_fresh(&config, &db, provenance);
489 let product_ids = resolve_product_ids(&config);
490 let assertions = build_human_assertions(&suppressed, &product_ids, false);
491 let params = projection_params(product_ids, assertions);
492 let current = report::project(&report, ¶ms);
493
494 let committed_stmts = parse_committed_statements(&committed);
495 let severity: BTreeMap<String, Severity> = report
496 .vulnerabilities
497 .iter()
498 .map(|v| (v.advisory_id.clone(), v.severity))
499 .collect();
500 let drift = check_drift(¤t, &committed_stmts, &severity);
501
502 for w in &drift.warnings {
503 eprintln!("warning: {w}");
504 }
505 for e in &drift.errors {
506 eprintln!("error: {e}");
507 }
508 if !args.quiet {
509 eprintln!(
510 "vex check: {} current statement(s), {} committed; {} drift error(s), {} warning(s).",
511 current.len(),
512 committed_stmts.len(),
513 drift.errors.len(),
514 drift.warnings.len()
515 );
516 }
517 u8::from(!drift.errors.is_empty())
518}
519
520pub(crate) fn run_vex_verify(args: VexVerifyArgs) -> u8 {
524 if !args.allow_untrusted_builds {
525 return usage_fail(
526 "vex verify re-derives witnesses by COMPILING each repo (build scripts + \
527 proc-macros run). Re-run with --allow-untrusted-builds only if you trust every repo.",
528 );
529 }
530 let Some(driver) = args.reach_driver.as_deref() else {
531 return usage_fail("vex verify requires --reach-driver <PATH>");
532 };
533 let config = match Config::load(&args.config) {
534 Ok(config) => config,
535 Err(e) => return fail(&e.to_string()),
536 };
537 let committed_text = match std::fs::read_to_string(&args.document) {
538 Ok(text) => text,
539 Err(e) => return fail(&format!("reading `{}`: {e}", args.document.display())),
540 };
541 let committed: serde_json::Value = match serde_json::from_str(&committed_text) {
542 Ok(value) => value,
543 Err(e) => return fail(&format!("parsing `{}`: {e}", args.document.display())),
544 };
545
546 let targets = committed_reachability_witnesses(&committed);
547 if targets.is_empty() {
548 if !args.quiet {
549 eprintln!("vex verify: no reachability witnesses to re-derive.");
550 }
551 return 0;
552 }
553
554 let db = match load_db_from(args.db.as_deref(), args.db_rev.as_deref(), args.offline) {
555 Ok(db) => db,
556 Err(e) => return fail(&e),
557 };
558 let provenance = build_provenance(&db.meta());
559 let Assembled {
560 mut report,
561 suppressed: _,
562 } = assemble_fresh(&config, &db, provenance);
563
564 let features = fleetreach_reach::FeatureSelection {
565 all_features: args.all_features,
566 no_default_features: args.no_default_features,
567 features: args.features.clone(),
568 };
569 static_reach::assess(
570 &mut report,
571 &config,
572 &static_reach::Options {
573 driver,
574 features,
575 sandbox: args.build_sandbox.into(),
576 verbose: args.verbose,
577 },
578 );
579
580 let failed = failed_reachability_witnesses(&targets, &report);
581 for (vuln, sub) in &failed {
582 eprintln!("error: witness no longer holds: {vuln} on {sub} is now reachable or undecided");
583 }
584 if !args.quiet {
585 eprintln!(
586 "vex verify: {} witness(es) re-derived, {} failed.",
587 targets.len(),
588 failed.len()
589 );
590 }
591 u8::from(!failed.is_empty())
592}
593
594#[cfg(test)]
595#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
596mod tests {
597 use super::*;
598 use fleetreach_core::semver::Version;
599 use fleetreach_core::{DependencyKind, Provenance, Reachability, RepoId, Summary, VulnFinding};
600 use fleetreach_report::StatementView;
601
602 fn finding_with_reach(id: &str, reach: Option<ReachVerdict>) -> VulnFinding {
603 VulnFinding {
604 advisory_id: id.into(),
605 aliases: vec![],
606 ecosystem: Default::default(),
607 title: "t".into(),
608 severity: Severity::High,
609 cvss_score: None,
610 url: None,
611 occurrences: vec![Occurrence::InRepo {
612 repo: RepoId("app".into()),
613 package: "foo".into(),
614 installed: Version::new(1, 0, 0),
615 patched: vec![],
616 dependency_kind: DependencyKind::Direct,
617 dependency_path: vec![],
618 active: None,
619 source: Default::default(),
620 }],
621 affected_functions: vec!["foo::bad".into()],
622 reachable: None,
623 reachability: reach.map(|verdict| Reachability {
624 verdict,
625 config: "nightly".into(),
626 engine: "e".into(),
627 targets: vec!["x86_64-unknown-linux-gnu".into()],
628 witness: Some("sha256:abc".into()),
629 }),
630 exploit: Default::default(),
631 }
632 }
633
634 fn report_of(vulns: Vec<VulnFinding>) -> FleetReport {
635 FleetReport {
636 schema_version: 1,
637 provenance: Provenance {
638 tool_version: "0".into(),
639 rustsec_crate_version: "0".into(),
640 db_commit: None,
641 db_timestamp: None,
642 host_os: "linux".into(),
643 host_arch: "x86_64".into(),
644 generated_at: "t".into(),
645 },
646 summary: Summary {
647 repos_scanned: 1,
648 repos_errored: 0,
649 vuln_count: vulns.len(),
650 warn_count: 0,
651 max_severity: Severity::High,
652 stale_ignores: vec![],
653 },
654 vulnerabilities: vulns,
655 warnings: vec![],
656 outcomes: vec![],
657 }
658 }
659
660 fn view(v: &str, p: &str, s: &str, status: &str) -> StatementView {
661 StatementView {
662 vulnerability: v.into(),
663 product: p.into(),
664 subcomponent: s.into(),
665 status: status.into(),
666 }
667 }
668
669 fn committed(v: &str, p: &str, s: &str, status: &str, notes: &str) -> CommittedStatement {
670 CommittedStatement {
671 vulnerability: v.into(),
672 product: p.into(),
673 subcomponent: s.into(),
674 status: status.into(),
675 status_notes: notes.into(),
676 }
677 }
678
679 #[test]
680 fn drift_flags_untriaged_and_stale() {
681 let current = vec![view(
682 "RUSTSEC-NEW",
683 "p",
684 "pkg:cargo/foo@1",
685 "under_investigation",
686 )];
687 let committed = vec![committed(
688 "RUSTSEC-OLD",
689 "p",
690 "pkg:cargo/bar@1",
691 "not_affected",
692 "role=Automated Analysis",
693 )];
694 let drift = check_drift(¤t, &committed, &BTreeMap::new());
695 assert_eq!(drift.errors.len(), 2, "untriaged + stale");
696 assert!(drift.warnings.is_empty());
697 }
698
699 #[test]
700 fn drift_is_empty_when_in_sync() {
701 let current = vec![view("RUSTSEC-A", "p", "s", "not_affected")];
702 let committed = vec![committed(
703 "RUSTSEC-A",
704 "p",
705 "s",
706 "not_affected",
707 "role=Human Assertion; approved_by=x",
708 )];
709 let drift = check_drift(¤t, &committed, &BTreeMap::new());
710 assert!(drift.errors.is_empty());
711 }
712
713 #[test]
716 fn dropped_human_suppression_is_flagged() {
717 let current = vec![view("RUSTSEC-A", "p", "s", "under_investigation")];
718 let committed = vec![committed(
719 "RUSTSEC-A",
720 "p",
721 "s",
722 "not_affected",
723 "role=Human Assertion; approved_by=x",
724 )];
725 let drift = check_drift(¤t, &committed, &BTreeMap::new());
726 assert_eq!(drift.errors.len(), 1, "dropped suppression");
727 assert!(drift.errors[0].contains("dropped suppression"));
728 }
729
730 #[test]
734 fn machine_not_affected_is_not_status_checked() {
735 let current = vec![view("RUSTSEC-A", "p", "s", "under_investigation")];
736 let committed = vec![committed(
737 "RUSTSEC-A",
738 "p",
739 "s",
740 "not_affected",
741 "role=Automated Analysis; static call-graph: no path",
742 )];
743 let drift = check_drift(¤t, &committed, &BTreeMap::new());
744 assert!(
745 drift.errors.is_empty(),
746 "machine not_affected must not be status-checked: {:?}",
747 drift.errors
748 );
749 }
750
751 #[test]
753 fn extracts_only_machine_reachability_witnesses() {
754 let doc = serde_json::json!({ "statements": [
755 { "vulnerability": { "name": "RUSTSEC-A" },
756 "subcomponents": [{ "@id": "pkg:cargo/foo@1.0.0" }],
757 "status": "not_affected",
758 "justification": "vulnerable_code_not_in_execute_path",
759 "status_notes": "role=Automated Analysis; static call-graph: no path" },
760 { "vulnerability": { "name": "RUSTSEC-H" },
761 "subcomponents": [{ "@id": "pkg:cargo/bar@2.0.0" }],
762 "status": "not_affected",
763 "justification": "vulnerable_code_not_in_execute_path",
764 "status_notes": "role=Human Assertion; approved_by=x" },
765 { "vulnerability": { "name": "RUSTSEC-U" },
766 "subcomponents": [{ "@id": "pkg:cargo/baz@3.0.0" }],
767 "status": "under_investigation" },
768 ]});
769 let w = committed_reachability_witnesses(&doc);
770 assert_eq!(
771 w,
772 vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())]
773 );
774 }
775
776 #[test]
777 fn witness_holds_when_still_not_reachable() {
778 let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
779 let report = report_of(vec![finding_with_reach(
780 "RUSTSEC-A",
781 Some(ReachVerdict::NotReachable),
782 )]);
783 assert!(failed_reachability_witnesses(&w, &report).is_empty());
784 }
785
786 #[test]
787 fn witness_fails_when_now_reachable() {
788 let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
789 let report = report_of(vec![finding_with_reach(
790 "RUSTSEC-A",
791 Some(ReachVerdict::Reachable {
792 witness: vec!["main".into(), "foo::bad".into()],
793 }),
794 )]);
795 assert_eq!(failed_reachability_witnesses(&w, &report).len(), 1);
796 }
797
798 #[test]
799 fn witness_holds_vacuously_when_advisory_is_gone() {
800 let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
801 assert!(failed_reachability_witnesses(&w, &report_of(vec![])).is_empty());
802 }
803
804 #[test]
805 fn witness_fails_when_now_undecided() {
806 let w = vec![("RUSTSEC-A".to_string(), "pkg:cargo/foo@1.0.0".to_string())];
807 let report = report_of(vec![finding_with_reach("RUSTSEC-A", None)]);
808 assert_eq!(failed_reachability_witnesses(&w, &report).len(), 1);
809 }
810}