Skip to main content

fleetreach_core/
remediation.rs

1//! v2 remediation: turn a correlated [`FleetReport`] into a queue of *actions*.
2//!
3//! `fix-first` ranks *which advisory* to patch; this layer answers *what to do* —
4//! the concrete dependency bump, batched by the bump that delivers it, with a
5//! reachability gate so vulns in provably dead code drop out of the active queue.
6//!
7//! This is a **pure, I/O-free assembly** over the existing model (no new scan-time
8//! data): the fix range is [`Occurrence::patched`], the sound verdict is
9//! [`VulnFinding::reachability`], and blast radius is the occurrence set. VEX
10//! suppression is *not* handled here — already-mitigated findings are filtered
11//! upstream before [`remediations`] ever sees them, keeping this crate VEX-free.
12
13use std::collections::{BTreeMap, BTreeSet};
14
15use semver::{Op, Version, VersionReq};
16use serde::{Deserialize, Serialize};
17
18use crate::{Ecosystem, FleetReport, Occurrence, ReachVerdict, Severity, VulnFinding};
19
20/// Where an action sits relative to the active fix queue. Only a **sound static**
21/// `NotReachable` demotes an item to the informational tier — the grep heuristic
22/// ([`VulnFinding::reachable`]) is too weak to gate, and an absent verdict is
23/// treated as [`Unknown`](ReachTier::Unknown) (fail-open: we never hide a vuln on
24/// weak evidence, the same stance as `--min-epss`).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ReachTier {
28    /// A concrete call path exists, or at least one grouped advisory is reachable.
29    Reachable,
30    /// Undecided for at least one grouped advisory (no engine run, or `Unknown`).
31    Unknown,
32    /// Every grouped advisory is soundly `NotReachable` — informational, not work.
33    NotReachable,
34}
35
36impl ReachTier {
37    /// Whether this item belongs in the active fix queue (vs the informational
38    /// tier). Only a fully-`NotReachable` group is demoted.
39    pub fn is_actionable(self) -> bool {
40        self != ReachTier::NotReachable
41    }
42}
43
44/// What to actually do about a group of advisories on one package.
45#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(tag = "type", rename_all = "snake_case")]
47pub enum Action {
48    /// Bump the package to `to` (the minimal version clearing every grouped
49    /// advisory). `breaking` flags a semver-major jump (or a `0.x` minor jump) so
50    /// the queue can favour low-churn fixes.
51    Upgrade { to: Version, breaking: bool },
52    /// No grouped advisory publishes a fix — route to VEX / mitigation, never a
53    /// fabricated upgrade.
54    NoFixAvailable,
55}
56
57/// One actionable remediation, derived from one or more [`VulnFinding`]s that
58/// share a target package. Computed from a [`FleetReport`]; never persisted in
59/// scan output, so it carries its own ranking signals (max/any across the group)
60/// to spare the report layer a re-join.
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct RemediationItem {
63    /// The dependency to act on (crate name, or toolchain channel).
64    pub package: String,
65    pub ecosystem: Ecosystem,
66    /// Distinct vulnerable versions present across the fleet, ascending.
67    pub current: Vec<Version>,
68    /// Advisory ids this single action resolves, sorted.
69    pub advisories: Vec<String>,
70    pub action: Action,
71    /// Worst-case reachability across the grouped advisories.
72    pub reach: ReachTier,
73    /// Distinct repos with a vulnerable occurrence.
74    pub repos: usize,
75    /// Total vulnerable occurrences covered.
76    pub occurrences: usize,
77    /// Highest severity in the group — primary ranking signal.
78    pub max_severity: Severity,
79    /// Any grouped advisory is actively exploited (CISA KEV).
80    pub kev: bool,
81    /// Highest EPSS in the group, when any is enriched.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub max_epss: Option<f32>,
84    /// Highest CVSS base score in the group, when any is known.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub max_cvss: Option<f32>,
87}
88
89/// Assemble the remediation queue from a correlated report.
90///
91/// Findings are grouped by target package; within a group, advisories that share
92/// a compatible fix range collapse into one [`Action::Upgrade`] (the batching
93/// win), while incompatible ranges (one demands `<2.0`, another `>=2.1`) split
94/// back into per-advisory actions. Findings with no published fix become
95/// [`Action::NoFixAvailable`]. The result is deterministically ordered by package
96/// then advisory set; the report layer applies the fix-first ranking on top.
97pub fn remediations(report: &FleetReport) -> Vec<RemediationItem> {
98    // Key on (ecosystem, package): a crate and a Go module can share a name, and
99    // they must never batch into one bump.
100    let mut groups: BTreeMap<(Ecosystem, String), Vec<&VulnFinding>> = BTreeMap::new();
101    for v in &report.vulnerabilities {
102        // Skip findings whose every occurrence is already patched — nothing to do.
103        if vuln_occ_count(v) == 0 {
104            continue;
105        }
106        if let Some(package) = finding_package(v) {
107            groups.entry((v.ecosystem, package)).or_default().push(v);
108        }
109    }
110
111    let mut items = Vec::new();
112    for ((ecosystem, package), findings) in groups {
113        let (fixable, nofix): (Vec<&VulnFinding>, Vec<&VulnFinding>) = findings
114            .into_iter()
115            .partition(|f| finding_floor(&finding_patched(f)).is_some());
116
117        if !nofix.is_empty() {
118            items.push(build_item(
119                ecosystem,
120                &package,
121                &nofix,
122                Action::NoFixAvailable,
123            ));
124        }
125        if fixable.is_empty() {
126            continue;
127        }
128
129        // Never suggest a downgrade: the bump must clear the advisory *and* land at
130        // or above the newest version any repo already has (an advisory's safe set
131        // can include a lower line — smallvec's >=0.6.14 alongside >=1.6.1). The
132        // batched target is the max of each advisory's forward fix; it's a single
133        // valid bump only if every advisory's safe set actually contains it, else
134        // the ranges conflict and we split per-advisory.
135        let group_lb = fixable
136            .iter()
137            .flat_map(|f| installed_versions(f))
138            .max()
139            .unwrap_or(Version::new(0, 0, 0));
140        let candidate = match fixable
141            .iter()
142            .filter_map(|f| finding_target(&finding_patched(f), &group_lb))
143            .max()
144        {
145            Some(c) => c,
146            None => continue, // unreachable given the partition, but never panic
147        };
148        let compatible = fixable
149            .iter()
150            .all(|f| satisfied_by(&finding_patched(f), &candidate));
151
152        if compatible {
153            let action = upgrade_action(&fixable, &candidate);
154            items.push(build_item(ecosystem, &package, &fixable, action));
155        } else {
156            for &f in &fixable {
157                let lb = installed_versions(f)
158                    .into_iter()
159                    .max()
160                    .unwrap_or(Version::new(0, 0, 0));
161                if let Some(target) = finding_target(&finding_patched(f), &lb) {
162                    let action = upgrade_action(&[f], &target);
163                    items.push(build_item(ecosystem, &package, &[f], action));
164                }
165            }
166        }
167    }
168
169    items.sort_by(|a, b| {
170        a.package
171            .cmp(&b.package)
172            .then_with(|| a.ecosystem.cmp(&b.ecosystem))
173            .then_with(|| a.advisories.cmp(&b.advisories))
174    });
175    items
176}
177
178/// Build one item from a subset of findings on the same (ecosystem, package), with
179/// a precomputed action.
180fn build_item(
181    ecosystem: Ecosystem,
182    package: &str,
183    subset: &[&VulnFinding],
184    action: Action,
185) -> RemediationItem {
186    let mut advisories: Vec<String> = subset.iter().map(|f| f.advisory_id.clone()).collect();
187    advisories.sort();
188    advisories.dedup();
189
190    let mut current: Vec<Version> = subset.iter().flat_map(|f| installed_versions(f)).collect();
191    current.sort();
192    current.dedup();
193
194    let repos: BTreeSet<&str> = subset.iter().flat_map(|f| repo_ids(f)).collect();
195    let occurrences = subset.iter().map(|f| vuln_occ_count(f)).sum();
196    let max_severity = subset.iter().map(|f| f.severity).max().unwrap_or_default();
197    let kev = subset.iter().any(|f| f.exploit.kev);
198    let max_epss = subset
199        .iter()
200        .filter_map(|f| f.exploit.epss)
201        .reduce(f32::max);
202    let max_cvss = subset.iter().filter_map(|f| f.cvss_score).reduce(f32::max);
203
204    RemediationItem {
205        package: package.to_string(),
206        ecosystem,
207        current,
208        advisories,
209        action,
210        reach: collapse_reach(subset.iter().copied()),
211        repos: repos.len(),
212        occurrences,
213        max_severity,
214        kev,
215        max_epss,
216        max_cvss,
217    }
218}
219
220/// An [`Action::Upgrade`] to `to`, flagged breaking when it crosses the
221/// compatibility boundary from the newest version the fleet currently has.
222fn upgrade_action(subset: &[&VulnFinding], to: &Version) -> Action {
223    let current_max = subset.iter().flat_map(|f| installed_versions(f)).max();
224    let breaking = match &current_max {
225        // Cargo treats `0.x` minor bumps as breaking; everything else by major.
226        Some(c) => to.major != c.major || (to.major == 0 && to.minor != c.minor),
227        None => false,
228    };
229    Action::Upgrade {
230        to: to.clone(),
231        breaking,
232    }
233}
234
235/// Collapse a group's per-finding verdicts to the safest queue placement: any
236/// reachable wins; else any undecided keeps it active; only an all-`NotReachable`
237/// group is demoted.
238fn collapse_reach<'a>(findings: impl Iterator<Item = &'a VulnFinding>) -> ReachTier {
239    let mut tier = ReachTier::NotReachable;
240    for f in findings {
241        match finding_reach(f) {
242            ReachTier::Reachable => return ReachTier::Reachable,
243            ReachTier::Unknown => tier = ReachTier::Unknown,
244            ReachTier::NotReachable => {}
245        }
246    }
247    tier
248}
249
250fn finding_reach(f: &VulnFinding) -> ReachTier {
251    match f.reachability.as_ref().map(|r| &r.verdict) {
252        Some(ReachVerdict::Reachable { .. }) => ReachTier::Reachable,
253        Some(ReachVerdict::NotReachable) => ReachTier::NotReachable,
254        Some(ReachVerdict::Unknown { .. }) | None => ReachTier::Unknown,
255    }
256}
257
258/// The minimal lower-bound version a single requirement permits (its floor). An
259/// upper-bound-only req (`<2.0`) has no floor and yields `None`.
260fn req_floor(req: &VersionReq) -> Option<Version> {
261    req.comparators.iter().find_map(|c| match c.op {
262        Op::Exact | Op::Greater | Op::GreaterEq | Op::Tilde | Op::Caret => Some(Version::new(
263            c.major,
264            c.minor.unwrap_or(0),
265            c.patch.unwrap_or(0),
266        )),
267        _ => None,
268    })
269}
270
271/// Whether any forward fix is namable for a finding (drives fixable/no-fix
272/// partition). `patched` is OR semantics — a version is safe if it matches *any*
273/// req (see [`Occurrence::is_vulnerable`]) — so this is true iff some req has a
274/// lower bound. `None` means no fix is published (empty set) or none is namable.
275fn finding_floor(patched: &[VersionReq]) -> Option<Version> {
276    patched.iter().filter_map(req_floor).min()
277}
278
279/// The version to actually upgrade *to*: the smallest req floor at or above the
280/// current floor `lb`, so we never recommend a downgrade when the advisory's safe
281/// set spans an older line too. Falls back to the highest available fix when every
282/// fix predates `lb` (pathological — the only published fixes are on a line below
283/// what's installed).
284fn finding_target(patched: &[VersionReq], lb: &Version) -> Option<Version> {
285    let mut floors: Vec<Version> = patched.iter().filter_map(req_floor).collect();
286    floors.sort();
287    floors
288        .iter()
289        .find(|v| *v >= lb)
290        .cloned()
291        .or_else(|| floors.last().cloned())
292}
293
294/// Whether a version lands in a finding's safe set (matches any patched req).
295fn satisfied_by(patched: &[VersionReq], v: &Version) -> bool {
296    patched.iter().any(|r| r.matches(v))
297}
298
299/// The target package key for a finding: the crate name, or a toolchain channel.
300fn finding_package(f: &VulnFinding) -> Option<String> {
301    f.occurrences.first().map(|o| match o {
302        Occurrence::InRepo { package, .. } => package.clone(),
303        Occurrence::Toolchain { channel, .. } => channel.clone(),
304    })
305}
306
307/// The union of patched ranges across a finding's occurrences (normally identical
308/// — the range comes from the advisory — but deduped defensively).
309fn finding_patched(f: &VulnFinding) -> Vec<VersionReq> {
310    let mut reqs: Vec<VersionReq> = f
311        .occurrences
312        .iter()
313        .flat_map(|o| match o {
314            Occurrence::InRepo { patched, .. } => patched.clone(),
315            Occurrence::Toolchain { patched, .. } => patched.clone(),
316        })
317        .collect();
318    reqs.sort_by_key(|r| r.to_string());
319    reqs.dedup_by(|a, b| a.to_string() == b.to_string());
320    reqs
321}
322
323fn installed_versions(f: &VulnFinding) -> Vec<Version> {
324    f.occurrences
325        .iter()
326        .filter(|o| o.is_vulnerable())
327        .filter_map(|o| match o {
328            Occurrence::InRepo { installed, .. } => Some(installed.clone()),
329            Occurrence::Toolchain { installed, .. } => installed.clone(),
330        })
331        .collect()
332}
333
334fn repo_ids(f: &VulnFinding) -> Vec<&str> {
335    f.occurrences
336        .iter()
337        .filter(|o| o.is_vulnerable())
338        .filter_map(|o| match o {
339            Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
340            Occurrence::Toolchain { .. } => None,
341        })
342        .collect()
343}
344
345fn vuln_occ_count(f: &VulnFinding) -> usize {
346    f.occurrences.iter().filter(|o| o.is_vulnerable()).count()
347}
348
349#[cfg(test)]
350mod tests {
351    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
352    use super::*;
353    use crate::{DependencyKind, Provenance, Reachability, RepoId, Summary, SCHEMA_VERSION};
354
355    fn in_repo(repo: &str, pkg: &str, installed: &str, patched: &[&str]) -> Occurrence {
356        Occurrence::InRepo {
357            repo: RepoId(repo.into()),
358            package: pkg.into(),
359            installed: Version::parse(installed).unwrap(),
360            patched: patched
361                .iter()
362                .map(|p| VersionReq::parse(p).unwrap())
363                .collect(),
364            dependency_kind: DependencyKind::Transitive,
365            dependency_path: vec![],
366            active: None,
367            source: Default::default(),
368        }
369    }
370
371    fn vuln(id: &str, sev: Severity, occ: Vec<Occurrence>) -> VulnFinding {
372        VulnFinding {
373            advisory_id: id.into(),
374            aliases: vec![],
375            ecosystem: Ecosystem::Cargo,
376            title: id.into(),
377            severity: sev,
378            cvss_score: None,
379            url: None,
380            occurrences: occ,
381            affected_functions: vec![],
382            reachable: None,
383            reachability: None,
384            exploit: Default::default(),
385        }
386    }
387
388    fn with_reach(mut f: VulnFinding, verdict: ReachVerdict) -> VulnFinding {
389        f.reachability = Some(Reachability {
390            verdict,
391            config: "cfg".into(),
392            engine: "test".into(),
393            targets: vec![],
394            witness: None,
395        });
396        f
397    }
398
399    fn report_of(vulns: Vec<VulnFinding>) -> FleetReport {
400        FleetReport {
401            schema_version: SCHEMA_VERSION,
402            provenance: Provenance {
403                tool_version: "t".into(),
404                rustsec_crate_version: "t".into(),
405                db_commit: None,
406                db_timestamp: None,
407                host_os: "t".into(),
408                host_arch: "t".into(),
409                generated_at: "t".into(),
410            },
411            summary: Summary {
412                repos_scanned: 0,
413                repos_errored: 0,
414                vuln_count: vulns.len(),
415                warn_count: 0,
416                max_severity: Severity::Unknown,
417                stale_ignores: vec![],
418            },
419            vulnerabilities: vulns,
420            warnings: vec![],
421            outcomes: vec![],
422        }
423    }
424
425    #[test]
426    fn single_fixable_finding_yields_one_upgrade() {
427        let r = report_of(vec![vuln(
428            "RUSTSEC-1",
429            Severity::High,
430            vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
431        )]);
432        let items = remediations(&r);
433        assert_eq!(items.len(), 1);
434        let it = &items[0];
435        assert_eq!(it.package, "foo");
436        assert_eq!(it.advisories, ["RUSTSEC-1"]);
437        assert_eq!(
438            it.action,
439            Action::Upgrade {
440                to: Version::new(1, 2, 0),
441                breaking: false,
442            }
443        );
444        assert_eq!(it.reach, ReachTier::Unknown);
445        assert_eq!(it.repos, 1);
446        assert_eq!(it.occurrences, 1);
447        assert_eq!(it.current, [Version::new(1, 0, 0)]);
448    }
449
450    #[test]
451    fn compatible_advisories_batch_into_one_bump() {
452        // Two advisories on the same crate, fixable by a single >=1.5.0 bump,
453        // in two different repos -> one batched action covering both repos.
454        let r = report_of(vec![
455            vuln(
456                "RUSTSEC-A",
457                Severity::Medium,
458                vec![in_repo("app1", "foo", "1.0.0", &[">=1.2.0"])],
459            ),
460            vuln(
461                "RUSTSEC-B",
462                Severity::High,
463                vec![in_repo("app2", "foo", "1.1.0", &[">=1.5.0"])],
464            ),
465        ]);
466        let items = remediations(&r);
467        assert_eq!(items.len(), 1);
468        let it = &items[0];
469        assert_eq!(it.advisories, ["RUSTSEC-A", "RUSTSEC-B"]);
470        assert_eq!(
471            it.action,
472            Action::Upgrade {
473                to: Version::new(1, 5, 0),
474                breaking: false,
475            }
476        );
477        assert_eq!(it.repos, 2);
478        // Ranking signal is the worst of the group.
479        assert_eq!(it.max_severity, Severity::High);
480    }
481
482    #[test]
483    fn incompatible_ranges_split_per_advisory() {
484        // One advisory fixed only in the 1.x line (<2.0), another only in >=2.1 —
485        // no single bump satisfies both, so they split.
486        let r = report_of(vec![
487            vuln(
488                "RUSTSEC-A",
489                Severity::High,
490                vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0, <2.0.0"])],
491            ),
492            vuln(
493                "RUSTSEC-B",
494                Severity::High,
495                vec![in_repo("app", "foo", "1.0.0", &[">=2.1.0"])],
496            ),
497        ]);
498        let items = remediations(&r);
499        assert_eq!(items.len(), 2);
500        let tos: Vec<&Action> = items.iter().map(|i| &i.action).collect();
501        assert!(tos.contains(&&Action::Upgrade {
502            to: Version::new(1, 2, 0),
503            breaking: false,
504        }));
505        assert!(tos.contains(&&Action::Upgrade {
506            to: Version::new(2, 1, 0),
507            breaking: true,
508        }));
509    }
510
511    #[test]
512    fn distinct_ecosystems_never_batch() {
513        // A crate `foo` and a Go module `foo` share a name but must stay separate.
514        let mut go = vuln(
515            "GO-2024-0001",
516            Severity::High,
517            vec![in_repo("r", "foo", "1.0.0", &[">=1.2.0"])],
518        );
519        go.ecosystem = Ecosystem::Go;
520        let cargo = vuln(
521            "RUSTSEC-2024-0001",
522            Severity::High,
523            vec![in_repo("r", "foo", "1.0.0", &[">=1.2.0"])],
524        );
525        let items = remediations(&report_of(vec![go, cargo]));
526        assert_eq!(
527            items.len(),
528            2,
529            "same name, different ecosystem must not batch"
530        );
531        let ecos: Vec<Ecosystem> = items.iter().map(|i| i.ecosystem).collect();
532        assert!(ecos.contains(&Ecosystem::Cargo) && ecos.contains(&Ecosystem::Go));
533    }
534
535    #[test]
536    fn never_recommends_a_downgrade() {
537        // smallvec RUSTSEC-2021-0003 shape: fixed in both the old 0.6.x line and
538        // 1.6.1+. Installed 1.6.0 must bump UP to 1.6.1, not down to 0.6.14.
539        let r = report_of(vec![vuln(
540            "RUSTSEC-2021-0003",
541            Severity::Critical,
542            vec![in_repo(
543                "app",
544                "smallvec",
545                "1.6.0",
546                &[">=0.6.14, <1.0.0", ">=1.6.1"],
547            )],
548        )]);
549        let items = remediations(&r);
550        assert_eq!(
551            items[0].action,
552            Action::Upgrade {
553                to: Version::new(1, 6, 1),
554                breaking: false, // 1.6.0 -> 1.6.1 is a patch bump
555            }
556        );
557    }
558
559    #[test]
560    fn no_published_fix_is_honest() {
561        let r = report_of(vec![vuln(
562            "RUSTSEC-1",
563            Severity::Critical,
564            vec![in_repo("app", "foo", "1.0.0", &[])],
565        )]);
566        let items = remediations(&r);
567        assert_eq!(items.len(), 1);
568        assert_eq!(items[0].action, Action::NoFixAvailable);
569        assert_eq!(items[0].max_severity, Severity::Critical);
570    }
571
572    #[test]
573    fn major_bump_is_breaking() {
574        let r = report_of(vec![vuln(
575            "RUSTSEC-1",
576            Severity::High,
577            vec![in_repo("app", "foo", "1.4.0", &[">=2.0.0"])],
578        )]);
579        let items = remediations(&r);
580        assert_eq!(
581            items[0].action,
582            Action::Upgrade {
583                to: Version::new(2, 0, 0),
584                breaking: true,
585            }
586        );
587    }
588
589    #[test]
590    fn zerover_minor_bump_is_breaking() {
591        let r = report_of(vec![vuln(
592            "RUSTSEC-1",
593            Severity::Low,
594            vec![in_repo("app", "foo", "0.4.0", &[">=0.5.0"])],
595        )]);
596        let items = remediations(&r);
597        assert_eq!(
598            items[0].action,
599            Action::Upgrade {
600                to: Version::new(0, 5, 0),
601                breaking: true,
602            }
603        );
604    }
605
606    #[test]
607    fn not_reachable_demotes_to_informational() {
608        let r = report_of(vec![with_reach(
609            vuln(
610                "RUSTSEC-1",
611                Severity::High,
612                vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
613            ),
614            ReachVerdict::NotReachable,
615        )]);
616        let items = remediations(&r);
617        assert_eq!(items[0].reach, ReachTier::NotReachable);
618        assert!(!items[0].reach.is_actionable());
619    }
620
621    #[test]
622    fn reachable_stays_actionable() {
623        let r = report_of(vec![with_reach(
624            vuln(
625                "RUSTSEC-1",
626                Severity::High,
627                vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
628            ),
629            ReachVerdict::Reachable { witness: vec![] },
630        )]);
631        let items = remediations(&r);
632        assert_eq!(items[0].reach, ReachTier::Reachable);
633        assert!(items[0].reach.is_actionable());
634    }
635
636    #[test]
637    fn any_reachable_in_a_batch_keeps_it_active() {
638        // One advisory NotReachable, one Reachable, batched on the same crate:
639        // the safe collapse keeps the whole action active.
640        let r = report_of(vec![
641            with_reach(
642                vuln(
643                    "RUSTSEC-A",
644                    Severity::Medium,
645                    vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
646                ),
647                ReachVerdict::NotReachable,
648            ),
649            with_reach(
650                vuln(
651                    "RUSTSEC-B",
652                    Severity::High,
653                    vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
654                ),
655                ReachVerdict::Reachable { witness: vec![] },
656            ),
657        ]);
658        let items = remediations(&r);
659        assert_eq!(items.len(), 1);
660        assert_eq!(items[0].reach, ReachTier::Reachable);
661    }
662
663    #[test]
664    fn fully_patched_finding_is_skipped() {
665        // Installed version already satisfies the patched range -> not vulnerable
666        // -> no remediation.
667        let r = report_of(vec![vuln(
668            "RUSTSEC-1",
669            Severity::High,
670            vec![in_repo("app", "foo", "1.2.0", &[">=1.2.0"])],
671        )]);
672        assert!(remediations(&r).is_empty());
673    }
674}