Skip to main content

deps_cli/update/
security.rs

1//! `--security-only` planner: phase-B OSV re-verification, `recommended_fix()` targeting, the
2//! native-form yank filter, and the three-outcome classification (#1120, spec 068 FR-008
3//! through FR-015).
4
5use std::time::Duration;
6
7use deps_core::Ecosystem;
8use deps_core::edit::{VulnFixSkip, plan_verified_fix, resolve_verified_fix};
9use deps_core::lsp_helpers::{RequirementGate, resolve_in_use_version, resolve_scan_outcome};
10use deps_core::osv::{OsvClient, ScanOutcome};
11
12use crate::analyze::ManifestAnalysis;
13use crate::update::ignore::IgnoreRules;
14use crate::update::{
15    CurrentVersion, Outcome, PlannedUpdateItem, UnfixableReason, UpdatePlan, is_requested,
16};
17
18/// Ceiling on the phase-B `check_candidates` timeout, mirroring `analyze.rs`'s own OSV scan
19/// timeout ceiling — the shared `reqwest` client already imposes its own client-wide 30s
20/// timeout, so a longer configured value would never actually bind.
21const OSV_CHECK_TIMEOUT_CEILING_SECS: u64 = 30;
22
23/// Plans the `--security-only` update set.
24///
25/// Every OSV-`Vulnerable` dependency, targeting `recommended_fix()`, independently
26/// re-verified against OSV before being classified
27/// `Applied`/`RequiresLockfileUpdate`/`Unfixable`.
28///
29/// `ignore_rules` is consulted only for **reporting** which rule was overridden (FR-008) —
30/// never to suppress a candidate; pass [`IgnoreRules::empty`] when no `--config` was given,
31/// same as the default planner.
32///
33/// # Examples
34///
35/// With no OSV scan having run (`analysis.vulnerabilities` is `None` — `--security-only` was
36/// combined with `network.offline`/`vulnerabilities_enabled = false`, or simply never
37/// executed against this manifest), this returns an empty plan without ever touching `osv`,
38/// so this example runs deterministically with no network access:
39///
40/// ```
41/// use deps_cli::analyze::ManifestAnalysis;
42/// use deps_cli::update::ignore::IgnoreRules;
43/// use deps_cli::update::security::plan_security_updates;
44/// use deps_core::osv::OsvClient;
45/// use deps_core::{EcosystemId, HttpCache};
46/// use deps_engine::test_util::TestTier3Ecosystem;
47/// use std::collections::{HashMap, HashSet};
48/// use std::sync::Arc;
49///
50/// # #[tokio::main]
51/// # async fn main() {
52/// let ecosystem = TestTier3Ecosystem::returning(vec![]);
53/// let osv = OsvClient::new(Arc::new(HttpCache::new()));
54///
55/// let analysis = ManifestAnalysis {
56///     parse_result: deps_core::test_util::stub_parse_result_with_dependencies(0),
57///     uri: deps_core::test_util::test_uri("/test/pubspec.yaml"),
58///     now: deps_core::PublishTime::now(),
59///     ecosystem_id: EcosystemId::Dart,
60///     cached_versions: HashMap::new(),
61///     resolved_versions: HashMap::new(),
62///     resolved_version_candidates: HashMap::new(),
63///     outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
64///     vulnerabilities: None,
65///     latest_status: None,
66///     fallback_status: None,
67///     cooldown_fallback_view: None,
68///     gossip_findings: HashMap::new(),
69///     licenses: HashMap::new(),
70///     license_policy: deps_core::licenses::LicensePolicy::default(),
71///     license_source: deps_core::LicenseSource::default(),
72///     network: deps_core::NetworkMode::Online,
73///     fetch_failed: HashSet::new(),
74///     registry_unreachable: false,
75///     license_fetch_incomplete: false,
76/// };
77///
78/// let plan = plan_security_updates(
79///     &analysis,
80///     &ecosystem,
81///     &osv,
82///     &[],
83///     &IgnoreRules::empty(),
84///     30,
85/// )
86/// .await;
87///
88/// assert!(plan.items.is_empty());
89/// # }
90/// ```
91pub async fn plan_security_updates(
92    analysis: &ManifestAnalysis,
93    ecosystem: &dyn Ecosystem,
94    osv: &OsvClient,
95    package_filter: &[String],
96    ignore_rules: &IgnoreRules,
97    fetch_timeout_secs: u64,
98) -> UpdatePlan {
99    let formatter = ecosystem.formatter();
100    let ecosystem_id = analysis.ecosystem_id;
101
102    let Some(vulnerabilities) = analysis.vulnerabilities.as_ref() else {
103        return UpdatePlan::default();
104    };
105
106    let vulnerable_keys: Vec<deps_core::osv::VulnKey> = vulnerabilities
107        .iter()
108        .filter(|(_, outcome)| matches!(outcome, ScanOutcome::Vulnerable(_)))
109        .map(|(key, _)| key.clone())
110        .collect();
111    if vulnerable_keys.is_empty() {
112        return UpdatePlan::default();
113    }
114
115    let (targets, _skipped) = deps_engine::classify::osv::build_scan_targets(
116        analysis.parse_result.as_ref(),
117        &analysis.resolved_versions,
118        &analysis.resolved_version_candidates,
119        formatter,
120        ecosystem_id,
121    );
122    let osv_name_by_key = deps_engine::classify::osv::osv_name_by_key(&targets);
123
124    // Issue #1517 (former FR-009 hazard, now dissolved): `deps-cli` runs its own phase-B.1
125    // "latest" check (`analyze_manifest`'s `latest_check`), so — unlike before, when this map
126    // was deliberately left empty — the CLI now has a real `LatestStatusMap` to pass here,
127    // the same shared source of truth `deps-lsp` commits to `DocumentState.signals`. `None`
128    // (vulnerabilities checking disabled, or offline) degrades to an empty map, which
129    // `collect_fix_target_resolutions` already treats as "nothing to reuse, always live-check"
130    // — never a silently-suppressed fix.
131    static EMPTY_LATEST_STATUS: std::sync::LazyLock<deps_core::osv::LatestStatusMap> =
132        std::sync::LazyLock::new(deps_core::osv::LatestStatusMap::new);
133    let latest_status = analysis
134        .latest_status
135        .as_ref()
136        .unwrap_or(&EMPTY_LATEST_STATUS);
137    let (resolved, live_check_candidates) =
138        deps_engine::classify::osv::collect_fix_target_resolutions(
139            vulnerabilities,
140            &vulnerable_keys,
141            &osv_name_by_key,
142            latest_status,
143            formatter,
144        );
145
146    let mut vulnerabilities = vulnerabilities.clone();
147    for (key, status) in resolved {
148        if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(&key) {
149            dv.fix_target_status = status;
150        }
151    }
152    if !live_check_candidates.is_empty() {
153        let timeout = Duration::from_secs(fetch_timeout_secs.min(OSV_CHECK_TIMEOUT_CEILING_SECS));
154        let statuses = osv
155            .check_candidates(ecosystem_id, &live_check_candidates, timeout)
156            .await;
157        deps_engine::classify::osv::apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
158    }
159
160    let vuln_key_by_range = deps_core::osv::vulnerability_keys(
161        analysis.parse_result.as_ref(),
162        &analysis.resolved_versions,
163        Some(&analysis.resolved_version_candidates),
164        formatter,
165        ecosystem_id,
166    );
167
168    let mut items = Vec::new();
169    for dep in analysis.parse_result.dependencies() {
170        let normalized_name = formatter.normalize_package_name(dep.name());
171        let Some(ScanOutcome::Vulnerable(dv)) = resolve_scan_outcome(
172            &vulnerabilities,
173            dep,
174            Some(&vuln_key_by_range),
175            &normalized_name,
176        ) else {
177            continue;
178        };
179
180        if !is_requested(package_filter, &normalized_name, formatter) {
181            items.push(skipped_not_requested(
182                dep,
183                &normalized_name,
184                ecosystem_id,
185                analysis,
186                formatter,
187            ));
188            continue;
189        }
190
191        let latest = analysis.latest_status.as_ref().and_then(|map| {
192            deps_core::lsp_helpers::resolve_latest_status(
193                map,
194                dep,
195                Some(&vuln_key_by_range),
196                &normalized_name,
197            )
198        });
199
200        items.push(classify_vulnerable_dependency(
201            dep,
202            dv,
203            latest,
204            &normalized_name,
205            analysis,
206            formatter,
207            ecosystem_id,
208            ignore_rules,
209        ));
210    }
211
212    UpdatePlan { items }
213}
214
215/// Classifies one already-confirmed-`Vulnerable`, already-requested dependency into its
216/// final [`PlannedUpdateItem`] — the pure, synchronous per-dependency decision the async
217/// orchestration in [`plan_security_updates`] delegates to once `dv.fix_target_status` has
218/// been resolved (live-checked or not). Split out specifically so this decision logic is
219/// unit-testable without an `OsvClient`/network dependency (spec 068 S5) — `dv` is taken
220/// pre-resolved rather than re-deriving its `fix_target_status` here.
221#[expect(
222    clippy::too_many_arguments,
223    reason = "issue #1517 added `latest` alongside seven pre-existing parameters; mirrors \
224              `deps-lsp::document::osv_scan::run_osv_fix_target_verification`'s identical \
225              precedent for the same OSV-plumbing reason"
226)]
227fn classify_vulnerable_dependency(
228    dep: &dyn deps_core::Dependency,
229    dv: &deps_core::osv::DependencyVulnerabilities,
230    latest: Option<&deps_core::osv::UpgradeStatus>,
231    normalized_name: &str,
232    analysis: &ManifestAnalysis,
233    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
234    ecosystem_id: deps_core::EcosystemId,
235    ignore_rules: &IgnoreRules,
236) -> PlannedUpdateItem {
237    // M8: the resolved in-use version, not the declared requirement text, is what
238    // `deps-cli check` and default mode both report as `current` — a security report must
239    // show which version is actually vulnerable ("serde 1.0.1 -> 1.0.2"), not the declared
240    // range ("serde 1 -> 1.0.2"), which hides that information.
241    //
242    // Code review finding 4: `security_current`'s fallback chain is *not* the same as default
243    // mode's. Default mode's `collect_update_candidates` (`deps-core::edit`) falls straight
244    // from an unresolved `resolve_in_use_version` to `CurrentVersion::Unknown` (rendered as
245    // `""`), the honest "we don't know" value `CurrentVersion::update_kind_to` maps to
246    // `UpdateKind::Unknown`. This function instead falls back to the declared requirement text
247    // (`CurrentVersion::Declared`) before `Unknown`, deliberately: an empty `current` in a
248    // vulnerability report ("serde  -> 1.0.2") reads as a rendering bug, and a
249    // `--security-only` report's whole purpose is communicating exposure, so showing the
250    // declared range ("serde 1 -> 1.0.2") when the exact in-use version can't be resolved is
251    // strictly more useful here than it would be worth changing default mode's shared,
252    // LSP-facing `current` semantics to match (which FR-001's byte-identical-`deps-lsp`
253    // constraint rules out doing casually anyway).
254    let current = security_current(dep, normalized_name, analysis, formatter, ecosystem_id);
255    let ignore_rule_overridden = ignore_override(ignore_rules, normalized_name);
256
257    // FR-011: the two-signal, load-bearing `Unfixable` rule — `fetch_and_classify_package`
258    // (`crates/deps-engine/src/classify/fetch.rs:605-825`) upholds the disjointness between
259    // `fetch_failed` and a present `PackageVersions` entry by convention across a large
260    // `match`, not by the type system, so both signals are checked explicitly: a future
261    // refactor breaking that disjointness degrades to fail-closed here rather than silently
262    // admitting a stale-but-present entry through the yank filter below. Deliberately
263    // stricter than `deps-lsp` (`code_actions.rs:590-604`), which fails closed only on a
264    // *timed-out* fetch and leaves a plain fetch failure unfiltered — the CLI's broader rule
265    // is intentional (the safe direction) and must not be narrowed to match `deps-lsp`.
266    let cached = analysis
267        .cached_versions
268        .get(normalized_name)
269        .or_else(|| analysis.cached_versions.get(dep.name()));
270    if analysis.fetch_failed.contains(dep.name()) || cached.is_none() {
271        return unfixable_item(
272            dep,
273            current,
274            UnfixableReason::FetchFailedOrAbsent,
275            ignore_rule_overridden,
276        );
277    }
278
279    // #1350: `resolve_verified_fix` replaces this function's own copy of the
280    // `recommended_fix -> osv_version_to_native -> is_safe_version_string ->
281    // fix_target_is_verified` chain (`fix_target_is_verified` is `pub(crate)` in `deps-core`
282    // again since this is its only remaining caller outside it). S1 fix: this must run
283    // *before* the version_requirement/version_range/yanked checks below, exactly like the
284    // pre-#1350 code did, so an unverified fix target is never misreported as
285    // `RequiresLockfileUpdate`/`Unfixable(Yanked)` instead of `Unfixable(NoVerifiedFix)`.
286    let (fix, version_native) = match resolve_verified_fix(dv, latest, formatter) {
287        Ok(pair) => pair,
288        Err(_) => {
289            return unfixable_item(
290                dep,
291                current,
292                UnfixableReason::NoVerifiedFix,
293                ignore_rule_overridden,
294            );
295        }
296    };
297
298    // C1 (critic finding, FR-010): a `Vulnerable` dependency with no declared
299    // `version_requirement()` at all (a Cargo workspace-inherited dependency, a git/path
300    // dependency whose manifest line carries no version) has nothing to rewrite — same
301    // treatment as the `version_range()` miss below, never a silent `continue` that would let
302    // the run exit 0 with a known-vulnerable dependency neither remediated nor reported.
303    let Some(version_req) = dep.version_requirement() else {
304        return requires_lockfile_update_item(
305            dep,
306            current,
307            deps_core::ConcreteVersion::new(version_native),
308            &fix.advisory_ids,
309            ignore_rule_overridden,
310        );
311    };
312
313    let Some(version_range) = dep.version_range() else {
314        return requires_lockfile_update_item(
315            dep,
316            current,
317            deps_core::ConcreteVersion::new(version_native),
318            &fix.advisory_ids,
319            ignore_rule_overridden,
320        );
321    };
322
323    // FR-012: native-form comparison — comparing OSV's wire-form version directly against
324    // `PackageVersions::yanked` would silently never match for an ecosystem whose OSV and
325    // native spellings diverge (PyPI, Maven, NuGet). `reports_yanked() == false` ecosystems
326    // structurally never populate `yanked` at all (`fetch.rs:617-629`), so this filter is
327    // inert — not converted to `Unfixable` — for them, matching `deps-lsp`'s own documented
328    // fail-open (FR-013).
329    let yanked = cached.is_some_and(|pv| {
330        pv.yanked
331            .iter()
332            .any(|(v, status)| v.as_str() == version_native && status.blocks_resolution())
333    });
334    if yanked {
335        return unfixable_item(
336            dep,
337            current,
338            UnfixableReason::Yanked {
339                target: deps_core::ConcreteVersion::new(version_native),
340            },
341            ignore_rule_overridden,
342        );
343    }
344
345    // #1350 code review: `plan_verified_fix`, not `plan_vulnerability_fix` — this function
346    // already resolved and verified the fix above via `resolve_verified_fix`, so calling the
347    // higher-level `plan_vulnerability_fix` here would re-run that same resolve-and-verify
348    // chain a second time per dependency for no reason.
349    match plan_verified_fix(
350        dep,
351        version_range,
352        version_req.as_str(),
353        &version_native,
354        formatter,
355    ) {
356        // FR-014: `--security-only`'s fix target comes from the advisory, never a
357        // GOSSIP-filtered registry `latest` (issue #1521 item 1) — `gossip_excluded_version`
358        // and `cooldown_fallback` both stay `None` (PlannedUpdateItem::new's defaults).
359        Ok(planned) => PlannedUpdateItem::new(
360            dep.name().as_str().to_string(),
361            current,
362            Outcome::Applied {
363                edit: planned.edit,
364                target: deps_core::ConcreteVersion::new(version_native),
365            },
366            fix.advisory_ids,
367            ignore_rule_overridden,
368            None,
369            None,
370        ),
371        // #1344/#1350: `RequirementAlreadyResolves` (the declared requirement already resolves
372        // forward to the fix target — see `bounded_requirement_already_resolves_to`'s and
373        // `NuGetFormatter`'s doc for why this is not simply "the requirement admits the fix")
374        // reads as "nothing to rewrite here" — note this runs after the FR-012 yanked filter
375        // above, so a requirement that already resolves forward but whose fix target is yanked
376        // was already reported `Unfixable(Yanked)` and never reaches this match.
377        Err(VulnFixSkip::RequirementAlreadyResolves) => requires_lockfile_update_item(
378            dep,
379            current,
380            deps_core::ConcreteVersion::new(version_native),
381            &fix.advisory_ids,
382            ignore_rule_overridden,
383        ),
384        // #1566 S1: `NoOpRewrite` here no longer safely implies "the literal already reads as
385        // the fix." `plan_verified_fix` only reaches `NoOpRewrite` after `bounded_requirement_already_resolves_to`
386        // has already returned `false` above (a `true` answer would have hit
387        // `RequirementAlreadyResolves` instead), so the historical assumption — no
388        // `compile_bounded_requirement` comparator exists (e.g. GitHub Actions/GitLab CI tag pins), and
389        // the declared literal happens to already spell the fix text verbatim — no longer holds
390        // once an ecosystem's formatter can deliberately echo `current` back unchanged for a
391        // requirement shape it has no safe single-value rewrite for (Cargo's compound
392        // comma-separated requirements, #1566).
393        //
394        // Code review regression: checking only `compile_bounded_requirement(..).is_some()` (a matcher
395        // *exists*) is not enough, because `bounded_requirement_already_resolves_to` can diverge from
396        // the raw matcher's verdict — `NuGetFormatter` overrides it to always report `false`
397        // for a bare/open-ended-minimum floor requirement regardless of what the matcher itself
398        // says (floor semantics: leaving the manifest unedited restores the floor version, even
399        // when the floor mathematically admits the fix), and `Pep440Matcher::matches` can
400        // return `None` (indeterminate — the compared version failed to parse) rather than a
401        // confirmed `Some(false)`. Neither case means the requirement was *confirmed* to
402        // exclude the fix, so re-deriving the verdict from the underlying matcher directly —
403        // not `bounded_requirement_already_resolves_to`, which those two overrides deliberately bend
404        // away from the matcher's plain answer — and requiring exactly `Some(false)` is the
405        // only way to tell "confirmed excluded" apart from "unknown"/"overridden for other
406        // reasons". Only a confirmed exclusion is a real gap; anything else falls back to the
407        // legacy "assume already fixed" reading, preserving pre-#1566 behavior.
408        Err(VulnFixSkip::NoOpRewrite) => {
409            let fix_concrete = deps_core::ConcreteVersion::new(version_native.as_str());
410            let confirmed_excluded = formatter
411                .compile_requirement(version_req)
412                .is_some_and(|matcher| matcher.matches(&fix_concrete) == Some(false));
413            if confirmed_excluded {
414                unfixable_item(
415                    dep,
416                    current,
417                    UnfixableReason::UnsupportedRequirementShape {
418                        target: fix_concrete,
419                    },
420                    ignore_rule_overridden,
421                )
422            } else {
423                requires_lockfile_update_item(
424                    dep,
425                    current,
426                    fix_concrete,
427                    &fix.advisory_ids,
428                    ignore_rule_overridden,
429                )
430            }
431        }
432        // #1578 S1: an oversized requirement is a size-based fail-closed guard, never a
433        // confirmed exclusion; reported as `OversizedRequirement` rather than
434        // `UnsupportedRequirementShape`, whose doc/message both assert confirmation.
435        Err(VulnFixSkip::OversizedRequirement) => unfixable_item(
436            dep,
437            current,
438            UnfixableReason::OversizedRequirement {
439                target: deps_core::ConcreteVersion::new(version_native.as_str()),
440            },
441            ignore_rule_overridden,
442        ),
443        // #1370: `UnresolvedPlaceholder` joins the `NoVerifiedFix` bucket, not the
444        // `RequirementAlreadyResolves`/`NoOpRewrite` one above — "requires lockfile update"
445        // implies the manifest requirement already admits the fix target, which is not known
446        // (and can never be, statically) for an unexpanded placeholder.
447        Err(
448            VulnFixSkip::UnverifiedTarget
449            | VulnFixSkip::NoRecommendedFix
450            | VulnFixSkip::UnsafeVersion
451            | VulnFixSkip::UnresolvedPlaceholder,
452        ) => unfixable_item(
453            dep,
454            current,
455            UnfixableReason::NoVerifiedFix,
456            ignore_rule_overridden,
457        ),
458    }
459}
460
461/// `--security-only`'s `current` resolution (M8/code-review finding 4): the resolved in-use
462/// version when one exists, else the declared requirement text, else [`CurrentVersion::Unknown`]
463/// — see [`classify_vulnerable_dependency`]'s doc for why this differs from default mode's
464/// [`deps_core::edit::collect_update_candidates`], which never falls back to the declared text.
465fn security_current(
466    dep: &dyn deps_core::Dependency,
467    normalized_name: &str,
468    analysis: &ManifestAnalysis,
469    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
470    ecosystem_id: deps_core::EcosystemId,
471) -> CurrentVersion {
472    resolve_in_use_version(
473        dep,
474        normalized_name,
475        &analysis.resolved_versions,
476        Some(&analysis.resolved_version_candidates),
477        formatter,
478        ecosystem_id,
479    )
480    .map(CurrentVersion::Resolved)
481    .or_else(|| {
482        dep.version_requirement()
483            .cloned()
484            .map(CurrentVersion::Declared)
485    })
486    .unwrap_or(CurrentVersion::Unknown)
487}
488
489/// Whether a `[update].ignore` rule matches this dependency, purely for the FR-008 override
490/// report — never for suppression in `--security-only` mode.
491fn ignore_override(ignore_rules: &IgnoreRules, normalized_name: &str) -> bool {
492    ignore_rules.matches_name(normalized_name)
493}
494
495fn skipped_not_requested(
496    dep: &dyn deps_core::Dependency,
497    normalized_name: &str,
498    ecosystem_id: deps_core::EcosystemId,
499    analysis: &ManifestAnalysis,
500    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
501) -> PlannedUpdateItem {
502    let current = security_current(dep, normalized_name, analysis, formatter, ecosystem_id);
503    PlannedUpdateItem::new(
504        dep.name().as_str().to_string(),
505        current,
506        Outcome::Skipped {
507            reason: crate::update::SkipReason::NotRequested,
508            target: None,
509        },
510        Vec::new(),
511        false,
512        None,
513        None,
514    )
515}
516
517fn unfixable_item(
518    dep: &dyn deps_core::Dependency,
519    current: CurrentVersion,
520    reason: UnfixableReason,
521    ignore_rule_overridden: bool,
522) -> PlannedUpdateItem {
523    PlannedUpdateItem::new(
524        dep.name().as_str().to_string(),
525        current,
526        Outcome::Unfixable(reason),
527        Vec::new(),
528        ignore_rule_overridden,
529        None,
530        None,
531    )
532}
533
534fn requires_lockfile_update_item(
535    dep: &dyn deps_core::Dependency,
536    current: CurrentVersion,
537    target: deps_core::ConcreteVersion,
538    advisory_ids: &[String],
539    ignore_rule_overridden: bool,
540) -> PlannedUpdateItem {
541    PlannedUpdateItem::new(
542        dep.name().as_str().to_string(),
543        current,
544        Outcome::RequiresLockfileUpdate { target },
545        advisory_ids.to_vec(),
546        ignore_rule_overridden,
547        None,
548        None,
549    )
550}
551
552#[cfg(test)]
553mod tests {
554    use super::*;
555    use crate::config::IgnoreRule;
556    use deps_core::licenses::LicensePolicy;
557    use deps_core::lsp_helpers::{
558        DiagnosticMessages, DiagnosticPolicy, MAX_REQUIREMENT_LEN, OsvNaming, PackageNaming,
559        PackageRendering, RequirementMatcher, RequirementResolution, SourcePolicy,
560    };
561    use deps_core::osv::{Advisory, Capped, OsvVersion, UpgradeStatus, VulnSeverity};
562    use deps_core::parser::DependencySource;
563    use deps_core::position::{Position, Range};
564    use deps_core::{
565        ConcreteVersion, Dependency, EcosystemId, PackageName, PackageVersions, RemovalStatus,
566        VersionReq,
567    };
568    use std::any::Any;
569    use std::collections::{HashMap, HashSet};
570
571    struct MockDep {
572        name: PackageName,
573        version_req: Option<VersionReq>,
574        version_range: Option<Range>,
575    }
576
577    impl Dependency for MockDep {
578        fn name(&self) -> &PackageName {
579            &self.name
580        }
581        fn name_range(&self) -> Range {
582            Range::default()
583        }
584        fn version_requirement(&self) -> Option<&VersionReq> {
585            self.version_req.as_ref()
586        }
587        fn version_range(&self) -> Option<Range> {
588            self.version_range
589        }
590        fn source(&self) -> DependencySource {
591            DependencySource::Registry
592        }
593        fn as_any(&self) -> &dyn Any {
594            self
595        }
596    }
597
598    fn dep(name: &str, req: &str) -> MockDep {
599        MockDep {
600            name: PackageName::new(name),
601            version_req: Some(VersionReq::new(req)),
602            version_range: Some(Range::new(
603                Position::new(0, 9),
604                Position::new(0, 9 + req.len() as u32),
605            )),
606        }
607    }
608
609    /// A fixed-answer [`RequirementMatcher`] — the test controls whether `compile_bounded_requirement`
610    /// reports the declared requirement as already admitting the fix target, independent of
611    /// real semver semantics (deps-cli has no `semver` dependency of its own to build a real
612    /// one with).
613    struct FixedMatcher(bool);
614    impl RequirementMatcher for FixedMatcher {
615        fn matches(&self, _version: &deps_core::ConcreteVersion) -> Option<bool> {
616            Some(self.0)
617        }
618
619        fn strict_prerelease_exclusion(&self) -> bool {
620            false
621        }
622    }
623
624    /// A formatter with a `compile_bounded_requirement` override (like 12 of the 14 real ecosystems),
625    /// whose verdict is fixed per test rather than computed from a real requirement grammar.
626    struct TestFormatter {
627        requirement_already_admits_fix: bool,
628        osv_native_differs: bool,
629    }
630    impl PackageNaming for TestFormatter {}
631    impl PackageRendering for TestFormatter {
632        fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
633            v.to_string()
634        }
635        fn package_url(&self, name: &PackageName) -> String {
636            name.as_str().to_string()
637        }
638    }
639    impl RequirementResolution for TestFormatter {
640        fn compile_bounded_requirement(
641            &self,
642            _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
643        ) -> Option<Box<dyn RequirementMatcher>> {
644            Some(Box::new(FixedMatcher(self.requirement_already_admits_fix)))
645        }
646    }
647    impl DiagnosticMessages for TestFormatter {}
648    impl DiagnosticPolicy for TestFormatter {}
649    impl SourcePolicy for TestFormatter {}
650    impl OsvNaming for TestFormatter {
651        fn osv_version_to_native(
652            &self,
653            version: &deps_core::osv::OsvVersion,
654        ) -> deps_core::ConcreteVersion {
655            // SC-005: simulates an ecosystem whose OSV and native version spellings diverge
656            // (PyPI/Maven/NuGet) — prefixes with "v" so a yank-filter test can prove the
657            // comparison goes through this conversion, not the raw OSV wire form.
658            let version = version.as_str();
659            if self.osv_native_differs {
660                deps_core::ConcreteVersion::new(format!("v{version}"))
661            } else {
662                deps_core::ConcreteVersion::new(version)
663            }
664        }
665    }
666
667    /// #1566 S1: simulates an ecosystem formatter (like Cargo's compound comma-separated
668    /// requirement handling) whose `compile_bounded_requirement` comparator has already confirmed the
669    /// fix target is NOT admitted, yet whose `format_version_replacing` deliberately echoes
670    /// `current` back unchanged because no single-value rewrite preserves the requirement
671    /// shape's semantics.
672    struct UnsupportedShapeFormatter;
673    impl PackageNaming for UnsupportedShapeFormatter {}
674    impl PackageRendering for UnsupportedShapeFormatter {
675        fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
676            v.to_string()
677        }
678        fn package_url(&self, name: &PackageName) -> String {
679            name.as_str().to_string()
680        }
681        fn format_version_replacing(
682            &self,
683            _version: &deps_core::ConcreteVersion,
684            current: &str,
685        ) -> String {
686            current.to_string()
687        }
688    }
689    impl RequirementResolution for UnsupportedShapeFormatter {
690        fn compile_bounded_requirement(
691            &self,
692            _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
693        ) -> Option<Box<dyn RequirementMatcher>> {
694            Some(Box::new(FixedMatcher(false)))
695        }
696    }
697    impl DiagnosticMessages for UnsupportedShapeFormatter {}
698    impl DiagnosticPolicy for UnsupportedShapeFormatter {}
699    impl SourcePolicy for UnsupportedShapeFormatter {}
700    impl OsvNaming for UnsupportedShapeFormatter {}
701
702    /// Code review regression (S1 fix): mirrors `NuGetFormatter::bounded_requirement_already_resolves_to`'s
703    /// real bare-floor override — the raw matcher mathematically admits the fix (`Some(true)`),
704    /// but the ecosystem overrides `bounded_requirement_already_resolves_to` to always report `false`
705    /// for a floor shape (floor semantics: leaving the manifest unedited keeps restoring the
706    /// declared floor). `format_version_replacing` echoes `current` back unchanged, so
707    /// `plan_verified_fix` reaches `NoOpRewrite` — but the raw matcher's `Some(true)` must never
708    /// be read as "confirmed excluded".
709    struct FloorLikeFormatter;
710    impl PackageNaming for FloorLikeFormatter {}
711    impl PackageRendering for FloorLikeFormatter {
712        fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
713            v.to_string()
714        }
715        fn package_url(&self, name: &PackageName) -> String {
716            name.as_str().to_string()
717        }
718        fn format_version_replacing(
719            &self,
720            _version: &deps_core::ConcreteVersion,
721            current: &str,
722        ) -> String {
723            current.to_string()
724        }
725    }
726    impl RequirementResolution for FloorLikeFormatter {
727        fn compile_bounded_requirement(
728            &self,
729            _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
730        ) -> Option<Box<dyn RequirementMatcher>> {
731            Some(Box::new(FixedMatcher(true)))
732        }
733        fn bounded_requirement_already_resolves_to(
734            &self,
735            _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
736            _target: &deps_core::ConcreteVersion,
737        ) -> bool {
738            false
739        }
740    }
741    impl DiagnosticMessages for FloorLikeFormatter {}
742    impl DiagnosticPolicy for FloorLikeFormatter {}
743    impl SourcePolicy for FloorLikeFormatter {}
744    impl OsvNaming for FloorLikeFormatter {}
745
746    /// A matcher that cannot decide — mirrors `Pep440Matcher::matches` returning `None` when
747    /// the compared version fails to parse, rather than a confirmed `Some(false)`.
748    struct IndeterminateMatcher;
749    impl RequirementMatcher for IndeterminateMatcher {
750        fn matches(&self, _version: &deps_core::ConcreteVersion) -> Option<bool> {
751            None
752        }
753        fn strict_prerelease_exclusion(&self) -> bool {
754            false
755        }
756    }
757
758    /// Code review regression (S1 fix): a matcher exists but returns `None` (indeterminate),
759    /// never `Some(false)` (confirmed excluded) — must not be treated the same as a confirmed
760    /// exclusion. `format_version_replacing` echoes `current` back unchanged so `plan_verified_fix`
761    /// reaches `NoOpRewrite`.
762    struct IndeterminateFormatter;
763    impl PackageNaming for IndeterminateFormatter {}
764    impl PackageRendering for IndeterminateFormatter {
765        fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
766            v.to_string()
767        }
768        fn package_url(&self, name: &PackageName) -> String {
769            name.as_str().to_string()
770        }
771        fn format_version_replacing(
772            &self,
773            _version: &deps_core::ConcreteVersion,
774            current: &str,
775        ) -> String {
776            current.to_string()
777        }
778    }
779    impl RequirementResolution for IndeterminateFormatter {
780        fn compile_bounded_requirement(
781            &self,
782            _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
783        ) -> Option<Box<dyn RequirementMatcher>> {
784            Some(Box::new(IndeterminateMatcher))
785        }
786    }
787    impl DiagnosticMessages for IndeterminateFormatter {}
788    impl DiagnosticPolicy for IndeterminateFormatter {}
789    impl SourcePolicy for IndeterminateFormatter {}
790    impl OsvNaming for IndeterminateFormatter {}
791
792    /// Issue #1578: echoes `current` back unchanged but panics if `compile_bounded_requirement`
793    /// is ever called — proves `plan_verified_fix`'s `OversizedRequirement` skip
794    /// short-circuits before reaching it, rather than merely happening to also produce the
795    /// right outcome.
796    struct PanicsIfCompiledFormatter;
797    impl PackageNaming for PanicsIfCompiledFormatter {}
798    impl PackageRendering for PanicsIfCompiledFormatter {
799        fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
800            v.to_string()
801        }
802        fn package_url(&self, name: &PackageName) -> String {
803            name.as_str().to_string()
804        }
805        fn format_version_replacing(
806            &self,
807            _version: &deps_core::ConcreteVersion,
808            current: &str,
809        ) -> String {
810            current.to_string()
811        }
812    }
813    impl RequirementResolution for PanicsIfCompiledFormatter {
814        fn compile_bounded_requirement(
815            &self,
816            _requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
817        ) -> Option<Box<dyn RequirementMatcher>> {
818            panic!(
819                "compile_bounded_requirement must not be called for an oversized requirement (#1578)"
820            );
821        }
822    }
823    impl DiagnosticMessages for PanicsIfCompiledFormatter {}
824    impl DiagnosticPolicy for PanicsIfCompiledFormatter {}
825    impl SourcePolicy for PanicsIfCompiledFormatter {}
826    impl OsvNaming for PanicsIfCompiledFormatter {}
827
828    /// A formatter with no `compile_bounded_requirement` override (like GitHub Actions/GitLab CI) —
829    /// `plan_vulnerability_fix`'s own textual no-op guard is the only available signal.
830    const NO_COMPILE_REQUIREMENT_FORMATTER: deps_core::test_util::StubFormatter =
831        deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
832
833    fn advisory(id: &str, fixed_version: &str) -> std::sync::Arc<Advisory> {
834        std::sync::Arc::new(
835            Advisory::new(
836                id.to_string(),
837                "2024-01-01T00:00:00Z".to_string(),
838                VulnSeverity::High,
839            )
840            .expect("valid osv id")
841            .with_fixed_versions(vec![OsvVersion::new(fixed_version)]),
842        )
843    }
844
845    fn verified_dv(fixed_version: &str) -> deps_core::osv::DependencyVulnerabilities {
846        deps_core::osv::DependencyVulnerabilities::new(Capped::new(
847            vec![advisory("RUSTSEC-2024-0001", fixed_version)],
848            1,
849        ))
850        .with_fix_target_status(UpgradeStatus::CandidateClean {
851            version: ConcreteVersion::new(fixed_version),
852        })
853    }
854
855    fn test_analysis(
856        cached_versions: HashMap<PackageName, PackageVersions>,
857        fetch_failed: HashSet<PackageName>,
858    ) -> ManifestAnalysis {
859        ManifestAnalysis {
860            parse_result: deps_core::test_util::stub_parse_result_with_dependencies(0),
861            uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
862            now: deps_core::PublishTime::now(),
863            ecosystem_id: EcosystemId::Cargo,
864            cached_versions,
865            resolved_versions: HashMap::new(),
866            resolved_version_candidates: HashMap::new(),
867            outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
868            vulnerabilities: None,
869            latest_status: None,
870            fallback_status: None,
871            cooldown_fallback_view: None,
872            gossip_findings: HashMap::new(),
873            licenses: HashMap::new(),
874            license_policy: LicensePolicy::default(),
875            license_source: deps_core::LicenseSource::default(),
876            network: deps_core::NetworkMode::Online,
877            fetch_failed,
878            registry_unreachable: false,
879            license_fetch_incomplete: false,
880        }
881    }
882
883    fn cached_with(name: &str, latest: &str) -> HashMap<PackageName, PackageVersions> {
884        let mut map = HashMap::new();
885        map.insert(PackageName::new(name), PackageVersions::latest_only(latest));
886        map
887    }
888
889    #[test]
890    fn test_classify_happy_path_is_applied() {
891        let dep = dep("serde", "0.9");
892        let dv = verified_dv("1.0.2");
893        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
894        let formatter = TestFormatter {
895            requirement_already_admits_fix: false,
896            osv_native_differs: false,
897        };
898        let item = classify_vulnerable_dependency(
899            &dep,
900            &dv,
901            None,
902            "serde",
903            &analysis,
904            &formatter,
905            EcosystemId::Cargo,
906            &IgnoreRules::empty(),
907        );
908        assert!(matches!(item.outcome, Outcome::Applied { .. }));
909        assert_eq!(
910            item.target(),
911            Some(&deps_core::ConcreteVersion::from("1.0.2"))
912        );
913    }
914
915    /// C1 (critical): a `Vulnerable` dependency with no declared `version_requirement()` at
916    /// all must be reported `RequiresLockfileUpdate`, never silently dropped.
917    #[test]
918    fn test_classify_no_version_requirement_is_requires_lockfile_update() {
919        let dep = MockDep {
920            name: PackageName::new("serde"),
921            version_req: None,
922            version_range: None,
923        };
924        let dv = verified_dv("1.0.2");
925        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
926        let formatter = TestFormatter {
927            requirement_already_admits_fix: false,
928            osv_native_differs: false,
929        };
930        let item = classify_vulnerable_dependency(
931            &dep,
932            &dv,
933            None,
934            "serde",
935            &analysis,
936            &formatter,
937            EcosystemId::Cargo,
938            &IgnoreRules::empty(),
939        );
940        assert!(matches!(
941            item.outcome,
942            Outcome::RequiresLockfileUpdate { .. }
943        ));
944    }
945
946    #[test]
947    fn test_classify_no_version_range_is_requires_lockfile_update() {
948        let dep = MockDep {
949            name: PackageName::new("serde"),
950            version_req: Some(VersionReq::new("1")),
951            version_range: None,
952        };
953        let dv = verified_dv("1.0.2");
954        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
955        let formatter = TestFormatter {
956            requirement_already_admits_fix: false,
957            osv_native_differs: false,
958        };
959        let item = classify_vulnerable_dependency(
960            &dep,
961            &dv,
962            None,
963            "serde",
964            &analysis,
965            &formatter,
966            EcosystemId::Cargo,
967            &IgnoreRules::empty(),
968        );
969        assert!(matches!(
970            item.outcome,
971            Outcome::RequiresLockfileUpdate { .. }
972        ));
973    }
974
975    /// S1 (significant, US-003): a requirement the ecosystem's own comparator confirms
976    /// already admits the fix target must be `RequiresLockfileUpdate`, never rewritten.
977    #[test]
978    fn test_classify_requirement_already_admits_fix_is_requires_lockfile_update() {
979        let dep = dep("serde", "1");
980        let dv = verified_dv("1.0.2");
981        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
982        let formatter = TestFormatter {
983            requirement_already_admits_fix: true,
984            osv_native_differs: false,
985        };
986        let item = classify_vulnerable_dependency(
987            &dep,
988            &dv,
989            None,
990            "serde",
991            &analysis,
992            &formatter,
993            EcosystemId::Cargo,
994            &IgnoreRules::empty(),
995        );
996        assert!(matches!(
997            item.outcome,
998            Outcome::RequiresLockfileUpdate { .. }
999        ));
1000        assert_eq!(
1001            item.target(),
1002            Some(&deps_core::ConcreteVersion::from("1.0.2"))
1003        );
1004    }
1005
1006    /// Fallback path (GitHub Actions/GitLab CI — no `compile_bounded_requirement`): the declared
1007    /// literal already spelling the fix text verbatim still resolves to
1008    /// `RequiresLockfileUpdate` via `plan_vulnerability_fix`'s own no-op guard.
1009    #[test]
1010    fn test_classify_no_compile_requirement_falls_back_to_no_op_guard() {
1011        let dep = dep("serde", "1.0.2");
1012        let dv = verified_dv("1.0.2");
1013        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1014        let item = classify_vulnerable_dependency(
1015            &dep,
1016            &dv,
1017            None,
1018            "serde",
1019            &analysis,
1020            &NO_COMPILE_REQUIREMENT_FORMATTER,
1021            EcosystemId::Cargo,
1022            &IgnoreRules::empty(),
1023        );
1024        assert!(matches!(
1025            item.outcome,
1026            Outcome::RequiresLockfileUpdate { .. }
1027        ));
1028    }
1029
1030    /// #1566 S1 regression: a requirement shape (e.g. Cargo's compound `">=1.2, <1.5"`) whose
1031    /// formatter echoes `current` back unchanged, while a real comparator has already
1032    /// confirmed the fix is NOT admitted, must be `Unfixable(UnsupportedRequirementShape)` —
1033    /// never `RequiresLockfileUpdate`, whose message would falsely tell the operator the
1034    /// vulnerability is already resolved by regenerating the lock file.
1035    #[test]
1036    fn test_classify_unsupported_requirement_shape_is_unfixable_not_requires_lockfile_update() {
1037        let dep = dep("foo", ">=1.2, <1.5");
1038        let dv = verified_dv("1.5.2");
1039        let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1040        let item = classify_vulnerable_dependency(
1041            &dep,
1042            &dv,
1043            None,
1044            "foo",
1045            &analysis,
1046            &UnsupportedShapeFormatter,
1047            EcosystemId::Cargo,
1048            &IgnoreRules::empty(),
1049        );
1050        assert!(
1051            matches!(
1052                item.outcome,
1053                Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
1054            ),
1055            "expected Unfixable(UnsupportedRequirementShape), got {:?}",
1056            item.outcome
1057        );
1058        assert_eq!(
1059            item.target(),
1060            Some(&deps_core::ConcreteVersion::from("1.5.2")),
1061            "an Unfixable item with a rejected fix target must report it (#1614)"
1062        );
1063        assert!(
1064            !item.reason().contains("regenerate the lock file"),
1065            "message must not claim the fix is already admitted: {}",
1066            item.reason()
1067        );
1068    }
1069
1070    /// Code review regression (S1 fix): a bare/open-ended-minimum floor requirement (like
1071    /// NuGet's), whose raw matcher mathematically admits the fix (`Some(true)`) but whose
1072    /// `bounded_requirement_already_resolves_to` override always reports `false` for floor shapes, must
1073    /// still be `RequiresLockfileUpdate` — the matcher's `Some(true)` is not a confirmed
1074    /// exclusion, so `NoOpRewrite` here means the declared floor already spells the fix
1075    /// version, not that the shape is unsupported.
1076    #[test]
1077    fn test_classify_floor_requirement_matching_matcher_is_requires_lockfile_update_not_unfixable()
1078    {
1079        let dep = dep("foo", "1.5.2");
1080        let dv = verified_dv("1.5.2");
1081        let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1082        let item = classify_vulnerable_dependency(
1083            &dep,
1084            &dv,
1085            None,
1086            "foo",
1087            &analysis,
1088            &FloorLikeFormatter,
1089            EcosystemId::Cargo,
1090            &IgnoreRules::empty(),
1091        );
1092        assert!(
1093            matches!(item.outcome, Outcome::RequiresLockfileUpdate { .. }),
1094            "expected RequiresLockfileUpdate, got {:?}",
1095            item.outcome
1096        );
1097    }
1098
1099    /// Code review regression (S1 fix): a matcher that cannot decide (`None`, like
1100    /// `Pep440Matcher::matches` on an unparseable comparison) must not be treated as a
1101    /// confirmed exclusion — falls back to `RequiresLockfileUpdate`, the legacy "assume already
1102    /// fixed" reading, rather than a false `Unfixable(UnsupportedRequirementShape)`.
1103    #[test]
1104    fn test_classify_indeterminate_matcher_is_requires_lockfile_update_not_unfixable() {
1105        let dep = dep("foo", "1.5.2");
1106        let dv = verified_dv("1.5.2");
1107        let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1108        let item = classify_vulnerable_dependency(
1109            &dep,
1110            &dv,
1111            None,
1112            "foo",
1113            &analysis,
1114            &IndeterminateFormatter,
1115            EcosystemId::Cargo,
1116            &IgnoreRules::empty(),
1117        );
1118        assert!(
1119            matches!(item.outcome, Outcome::RequiresLockfileUpdate { .. }),
1120            "expected RequiresLockfileUpdate, got {:?}",
1121            item.outcome
1122        );
1123    }
1124
1125    /// US-003 mixed-outcome regression: spec.md's own US-003 fixture has one dependency whose
1126    /// requirement does not yet admit the fix (`Applied`) alongside one whose requirement
1127    /// already does (`RequiresLockfileUpdate`) — this proves both outcomes are reachable from
1128    /// the same classification function with the S1 gate wired in, not that a real
1129    /// `plan_security_updates` run over both dependencies at once shares no state (each call
1130    /// below is independent, driven by its own `TestFormatter` verdict; N2, critic re-review:
1131    /// this test cannot and does not rule out a shared-state bug across dependencies within
1132    /// one `plan_security_updates` call — that would need a network-mocked end-to-end test,
1133    /// out of scope here).
1134    #[test]
1135    fn test_classify_us003_mixed_outcomes_are_both_reachable() {
1136        let rewrite_dep = dep("serde", "0.9");
1137        let rewrite_dv = verified_dv("1.0.2");
1138        let rewrite_analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1139        let rewrite_formatter = TestFormatter {
1140            requirement_already_admits_fix: false,
1141            osv_native_differs: false,
1142        };
1143        let rewrite_item = classify_vulnerable_dependency(
1144            &rewrite_dep,
1145            &rewrite_dv,
1146            None,
1147            "serde",
1148            &rewrite_analysis,
1149            &rewrite_formatter,
1150            EcosystemId::Cargo,
1151            &IgnoreRules::empty(),
1152        );
1153
1154        let lockfile_dep = dep("tokio", "1");
1155        let lockfile_dv = verified_dv("1.0.2");
1156        let lockfile_analysis = test_analysis(cached_with("tokio", "1.0.2"), HashSet::new());
1157        let lockfile_formatter = TestFormatter {
1158            requirement_already_admits_fix: true,
1159            osv_native_differs: false,
1160        };
1161        let lockfile_item = classify_vulnerable_dependency(
1162            &lockfile_dep,
1163            &lockfile_dv,
1164            None,
1165            "tokio",
1166            &lockfile_analysis,
1167            &lockfile_formatter,
1168            EcosystemId::Cargo,
1169            &IgnoreRules::empty(),
1170        );
1171
1172        assert!(
1173            matches!(rewrite_item.outcome, Outcome::Applied { .. }),
1174            "serde's requirement (\"0.9\") does not yet admit 1.0.2, so it must be rewritten"
1175        );
1176        assert_eq!(
1177            rewrite_item.target(),
1178            Some(&deps_core::ConcreteVersion::from("1.0.2"))
1179        );
1180        assert!(
1181            matches!(
1182                lockfile_item.outcome,
1183                Outcome::RequiresLockfileUpdate { .. }
1184            ),
1185            "tokio's requirement (\"1\") already admits 1.0.2 per spec.md's US-003 fixture, so \
1186             it must be reported, not rewritten"
1187        );
1188        assert_eq!(
1189            lockfile_item.target(),
1190            Some(&deps_core::ConcreteVersion::from("1.0.2"))
1191        );
1192    }
1193
1194    /// FR-011: registry fetch failure is `Unfixable`, not silently passed through.
1195    #[test]
1196    fn test_classify_fetch_failed_is_unfixable() {
1197        let dep = dep("serde", "0.9");
1198        let dv = verified_dv("1.0.2");
1199        let mut fetch_failed = HashSet::new();
1200        fetch_failed.insert(PackageName::new("serde"));
1201        let analysis = test_analysis(cached_with("serde", "1.0.2"), fetch_failed);
1202        let formatter = TestFormatter {
1203            requirement_already_admits_fix: false,
1204            osv_native_differs: false,
1205        };
1206        let item = classify_vulnerable_dependency(
1207            &dep,
1208            &dv,
1209            None,
1210            "serde",
1211            &analysis,
1212            &formatter,
1213            EcosystemId::Cargo,
1214            &IgnoreRules::empty(),
1215        );
1216        assert!(matches!(
1217            item.outcome,
1218            Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent)
1219        ));
1220    }
1221
1222    /// FR-011: no `PackageVersions` entry at all is `Unfixable`, even without a `fetch_failed`
1223    /// entry (the two-signal rule).
1224    #[test]
1225    fn test_classify_absent_package_versions_is_unfixable() {
1226        let dep = dep("serde", "0.9");
1227        let dv = verified_dv("1.0.2");
1228        let analysis = test_analysis(HashMap::new(), HashSet::new());
1229        let formatter = TestFormatter {
1230            requirement_already_admits_fix: false,
1231            osv_native_differs: false,
1232        };
1233        let item = classify_vulnerable_dependency(
1234            &dep,
1235            &dv,
1236            None,
1237            "serde",
1238            &analysis,
1239            &formatter,
1240            EcosystemId::Cargo,
1241            &IgnoreRules::empty(),
1242        );
1243        assert!(matches!(
1244            item.outcome,
1245            Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent)
1246        ));
1247    }
1248
1249    #[test]
1250    fn test_classify_no_recommended_fix_is_unfixable() {
1251        let dep = dep("serde", "0.9");
1252        // No advisory has a claimable fix (no `fixed_versions`), so `recommended_fix()` is
1253        // `None`.
1254        let advisory = std::sync::Arc::new(
1255            Advisory::new(
1256                "RUSTSEC-2024-0001".to_string(),
1257                "2024-01-01T00:00:00Z".to_string(),
1258                VulnSeverity::High,
1259            )
1260            .expect("valid osv id"),
1261        );
1262        let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(vec![advisory], 1));
1263        let analysis = test_analysis(cached_with("serde", "1.2.0"), HashSet::new());
1264        let formatter = TestFormatter {
1265            requirement_already_admits_fix: false,
1266            osv_native_differs: false,
1267        };
1268        let item = classify_vulnerable_dependency(
1269            &dep,
1270            &dv,
1271            None,
1272            "serde",
1273            &analysis,
1274            &formatter,
1275            EcosystemId::Cargo,
1276            &IgnoreRules::empty(),
1277        );
1278        assert!(matches!(
1279            item.outcome,
1280            Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1281        ));
1282    }
1283
1284    #[test]
1285    fn test_classify_unverified_fix_target_is_unfixable() {
1286        let dep = dep("serde", "0.9");
1287        // `fix_target_status` left at `NotChecked` — `fix_target_is_verified` rejects it.
1288        let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1289            vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1290            1,
1291        ));
1292        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1293        let formatter = TestFormatter {
1294            requirement_already_admits_fix: false,
1295            osv_native_differs: false,
1296        };
1297        let item = classify_vulnerable_dependency(
1298            &dep,
1299            &dv,
1300            None,
1301            "serde",
1302            &analysis,
1303            &formatter,
1304            EcosystemId::Cargo,
1305            &IgnoreRules::empty(),
1306        );
1307        assert!(matches!(
1308            item.outcome,
1309            Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1310        ));
1311    }
1312
1313    /// #1350 S1 regression: an unverified fix target must report `Unfixable(NoVerifiedFix)`
1314    /// even when the dependency also has no declared `version_requirement()` — the
1315    /// `resolve_verified_fix` check must run BEFORE the C1 no-requirement guard, exactly as
1316    /// the pre-#1350 code's `fix_target_is_verified` check did, or this misreports
1317    /// `RequiresLockfileUpdate` for a fix that was never actually confirmed safe.
1318    #[test]
1319    fn test_classify_unverified_target_with_no_version_requirement_is_unfixable_not_lockfile() {
1320        let dep = MockDep {
1321            name: PackageName::new("serde"),
1322            version_req: None,
1323            version_range: None,
1324        };
1325        // `fix_target_status` left at `NotChecked` — never verified.
1326        let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1327            vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1328            1,
1329        ));
1330        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1331        let formatter = TestFormatter {
1332            requirement_already_admits_fix: false,
1333            osv_native_differs: false,
1334        };
1335        let item = classify_vulnerable_dependency(
1336            &dep,
1337            &dv,
1338            None,
1339            "serde",
1340            &analysis,
1341            &formatter,
1342            EcosystemId::Cargo,
1343            &IgnoreRules::empty(),
1344        );
1345        assert!(
1346            matches!(
1347                item.outcome,
1348                Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1349            ),
1350            "got {:?}",
1351            item.outcome
1352        );
1353    }
1354
1355    /// #1350 S1 regression: an unverified fix target must report `Unfixable(NoVerifiedFix)`
1356    /// even when its (never-verified) target version also happens to appear in the registry's
1357    /// yanked list — the verification check must run BEFORE the FR-012 yanked filter, exactly
1358    /// as the pre-#1350 code's combined check did.
1359    #[test]
1360    fn test_classify_unverified_target_and_yanked_is_unfixable_no_verified_fix_not_yanked() {
1361        let dep = dep("serde", "0.9");
1362        // `fix_target_status` left at `NotChecked` — never verified.
1363        let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1364            vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1365            1,
1366        ));
1367        let mut cached = HashMap::new();
1368        cached.insert(
1369            PackageName::new("serde"),
1370            PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1371                std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
1372            ),
1373        );
1374        let analysis = test_analysis(cached, HashSet::new());
1375        let formatter = TestFormatter {
1376            requirement_already_admits_fix: false,
1377            osv_native_differs: false,
1378        };
1379        let item = classify_vulnerable_dependency(
1380            &dep,
1381            &dv,
1382            None,
1383            "serde",
1384            &analysis,
1385            &formatter,
1386            EcosystemId::Cargo,
1387            &IgnoreRules::empty(),
1388        );
1389        assert!(
1390            matches!(
1391                item.outcome,
1392                Outcome::Unfixable(UnfixableReason::NoVerifiedFix)
1393            ),
1394            "got {:?}",
1395            item.outcome
1396        );
1397    }
1398
1399    /// FR-012: the fix target is present in the registry's yanked list — `Unfixable`, not
1400    /// written.
1401    #[test]
1402    fn test_classify_yanked_fix_target_is_unfixable() {
1403        let dep = dep("serde", "0.9");
1404        let dv = verified_dv("1.0.2");
1405        let mut cached = HashMap::new();
1406        cached.insert(
1407            PackageName::new("serde"),
1408            PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1409                std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
1410            ),
1411        );
1412        let analysis = test_analysis(cached, HashSet::new());
1413        let formatter = TestFormatter {
1414            requirement_already_admits_fix: false,
1415            osv_native_differs: false,
1416        };
1417        let item = classify_vulnerable_dependency(
1418            &dep,
1419            &dv,
1420            None,
1421            "serde",
1422            &analysis,
1423            &formatter,
1424            EcosystemId::Cargo,
1425            &IgnoreRules::empty(),
1426        );
1427        assert!(matches!(
1428            item.outcome,
1429            Outcome::Unfixable(UnfixableReason::Yanked { .. })
1430        ));
1431        assert_eq!(
1432            item.target(),
1433            Some(&deps_core::ConcreteVersion::from("1.0.2")),
1434            "a yanked-unfixable item must report the rejected fix target (#1614)"
1435        );
1436    }
1437
1438    /// #1344 C3: since the requirement-already-admits-fix gate moved inside
1439    /// `plan_vulnerability_fix`, it now runs AFTER the FR-012 yanked filter, not before it —
1440    /// a requirement that already admits the fix AND a yanked fix target must report
1441    /// `Unfixable(Yanked)`, not `RequiresLockfileUpdate`: a yanked version is never actually
1442    /// selected by re-resolving regardless of what the declared requirement admits, so
1443    /// `Unfixable(Yanked)` is the more truthful classification. Pins this intentional
1444    /// ordering so a future refactor that reverts it is a visible test failure, not a silent
1445    /// behavior change.
1446    #[test]
1447    fn test_classify_requirement_admits_fix_and_yanked_is_unfixable_yanked_not_lockfile() {
1448        let dep = dep("serde", "1");
1449        let dv = verified_dv("1.0.2");
1450        let mut cached = HashMap::new();
1451        cached.insert(
1452            PackageName::new("serde"),
1453            PackageVersions::new("1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1454                std::sync::Arc::from([("1.0.2".into(), RemovalStatus::from_yanked(true))]),
1455            ),
1456        );
1457        let analysis = test_analysis(cached, HashSet::new());
1458        let formatter = TestFormatter {
1459            requirement_already_admits_fix: true,
1460            osv_native_differs: false,
1461        };
1462        let item = classify_vulnerable_dependency(
1463            &dep,
1464            &dv,
1465            None,
1466            "serde",
1467            &analysis,
1468            &formatter,
1469            EcosystemId::Cargo,
1470            &IgnoreRules::empty(),
1471        );
1472        assert!(matches!(
1473            item.outcome,
1474            Outcome::Unfixable(UnfixableReason::Yanked { .. })
1475        ));
1476    }
1477
1478    /// SC-005: the yank filter must compare via the ecosystem's *native* version spelling,
1479    /// not OSV's raw wire form — proven here with a formatter whose two forms diverge
1480    /// (mirrors PyPI/Maven/NuGet).
1481    #[test]
1482    fn test_classify_yank_filter_uses_native_form_not_osv_wire_form() {
1483        let dep = dep("serde", "0.9");
1484        // `fixed_versions` is OSV's own wire-form spelling ("1.0.2"); `fix_target_status` is
1485        // already converted to native form ("v1.0.2") — matching what `osv_version_to_native`
1486        // would have produced and what `fix_target_is_verified` compares against.
1487        let dv = deps_core::osv::DependencyVulnerabilities::new(Capped::new(
1488            vec![advisory("RUSTSEC-2024-0001", "1.0.2")],
1489            1,
1490        ))
1491        .with_fix_target_status(UpgradeStatus::CandidateClean {
1492            version: ConcreteVersion::new("v1.0.2"),
1493        });
1494        let mut cached = HashMap::new();
1495        cached.insert(
1496            PackageName::new("serde"),
1497            PackageVersions::new("v1.0.2".into(), std::sync::Arc::from([])).with_yanked(
1498                std::sync::Arc::from([("v1.0.2".into(), RemovalStatus::from_yanked(true))]),
1499            ),
1500        );
1501        let analysis = test_analysis(cached, HashSet::new());
1502        let formatter = TestFormatter {
1503            requirement_already_admits_fix: false,
1504            osv_native_differs: true,
1505        };
1506        let item = classify_vulnerable_dependency(
1507            &dep,
1508            &dv,
1509            None,
1510            "serde",
1511            &analysis,
1512            &formatter,
1513            EcosystemId::Cargo,
1514            &IgnoreRules::empty(),
1515        );
1516        assert!(
1517            matches!(
1518                item.outcome,
1519                Outcome::Unfixable(UnfixableReason::Yanked { .. })
1520            ),
1521            "the yanked native-form entry (v1.0.2) must match the converted native-form fix \
1522             target, not the raw OSV wire form (1.0.2), which was never in the yanked list"
1523        );
1524    }
1525
1526    /// FR-013: an ecosystem that never reports yank status (`yanked` structurally empty) must
1527    /// not be converted to `Unfixable` solely for that reason — the filter is inert, not a
1528    /// failure.
1529    #[test]
1530    fn test_classify_empty_yanked_list_is_fail_open() {
1531        let dep = dep("serde", "0.9");
1532        let dv = verified_dv("1.0.2");
1533        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1534        let formatter = TestFormatter {
1535            requirement_already_admits_fix: false,
1536            osv_native_differs: false,
1537        };
1538        let item = classify_vulnerable_dependency(
1539            &dep,
1540            &dv,
1541            None,
1542            "serde",
1543            &analysis,
1544            &formatter,
1545            EcosystemId::Cargo,
1546            &IgnoreRules::empty(),
1547        );
1548        assert!(matches!(item.outcome, Outcome::Applied { .. }));
1549    }
1550
1551    /// FR-008: a matching `[update].ignore` rule is reported as overridden, never suppressed.
1552    #[test]
1553    fn test_classify_ignore_rule_is_overridden_not_suppressed() {
1554        let dep = dep("serde", "0.9");
1555        let dv = verified_dv("1.0.2");
1556        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1557        let formatter = TestFormatter {
1558            requirement_already_admits_fix: false,
1559            osv_native_differs: false,
1560        };
1561        let ignore_rules = IgnoreRules::new(
1562            vec![IgnoreRule {
1563                name: "serde".to_string(),
1564                update_types: None,
1565            }],
1566            &formatter,
1567        );
1568        let item = classify_vulnerable_dependency(
1569            &dep,
1570            &dv,
1571            None,
1572            "serde",
1573            &analysis,
1574            &formatter,
1575            EcosystemId::Cargo,
1576            &ignore_rules,
1577        );
1578        assert!(matches!(item.outcome, Outcome::Applied { .. }));
1579        assert!(
1580            item.ignore_rule_overridden,
1581            "a matching rule must be reported as overridden, not silently applied"
1582        );
1583    }
1584
1585    /// M8: `current` must be the resolved in-use version, not the bare declared requirement
1586    /// text — a security report must show which version is actually vulnerable.
1587    #[test]
1588    fn test_classify_current_falls_back_to_declared_requirement_when_unresolvable() {
1589        let dep = dep("serde", "0.9");
1590        let dv = verified_dv("1.0.2");
1591        let analysis = test_analysis(cached_with("serde", "1.0.2"), HashSet::new());
1592        let formatter = TestFormatter {
1593            requirement_already_admits_fix: false,
1594            osv_native_differs: false,
1595        };
1596        let item = classify_vulnerable_dependency(
1597            &dep,
1598            &dv,
1599            None,
1600            "serde",
1601            &analysis,
1602            &formatter,
1603            EcosystemId::Cargo,
1604            &IgnoreRules::empty(),
1605        );
1606        // No lockfile-resolved version in `analysis.resolved_versions` and Cargo's bare "0.9"
1607        // is a caret range (not a pin), so `resolve_in_use_version` returns `None` and this
1608        // falls back to the declared requirement text.
1609        assert_eq!(
1610            item.current,
1611            CurrentVersion::Declared(VersionReq::new("0.9"))
1612        );
1613    }
1614
1615    /// Issue #1578 gap 1: the mock-only `NoOpRewrite`/`UnsupportedRequirementShape` regression
1616    /// (`test_classify_unsupported_requirement_shape_is_unfixable_not_requires_lockfile_update`)
1617    /// proves the *decision logic*, but not that Cargo's real `semver`-backed matcher actually
1618    /// returns `Some(false)` (not `None`/`Some(true)`) for this exact compound-requirement/fix
1619    /// pair. This exercises the real `deps_engine::setup::CargoFormatter` (Cargo's own
1620    /// `deps_cargo::CargoFormatter`, reached through deps-engine's ecosystem registry rather
1621    /// than linking deps-cargo directly) end to end: its own
1622    /// `format_version_replacing` echoes a compound requirement back unchanged (reaching
1623    /// `NoOpRewrite`), and its `compile_bounded_requirement` (real `semver::VersionReq`) must confirm
1624    /// `1.5.2` is excluded by `">=1.2, <1.5"`.
1625    #[test]
1626    fn test_classify_real_cargo_formatter_compound_requirement_confirms_exclusion() {
1627        let dep = dep("foo", ">=1.2, <1.5");
1628        let dv = verified_dv("1.5.2");
1629        let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1630        let item = classify_vulnerable_dependency(
1631            &dep,
1632            &dv,
1633            None,
1634            "foo",
1635            &analysis,
1636            &deps_engine::setup::CargoFormatter,
1637            EcosystemId::Cargo,
1638            &IgnoreRules::empty(),
1639        );
1640        assert!(
1641            matches!(
1642                item.outcome,
1643                Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
1644            ),
1645            "real Cargo semver matcher must confirm 1.5.2 is excluded by \">=1.2, <1.5\", got {:?}",
1646            item.outcome
1647        );
1648    }
1649
1650    /// Issue #1578 gap 2: an oversized requirement must be rejected as `Unfixable` before ever
1651    /// reaching `compile_bounded_requirement` — `PanicsIfCompiledFormatter` panics if that call is
1652    /// made, so this fails loudly (not just with the wrong outcome) if the gate is removed or
1653    /// reordered after the `compile_bounded_requirement` call.
1654    #[test]
1655    fn test_classify_oversized_requirement_never_reaches_compile_requirement() {
1656        let oversized_req = "1".repeat(MAX_REQUIREMENT_LEN + 1);
1657        let dep = dep("foo", &oversized_req);
1658        let dv = verified_dv("1.5.2");
1659        let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1660        let item = classify_vulnerable_dependency(
1661            &dep,
1662            &dv,
1663            None,
1664            "foo",
1665            &analysis,
1666            &PanicsIfCompiledFormatter,
1667            EcosystemId::Cargo,
1668            &IgnoreRules::empty(),
1669        );
1670        // #1578 S1: `OversizedRequirement`, not `UnsupportedRequirementShape` — the matcher
1671        // (which would have panicked) never ran, so this is a size-based fail-closed guard, not
1672        // a confirmed exclusion.
1673        assert!(
1674            matches!(
1675                item.outcome,
1676                Outcome::Unfixable(UnfixableReason::OversizedRequirement { .. })
1677            ),
1678            "got {:?}",
1679            item.outcome
1680        );
1681        assert_eq!(
1682            item.target(),
1683            Some(&deps_core::ConcreteVersion::from("1.5.2")),
1684            "an oversized-requirement-unfixable item must report the rejected fix target (#1614)"
1685        );
1686    }
1687
1688    /// #1578 M2: exactly at `MAX_REQUIREMENT_LEN` must NOT be treated as oversized
1689    /// (`requirement_is_oversized` uses strict `>`) — falls through to the real matcher, which
1690    /// confirms the compound requirement excludes the fix (`UnsupportedRequirementShape`, not
1691    /// `OversizedRequirement`).
1692    #[test]
1693    fn test_classify_requirement_at_exact_cap_is_not_oversized() {
1694        let head = ">=1.2,";
1695        let tail = " <1.5";
1696        let padding = " ".repeat(MAX_REQUIREMENT_LEN - head.len() - tail.len());
1697        let requirement = format!("{head}{padding}{tail}");
1698        assert_eq!(requirement.len(), MAX_REQUIREMENT_LEN);
1699
1700        let dep = dep("foo", &requirement);
1701        let dv = verified_dv("1.5.2");
1702        let analysis = test_analysis(cached_with("foo", "1.5.2"), HashSet::new());
1703        let item = classify_vulnerable_dependency(
1704            &dep,
1705            &dv,
1706            None,
1707            "foo",
1708            &analysis,
1709            &deps_engine::setup::CargoFormatter,
1710            EcosystemId::Cargo,
1711            &IgnoreRules::empty(),
1712        );
1713        assert!(
1714            matches!(
1715                item.outcome,
1716                Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. })
1717            ),
1718            "expected the matcher to run at the exact cap and confirm exclusion, got {:?}",
1719            item.outcome
1720        );
1721    }
1722}