Skip to main content

deps_engine/classify/
osv.rs

1//! Vulnerability-scan target construction and fix-target-verification decision logic.
2//!
3//! Pure classification helpers extracted from `deps-lsp`'s `document/osv_scan.rs`: deciding
4//! which dependencies to scan and how to resolve a recommended fix target's verification
5//! status. The orchestration around these decisions — the phase-A/await/phase-B-commit shape,
6//! the mid-flight staleness guard, and the `OsvClient` network calls themselves — stays in
7//! `deps-lsp`'s `run_osv_scan_phase_a`/`run_osv_phase_b_and_commit`/
8//! `run_osv_fix_target_verification`, since it owns a progress/staleness lifecycle this crate
9//! must not know about (issue #1059).
10
11use deps_core::ConcreteVersion;
12use deps_core::EcosystemId;
13use deps_core::PackageName;
14use deps_core::lsp_helpers::{
15    OsvNameAvailability, has_unqueryable_resolved_pin, resolve_in_use_versions,
16};
17use std::collections::HashMap;
18
19/// Builds the OSV scan targets for one manifest's dependencies, applying the
20/// version-selection policy from `architecture.md` §3 in order:
21///
22/// 0. Skip unless `formatter.source_is_public_registry_content(&dep.source())` — a patched
23///    git/path fork must never be flagged with a CVE for a version it does
24///    not actually contain, and neither must a genuinely different private registry's
25///    dependency (only a verified crates.io mirror counts as public-registry content,
26///    F1/F1b).
27/// 1. Use the lock-file-resolved version if present.
28/// 2. Otherwise use the declared requirement, if it is already concrete.
29/// 3. Otherwise skip — querying a fabricated version is a silent false
30///    negative, which is worse than not scanning at all.
31///
32/// **Go exception** (#228 follow-up, unified with #235's
33/// [`deps_core::lsp_helpers::RequirementResolution::manifest_requirement_is_resolved_version`]):
34/// step 1 is skipped entirely for a dependency whose manifest requirement is
35/// itself the resolved version (a Go `require`-directive dependency), going
36/// straight to step 2. Go's `go.mod` `require` line is already an exact
37/// pinned version, never a range, unlike Cargo/npm where the manifest is a
38/// range and the lockfile holds the pin. go.sum-derived `resolved_versions`
39/// is unreliable here: go.sum is a checksum ledger that `go get`/`go build`
40/// only ever append to (only `go mod tidy` prunes it), so its
41/// last-occurrence-wins parse can surface a version still recorded in the
42/// file but no longer selected by Go's MVS — silently querying OSV against
43/// the wrong version. Routing through the formatter hook (rather than a bare
44/// `ecosystem == EcosystemId::Go` check) also excludes Go's `exclude`/
45/// `replace` directive pseudo-dependencies, whose `version_requirement()` is
46/// not an in-use version.
47///
48/// Every dependency that does **not** become a [`deps_core::osv::ScanTarget`]
49/// gets an explicit [`deps_core::osv::ScanOutcome::Skipped`] entry in the
50/// returned map instead of silently vanishing (critique C1) — absence from
51/// [`deps_core::osv::VulnerabilityMap`] must never happen for an input this
52/// function considered.
53///
54/// Each dependency's map/target key comes from
55/// [`deps_core::osv::vulnerability_keys`] rather than a bare
56/// `formatter.normalize_package_name(dep.name())` (#394 S2): when two
57/// occurrences of one name resolve to different in-use versions (or mix a
58/// registry source with a git/path fork), their keys are disambiguated so
59/// one occurrence's OSV result never overwrites another's in the shared
60/// [`deps_core::osv::VulnerabilityMap`]. Occurrences that share both a name
61/// and an identical in-use version keep the plain key and are scanned once —
62/// a dedup, not a gap, since the OSV result would be identical either way.
63///
64/// # Examples
65///
66/// ```
67/// use deps_core::lsp_helpers::{
68///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
69///     RequirementResolution, SourcePolicy,
70/// };
71/// use deps_core::test_util::stub_parse_result_with_dependencies;
72/// use deps_core::{ConcreteVersion, EcosystemId, PackageName};
73/// use deps_engine::classify::osv::build_scan_targets;
74/// use std::collections::HashMap;
75///
76/// struct SimpleFormatter;
77/// impl PackageNaming for SimpleFormatter {}
78/// impl PackageRendering for SimpleFormatter {
79///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
80///         version.to_string()
81///     }
82///     fn package_url(&self, name: &PackageName) -> String {
83///         name.as_str().to_string()
84///     }
85/// }
86/// impl RequirementResolution for SimpleFormatter {}
87/// impl DiagnosticMessages for SimpleFormatter {}
88/// impl DiagnosticPolicy for SimpleFormatter {}
89/// impl SourcePolicy for SimpleFormatter {}
90/// impl OsvNaming for SimpleFormatter {}
91///
92/// // A single registry-sourced dependency ("dep-0"), with a lock-file-resolved version —
93/// // step 1 of the ladder above.
94/// let parsed = stub_parse_result_with_dependencies(1);
95/// let mut resolved_versions = HashMap::new();
96/// resolved_versions.insert(PackageName::new("dep-0"), ConcreteVersion::from("1.0.0"));
97///
98/// let (targets, skipped) = build_scan_targets(
99///     parsed.as_ref(),
100///     &resolved_versions,
101///     &HashMap::new(),
102///     &SimpleFormatter,
103///     EcosystemId::Cargo,
104/// );
105///
106/// assert_eq!(targets.len(), 1);
107/// assert!(skipped.is_empty(), "the one dependency became a scan target, nothing to skip");
108/// ```
109pub fn build_scan_targets(
110    parse_result: &dyn deps_core::ParseResult,
111    resolved_versions: &HashMap<PackageName, ConcreteVersion>,
112    resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
113    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
114    ecosystem: EcosystemId,
115) -> (
116    Vec<deps_core::osv::ScanTarget>,
117    deps_core::osv::VulnerabilityMap,
118) {
119    use deps_core::osv::{OsvQueryName, ScanOutcome, SkipReason};
120    use std::collections::hash_map::Entry;
121
122    let mut targets: Vec<deps_core::osv::ScanTarget> = Vec::new();
123    let mut target_index: HashMap<deps_core::osv::VulnKey, usize> = HashMap::new();
124    let mut skipped = deps_core::osv::VulnerabilityMap::new();
125    let keys = deps_core::osv::vulnerability_keys(
126        parse_result,
127        resolved_versions,
128        Some(resolved_version_candidates),
129        formatter,
130        ecosystem,
131    );
132
133    for dep in parse_result.dependencies() {
134        let normalized_name = formatter.normalize_package_name(dep.name());
135        let key = deps_core::osv::vuln_key_for(dep, Some(&keys), formatter);
136
137        if !formatter.source_is_public_registry_content(&dep.source()) {
138            skipped.insert(key, ScanOutcome::Skipped(SkipReason::NonRegistrySource));
139            continue;
140        }
141
142        // go.mod's `require` line, not go.sum, is authoritative for Go: go.sum is an
143        // append-only ledger (only `go mod tidy` prunes it), so its last-occurrence-wins
144        // parse can yield a stale, no-longer-selected version (see
145        // `manifest_requirement_is_resolved_version`).
146        let versions = resolve_in_use_versions(
147            dep,
148            &normalized_name,
149            resolved_versions,
150            Some(resolved_version_candidates),
151            formatter,
152            ecosystem,
153        );
154
155        let Some(versions) = versions else {
156            let reason = if has_unqueryable_resolved_pin(dep, formatter, ecosystem) {
157                SkipReason::ResolvedTagNotFullVersion
158            } else {
159                SkipReason::NoConcreteVersion
160            };
161            skipped.insert(key, ScanOutcome::Skipped(reason));
162            continue;
163        };
164
165        let osv_name = match formatter.osv_name_availability(dep) {
166            OsvNameAvailability::Ready => match formatter.osv_package_name(dep) {
167                Some(name) => OsvQueryName::Confirmed(name),
168                None => {
169                    skipped.insert(key, ScanOutcome::Skipped(SkipReason::UnmappableName));
170                    continue;
171                }
172            },
173            OsvNameAvailability::AwaitingRegistryData {
174                written_fallback: Some(name),
175            } => OsvQueryName::Provisional(name),
176            OsvNameAvailability::AwaitingRegistryData {
177                written_fallback: None,
178            } => {
179                skipped.insert(
180                    key,
181                    ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed),
182                );
183                continue;
184            }
185        };
186
187        let target = deps_core::osv::ScanTarget::from_native(
188            key,
189            osv_name,
190            versions.primary().clone(),
191            formatter,
192        )
193        .with_siblings(&versions, formatter);
194        match target_index.entry(target.key.clone()) {
195            Entry::Vacant(slot) => {
196                slot.insert(targets.len());
197                targets.push(target);
198            }
199            Entry::Occupied(slot) => {
200                if let Some(existing) = targets.get_mut(*slot.get())
201                    && matches!(existing.osv_name, OsvQueryName::Provisional(_))
202                    && matches!(target.osv_name, OsvQueryName::Confirmed(_))
203                {
204                    *existing = target;
205                }
206            }
207        }
208    }
209
210    (targets, skipped)
211}
212/// Outcome of classifying one dependency for [`build_latest_check_targets`]/
213/// [`build_candidate_check_targets`]'s shared registry-source/cached-version/OSV-name gates
214/// (code-review finding: the two functions used to duplicate this exact branch sequence, which
215/// this PR's `StructuralSkipReason` retyping had to edit in lockstep in both copies).
216enum DepCheckClassification<'a> {
217    /// `formatter.source_is_public_registry_content` returned `false`.
218    NonRegistrySource,
219    /// No registry-cached version list yet for this dependency — absence, not a structural
220    /// skip: a later commit populating `cached_versions` can still turn this into a real
221    /// target (see both callers' own doc for why this must not be read as "not applicable").
222    NoCachedVersions,
223    /// A cached version list exists, but the OSV name still depends on registry data that has
224    /// not landed (`OsvNameAvailability::AwaitingRegistryData`) — transient, never structural.
225    AwaitingOsvName {
226        /// This dependency's registry-cached version list.
227        cached: &'a deps_core::lsp_helpers::PackageVersions,
228    },
229    /// A cached version list exists, but `formatter.osv_package_name` returned `None`.
230    UnmappableName {
231        /// This dependency's registry-cached version list, for a caller that still wants to
232        /// report a real (if unmappable) candidate version.
233        cached: &'a deps_core::lsp_helpers::PackageVersions,
234    },
235    /// A real, checkable target: the resolved OSV package name and this dependency's
236    /// registry-cached version list.
237    Target {
238        /// `formatter.osv_package_name(dep)`'s resolved value.
239        osv_name: deps_core::osv::OsvPackageName,
240        /// This dependency's registry-cached version list.
241        cached: &'a deps_core::lsp_helpers::PackageVersions,
242    },
243}
244
245/// The shared classification steps behind [`DepCheckClassification`]'s variants — see that
246/// type's doc.
247fn classify_dep_for_check_targets<'a>(
248    dep: &dyn deps_core::Dependency,
249    cached_versions: &'a HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
250    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
251) -> DepCheckClassification<'a> {
252    if !formatter.source_is_public_registry_content(&dep.source()) {
253        return DepCheckClassification::NonRegistrySource;
254    }
255
256    let normalized_name = formatter.normalize_package_name(dep.name());
257    let Some(cached) = cached_versions
258        .get(normalized_name.as_str())
259        .or_else(|| cached_versions.get(dep.name()))
260    else {
261        return DepCheckClassification::NoCachedVersions;
262    };
263
264    if matches!(
265        formatter.osv_name_availability(dep),
266        OsvNameAvailability::AwaitingRegistryData { .. }
267    ) {
268        return DepCheckClassification::AwaitingOsvName { cached };
269    }
270
271    match formatter.osv_package_name(dep) {
272        Some(osv_name) => DepCheckClassification::Target { osv_name, cached },
273        None => DepCheckClassification::UnmappableName { cached },
274    }
275}
276
277/// Builds phase B.1's latest-check targets for **every** dependency with a registry-cached
278/// latest (issue #1517).
279///
280/// Not just ones phase A already flagged [`deps_core::osv::ScanOutcome::Vulnerable`] at their
281/// pinned version — closes the gap that let a cleanly-pinned dependency's malicious/vulnerable
282/// `latest` go completely unchecked while every renderer (hover, diagnostics, code actions,
283/// code lens, inlay hints, completion) and `deps-cli update`'s default mode still recommended
284/// it as a safe upgrade.
285///
286/// `osv_name` comes from `formatter.osv_package_name(dep)` directly, not from phase A's
287/// `osv_name_by_key` — that map only has entries for dependencies phase A actually built a
288/// [`deps_core::osv::ScanTarget`] for, which excludes any dependency phase A skipped for
289/// [`deps_core::osv::SkipReason::NoConcreteVersion`] (e.g. a `^1.0.4` requirement with no
290/// committed lock file) — such a dependency still has a resolvable registry `latest` and must
291/// still have it checked.
292///
293/// Every dependency considered gets either a [`deps_core::osv::ScanTarget`] in the returned
294/// `Vec` or an explicit structural entry in the returned [`deps_core::osv::LatestStatusMap`] —
295/// never silently neither, mirroring [`build_scan_targets`]'s own invariant 0 discipline
296/// (absence must never be read as "clean" or "not applicable"). A dependency with no
297/// registry-cached `latest` at all (the fetch hasn't completed, or the registry doesn't know
298/// the package) gets neither: it isn't a structural gap, since a later commit populating
299/// `cached_versions` can turn it into a real target — the map's absence there falls through to
300/// [`deps_core::lsp_helpers::LatestVerdict::Unverified`] (fail-closed), not
301/// [`deps_core::lsp_helpers::LatestVerdict::NotApplicable`].
302///
303/// # Examples
304///
305/// ```
306/// use deps_core::lsp_helpers::{
307///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
308///     PackageVersions, RequirementResolution, SourcePolicy,
309/// };
310/// use deps_core::osv::{UpgradeStatus, vulnerability_keys};
311/// use deps_core::test_util::stub_parse_result_with_dependencies;
312/// use deps_core::{ConcreteVersion, EcosystemId, PackageName};
313/// use deps_engine::classify::osv::build_latest_check_targets;
314/// use std::collections::HashMap;
315///
316/// struct SimpleFormatter;
317/// impl PackageNaming for SimpleFormatter {}
318/// impl PackageRendering for SimpleFormatter {
319///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
320///         version.to_string()
321///     }
322///     fn package_url(&self, name: &PackageName) -> String {
323///         name.as_str().to_string()
324///     }
325/// }
326/// impl RequirementResolution for SimpleFormatter {}
327/// impl DiagnosticMessages for SimpleFormatter {}
328/// impl DiagnosticPolicy for SimpleFormatter {}
329/// impl SourcePolicy for SimpleFormatter {}
330/// impl OsvNaming for SimpleFormatter {}
331///
332/// let parsed = stub_parse_result_with_dependencies(1);
333/// let mut cached_versions = HashMap::new();
334/// cached_versions.insert(
335///     PackageName::new("dep-0"),
336///     PackageVersions::latest_only("2.0.0"),
337/// );
338/// let vuln_keys = vulnerability_keys(
339///     parsed.as_ref(),
340///     &HashMap::new(),
341///     None,
342///     &SimpleFormatter,
343///     EcosystemId::Cargo,
344/// );
345///
346/// let (targets, structural) = build_latest_check_targets(
347///     parsed.as_ref(),
348///     &cached_versions,
349///     &vuln_keys,
350///     &SimpleFormatter,
351/// );
352///
353/// assert_eq!(targets.len(), 1);
354/// assert_eq!(targets[0].display_version, "2.0.0");
355/// assert!(structural.is_empty(), "the one dependency became a real target, nothing structural");
356/// ```
357pub fn build_latest_check_targets(
358    parse_result: &dyn deps_core::ParseResult,
359    cached_versions: &HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
360    vuln_keys: &deps_core::osv::VulnKeys,
361    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
362) -> (
363    Vec<deps_core::osv::ScanTarget>,
364    deps_core::osv::LatestStatusMap,
365) {
366    use deps_core::osv::{SkipReason, StructuralSkipReason, UpgradeStatus, vuln_key_for};
367
368    // TODO(#1727): candidate-side checks do not evaluate sibling release tags of the candidate.
369    let mut targets = Vec::new();
370    let mut structural = deps_core::osv::LatestStatusMap::new();
371    let mut seen = std::collections::HashSet::new();
372
373    for dep in parse_result.dependencies() {
374        let key = vuln_key_for(dep, Some(vuln_keys), formatter);
375
376        match classify_dep_for_check_targets(dep, cached_versions, formatter) {
377            DepCheckClassification::NonRegistrySource => {
378                structural.insert(
379                    key,
380                    UpgradeStatus::StructurallyUnchecked(StructuralSkipReason::NonRegistrySource),
381                );
382            }
383            // No registry-cached latest yet — absence, not a structural skip (see doc above).
384            DepCheckClassification::NoCachedVersions => {}
385            DepCheckClassification::AwaitingOsvName { cached } => {
386                structural.insert(
387                    key,
388                    UpgradeStatus::CandidateUnverified {
389                        version: cached.latest.clone(),
390                        reason: SkipReason::CanonicalNameUnconfirmed,
391                    },
392                );
393            }
394            DepCheckClassification::UnmappableName { cached } => {
395                structural.insert(
396                    key,
397                    UpgradeStatus::CandidateUnverified {
398                        version: cached.latest.clone(),
399                        reason: SkipReason::UnmappableName,
400                    },
401                );
402            }
403            DepCheckClassification::Target { osv_name, cached } => {
404                // Occurrences sharing a key share one result (see `build_scan_targets`).
405                if !seen.insert(key.clone()) {
406                    continue;
407                }
408                targets.push(deps_core::osv::ScanTarget::from_native(
409                    key,
410                    deps_core::osv::OsvQueryName::Confirmed(osv_name),
411                    cached.latest.clone(),
412                    formatter,
413                ));
414            }
415        }
416    }
417
418    (targets, structural)
419}
420
421/// Bounds how many candidate versions per dependency [`build_candidate_check_targets`] checks
422/// (#1524) — code actions/completion display at most a handful of "update to X" items per
423/// dependency, so checking a small superset of that is enough to cover what a
424/// candidate-offering surface could actually display, without unbounded OSV traffic for a
425/// dependency with hundreds of published versions.
426const MAX_CANDIDATE_CHECK_VERSIONS: usize = 6;
427
428/// Builds phase B's candidate-check targets for #1524.
429///
430/// Organized into up to `MAX_CANDIDATE_CHECK_VERSIONS` "rounds": round `r`'s
431/// `Vec<ScanTarget>` holds, for every dependency that has one, its `r`-th newest non-yanked
432/// registry version (rank 0 = newest). Callers batch-check one round at a time via
433/// [`deps_core::osv::OsvClient::check_candidates`]
434/// — at most one target per [`deps_core::osv::VulnKey`] per round, so a single call never
435/// collapses two of one dependency's own candidates into one result — and merge each round's
436/// [`deps_core::osv::LatestStatusMap`]-shaped result into a
437/// [`deps_core::osv::CandidateStatusMap`] keyed by the version each round actually checked.
438///
439/// Shares [`build_latest_check_targets`]'s structural-skip classification via
440/// `classify_dep_for_check_targets` (non-public-registry source, unmappable OSV name) — recorded
441/// once per dependency in the returned `structural` map as
442/// [`deps_core::osv::CandidateStatuses::Structural`] (see that type's doc), never duplicated per
443/// round.
444///
445/// Selection is deliberately simpler than
446/// [`deps_core::completion::prepare_version_display_items`]'s exact display-item algorithm
447/// (which needs the full `dyn Version` registry response this background task never has
448/// cached, only the bare [`ConcreteVersion`] list [`deps_core::lsp_helpers::PackageVersions`]
449/// carries): the newest `MAX_CANDIDATE_CHECK_VERSIONS` non-yanked entries from
450/// [`deps_core::lsp_helpers::PackageVersions::available`], newest-first. A display item this
451/// selection doesn't happen to cover (rare: `prepare_version_display_items`'s own "bump the
452/// latest pick in" `#956` behavior) simply reads as
453/// [`deps_core::lsp_helpers::LatestVerdict::Unverified`] and is excluded — over-conservative,
454/// never under-conservative.
455pub fn build_candidate_check_targets(
456    parse_result: &dyn deps_core::ParseResult,
457    cached_versions: &HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
458    vuln_keys: &deps_core::osv::VulnKeys,
459    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
460) -> (
461    Vec<Vec<deps_core::osv::ScanTarget>>,
462    deps_core::osv::CandidateStatusMap,
463) {
464    use deps_core::osv::{CandidateStatuses, StructuralSkipReason, vuln_key_for};
465
466    let mut rounds: Vec<Vec<deps_core::osv::ScanTarget>> =
467        vec![Vec::new(); MAX_CANDIDATE_CHECK_VERSIONS];
468    let mut structural = deps_core::osv::CandidateStatusMap::new();
469    let mut seen = std::collections::HashSet::new();
470
471    for dep in parse_result.dependencies() {
472        let key = vuln_key_for(dep, Some(vuln_keys), formatter);
473
474        let (osv_name, package_versions) =
475            match classify_dep_for_check_targets(dep, cached_versions, formatter) {
476                DepCheckClassification::NonRegistrySource => {
477                    structural.insert(
478                        key,
479                        CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
480                    );
481                    continue;
482                }
483                // No registry-cached version list yet — absence, not a structural skip (see
484                // doc above), matching `build_latest_check_targets`'s identical treatment.
485                DepCheckClassification::NoCachedVersions => continue,
486                // Transient, like an absent cache: never recorded as structural.
487                DepCheckClassification::AwaitingOsvName { .. } => continue,
488                DepCheckClassification::UnmappableName { .. } => {
489                    structural.insert(
490                        key,
491                        CandidateStatuses::Structural(StructuralSkipReason::UnmappableName),
492                    );
493                    continue;
494                }
495                DepCheckClassification::Target { osv_name, cached } => (osv_name, cached),
496            };
497
498        if !seen.insert(key.clone()) {
499            continue;
500        }
501
502        let yanked: std::collections::HashSet<&ConcreteVersion> = package_versions
503            .yanked
504            .iter()
505            .map(|(version, _)| version)
506            .collect();
507
508        for (rank, version) in package_versions
509            .available
510            .iter()
511            .filter(|v| !yanked.contains(v))
512            .take(MAX_CANDIDATE_CHECK_VERSIONS)
513            .enumerate()
514        {
515            // `rank` is in `0..MAX_CANDIDATE_CHECK_VERSIONS` by construction (bounded by the
516            // `take` above), matching `rounds`' own length — but a bare index would still
517            // panic if that invariant were ever broken, so this fails closed instead.
518            let Some(bucket) = rounds.get_mut(rank) else {
519                continue;
520            };
521            bucket.push(deps_core::osv::ScanTarget::from_native(
522                key.clone(),
523                deps_core::osv::OsvQueryName::Confirmed(osv_name.clone()),
524                version.clone(),
525                formatter,
526            ));
527        }
528    }
529
530    (rounds, structural)
531}
532
533/// Outcome of `resolve_fix_target` for one vulnerable dependency.
534#[derive(Debug, PartialEq, Eq)]
535enum FixTargetResolution {
536    /// No fix recommended, F failed [`deps_core::lsp_helpers::is_safe_version_string`], or no
537    /// `osv_name` is on record for this key, or that name is only provisional (#1694) — nothing to
538    /// verify or record; `fix_target_status`
539    /// stays untouched (left at `NotChecked`).
540    Skip,
541    /// F's status was resolved without a network call by reusing the already-checked
542    /// "latest" candidate's result (FR-002, F == latest).
543    Resolved(deps_core::osv::UpgradeStatus),
544    /// F differs from latest and needs a live [`deps_core::osv::OsvClient::check_candidates`]
545    /// check — carries the [`deps_core::osv::ScanTarget`] to batch into the caller's single
546    /// combined call (NFR-001). Keyed with the dependency's plain [`deps_core::osv::VulnKey`]:
547    /// this candidate is always checked via a *separate* `check_candidates` call from the "B.1
548    /// latest" candidates, so its result `HashMap` never shares a key space with the phase-A
549    /// `VulnerabilityMap` — no suffix is needed to disambiguate.
550    NeedsLiveCheck(deps_core::osv::ScanTarget),
551}
552/// Pure (network-free) decision logic for `run_osv_fix_target_verification`'s per-dependency
553/// resolution order — see that function's doc for the two cases and their rationale. Split
554/// out so each case is unit-testable without an `OsvClient`/network dependency.
555fn resolve_fix_target(
556    dv: &deps_core::osv::DependencyVulnerabilities,
557    key: &deps_core::osv::VulnKey,
558    latest_status: &deps_core::osv::LatestStatusMap,
559    osv_name_by_key: &HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName>,
560    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
561) -> FixTargetResolution {
562    use deps_core::edit::{VulnFixSkip, resolve_recommended_fix};
563    use deps_core::osv::{OsvQueryName, ScanTarget, UpgradeStatus};
564
565    let latest = latest_status.get(key);
566
567    // #1350: `resolve_recommended_fix` is the shared prefix (`recommended_fix` ->
568    // `osv_version_to_native` -> `is_safe_version_string`) this function used to duplicate.
569    // Deliberately the *unverified* helper, not `resolve_verified_fix`: this function is
570    // itself the producer of `dv.fix_target_status`, so it must not gate on a status it
571    // has not computed yet.
572    let (fix, version_native) = match resolve_recommended_fix(dv, latest, formatter) {
573        Ok(pair) => pair,
574        Err(VulnFixSkip::NoRecommendedFix) => return FixTargetResolution::Skip,
575        // `resolve_recommended_fix` already emits a WARN via `warn_rejected_value` for this
576        // case (M1: consistent with every other unsafe-value rejection gate in this codebase,
577        // not the accidental DEBUG this call site used before #1350) — this DEBUG line adds
578        // only the batch-scan `key` that generic warning doesn't carry.
579        Err(VulnFixSkip::UnsafeVersion) => {
580            tracing::debug!(
581                key = %key,
582                "OSV #462: fix-target version failed validation, skipping verification"
583            );
584            return FixTargetResolution::Skip;
585        }
586        // `resolve_recommended_fix` can only ever return `NoRecommendedFix`/`UnsafeVersion` —
587        // these five variants exist only for `plan_vulnerability_fix`'s later,
588        // `resolve_verified_fix`-based decision. Handled explicitly rather than folded into a
589        // wildcard (code review finding) so a future `VulnFixSkip` variant, or a change that
590        // starts surfacing one of these here, is a compile error instead of silently
591        // degrading to `Skip` — the same bug class `EcosystemId`'s exhaustive-match
592        // convention exists to catch project-wide.
593        Err(
594            VulnFixSkip::UnverifiedTarget
595            | VulnFixSkip::RequirementAlreadyResolves
596            | VulnFixSkip::NoOpRewrite
597            | VulnFixSkip::UnresolvedPlaceholder
598            | VulnFixSkip::OversizedRequirement,
599        ) => return FixTargetResolution::Skip,
600    };
601
602    // Issue #1517: only a *resolved* latest verdict (`CandidateClean`/`CandidateVulnerable`)
603    // can be reused — `NotChecked`/`CandidateUnverified` (transient failure, structural skip,
604    // or simply never checked) must always fall through to a live check below, never be
605    // silently treated as "F already covered by the latest check".
606    let reused_latest_version: Option<&str> = match latest {
607        Some(
608            UpgradeStatus::CandidateClean { version }
609            | UpgradeStatus::CandidateVulnerable { version, .. },
610        ) => Some(version.as_str()),
611        // `UpgradeStatus` is `#[non_exhaustive]` across the crate boundary: the wildcard is
612        // required by the compiler, not a stylistic shortcut — every variant defined *today*
613        // (`NotChecked`, `CandidateUnverified`) is still handled by the fall-through-to-`None`
614        // (never-reuse) behavior, matching this project's `EcosystemId`-style exhaustive-match
615        // convention as closely as a foreign `#[non_exhaustive]` type allows.
616        Some(_) | None => None,
617    };
618    if reused_latest_version == Some(version_native.as_str()) {
619        return FixTargetResolution::Resolved(latest.cloned().unwrap_or(UpgradeStatus::NotChecked));
620    }
621
622    let Some(osv_name) = osv_name_by_key.get(key) else {
623        tracing::debug!(
624            key = %key,
625            "OSV #462: no osv_name on record for fix-target verification, skipping"
626        );
627        return FixTargetResolution::Skip;
628    };
629    let osv_name = match osv_name {
630        OsvQueryName::Confirmed(name) => name.clone(),
631        OsvQueryName::Provisional(_) => {
632            tracing::debug!(
633                key = %key,
634                "OSV #1694: unconfirmed package name, fix target stays unverified"
635            );
636            return FixTargetResolution::Skip;
637        }
638    };
639    FixTargetResolution::NeedsLiveCheck(ScanTarget::new(
640        key.clone(),
641        OsvQueryName::Confirmed(osv_name),
642        fix.version,
643        ConcreteVersion::new(version_native),
644    ))
645}
646/// Pure aggregation step of `run_osv_fix_target_verification`: resolves every vulnerable
647/// dependency's fix target via `resolve_fix_target`.
648///
649/// Splits immediately-resolvable results (`resolved`) from the ones that need a live check
650/// (`live_check_candidates`) — the latter collected into one `Vec` across *every* dependency
651/// before the caller's single `check_candidates` call, so multiple dependencies needing a live
652/// check always batch into one network round-trip rather than one per dependency (NFR-001).
653/// Split out from the async orchestrator specifically so this batching/aggregation behavior is
654/// unit-testable without an `OsvClient`.
655///
656/// # Examples
657///
658/// ```
659/// use deps_core::lsp_helpers::{
660///     DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
661///     RequirementResolution, SourcePolicy,
662/// };
663/// use deps_core::osv::{
664///     Advisory, Capped, DependencyVulnerabilities, OsvVersion, ScanOutcome, UpgradeStatus,
665///     VulnSeverity, VulnerabilityMap,
666/// };
667/// use deps_core::test_util::vuln_key;
668/// use deps_core::{ConcreteVersion, PackageName};
669/// use deps_engine::classify::osv::collect_fix_target_resolutions;
670/// use std::collections::HashMap;
671/// use std::sync::Arc;
672///
673/// struct SimpleFormatter;
674/// impl PackageNaming for SimpleFormatter {}
675/// impl PackageRendering for SimpleFormatter {
676///     fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
677///         version.to_string()
678///     }
679///     fn package_url(&self, name: &PackageName) -> String {
680///         name.as_str().to_string()
681///     }
682/// }
683/// impl RequirementResolution for SimpleFormatter {}
684/// impl DiagnosticMessages for SimpleFormatter {}
685/// impl DiagnosticPolicy for SimpleFormatter {}
686/// impl SourcePolicy for SimpleFormatter {}
687/// impl OsvNaming for SimpleFormatter {}
688///
689/// let advisory = Arc::new(
690///     Advisory::new(
691///         "RUSTSEC-2024-0001".to_string(),
692///         "2024-01-01T00:00:00Z".to_string(),
693///         VulnSeverity::High,
694///     )
695///     .expect("valid osv id")
696///     .with_fixed_versions(vec![OsvVersion::new("1.2.0")]),
697/// );
698/// let latest_status = UpgradeStatus::CandidateClean {
699///     version: ConcreteVersion::new("1.2.0"),
700/// };
701/// let dv = DependencyVulnerabilities::new(Capped::new(vec![advisory], 1));
702///
703/// let mut vulnerabilities = VulnerabilityMap::new();
704/// vulnerabilities.insert(vuln_key("pkg"), ScanOutcome::Vulnerable(dv));
705///
706/// let mut latest_status_map = HashMap::new();
707/// latest_status_map.insert(vuln_key("pkg"), latest_status.clone());
708///
709/// // F (the fix, 1.2.0) equals the already-checked "latest" candidate — resolved without a
710/// // live network check.
711/// let (resolved, live_check_candidates) = collect_fix_target_resolutions(
712///     &vulnerabilities,
713///     &[vuln_key("pkg")],
714///     &HashMap::new(),
715///     &latest_status_map,
716///     &SimpleFormatter,
717/// );
718///
719/// assert_eq!(resolved, vec![(vuln_key("pkg"), latest_status)]);
720/// assert!(live_check_candidates.is_empty());
721/// ```
722pub fn collect_fix_target_resolutions(
723    vulnerabilities: &deps_core::osv::VulnerabilityMap,
724    vulnerable_keys: &[deps_core::osv::VulnKey],
725    osv_name_by_key: &HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName>,
726    latest_status: &deps_core::osv::LatestStatusMap,
727    formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
728) -> (
729    Vec<(deps_core::osv::VulnKey, deps_core::osv::UpgradeStatus)>,
730    Vec<deps_core::osv::ScanTarget>,
731) {
732    use deps_core::osv::ScanOutcome;
733
734    let mut resolved = Vec::new();
735    let mut live_check_candidates = Vec::new();
736
737    for key in vulnerable_keys {
738        let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get(key) else {
739            continue;
740        };
741        match resolve_fix_target(dv, key, latest_status, osv_name_by_key, formatter) {
742            FixTargetResolution::Skip => {}
743            FixTargetResolution::Resolved(status) => resolved.push((key.clone(), status)),
744            FixTargetResolution::NeedsLiveCheck(target) => live_check_candidates.push(target),
745        }
746    }
747
748    (resolved, live_check_candidates)
749}
750/// Applies a live [`deps_core::osv::OsvClient::check_candidates`] result back onto the matching
751/// dependency's `fix_target_status`.
752///
753/// Keyed by the same plain [`deps_core::osv::VulnKey`] the vulnerable dependency was scanned
754/// under.
755///
756/// A key absent from `statuses` (timeout, OSV outage, or a chunk `check_candidates` itself
757/// dropped) simply leaves that dependency's `fix_target_status` untouched — still
758/// `NotChecked` if it was never set, which is exactly the fail-closed degradation
759/// FR-004/NFR-002 call for (never a panic, never a fabricated "verified" status).
760///
761/// # Examples
762///
763/// ```
764/// use deps_core::osv::{
765///     Advisory, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
766///     VulnerabilityMap,
767/// };
768/// use deps_core::test_util::vuln_key;
769/// use deps_core::ConcreteVersion;
770/// use deps_engine::classify::osv::apply_live_fix_target_statuses;
771/// use std::collections::HashMap;
772/// use std::sync::Arc;
773///
774/// let advisory = Arc::new(
775///     Advisory::new(
776///         "RUSTSEC-2024-0001".to_string(),
777///         "2024-01-01T00:00:00Z".to_string(),
778///         VulnSeverity::High,
779///     )
780///     .expect("valid osv id"),
781/// );
782/// let dv = DependencyVulnerabilities::new(Capped::new(vec![advisory], 1));
783///
784/// let mut vulnerabilities = VulnerabilityMap::new();
785/// vulnerabilities.insert(vuln_key("pkg"), ScanOutcome::Vulnerable(dv));
786///
787/// let mut statuses = HashMap::new();
788/// statuses.insert(
789///     vuln_key("pkg"),
790///     UpgradeStatus::CandidateClean {
791///         version: ConcreteVersion::new("1.2.0"),
792///     },
793/// );
794///
795/// apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
796///
797/// let ScanOutcome::Vulnerable(dv) = vulnerabilities.get(&vuln_key("pkg")).unwrap() else {
798///     unreachable!()
799/// };
800/// assert_eq!(
801///     dv.fix_target_status,
802///     UpgradeStatus::CandidateClean {
803///         version: ConcreteVersion::new("1.2.0")
804///     }
805/// );
806/// ```
807pub fn apply_live_fix_target_statuses(
808    vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
809    statuses: HashMap<deps_core::osv::VulnKey, deps_core::osv::UpgradeStatus>,
810) {
811    use deps_core::osv::ScanOutcome;
812
813    for (key, status) in statuses {
814        if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(&key) {
815            dv.fix_target_status = status;
816        }
817    }
818}
819
820/// Projects `targets` down to `key -> osv_name`.
821///
822/// A two-line helper centralizing an identical inline `HashMap` build `deps-lsp` used to
823/// duplicate at `document/osv_scan.rs:116` and `deps-cli`'s `--security-only` planner (#1329)
824/// needs too — both consume this instead of re-deriving it from [`build_scan_targets`]'s own
825/// `Vec<deps_core::osv::ScanTarget>` output.
826///
827/// # Examples
828///
829/// ```
830/// use deps_core::ConcreteVersion;
831/// use deps_core::osv::{OsvPackageName, OsvQueryName, OsvVersion, ScanTarget};
832/// use deps_core::test_util::vuln_key;
833/// use deps_engine::classify::osv::osv_name_by_key;
834///
835/// let name = OsvQueryName::Confirmed(OsvPackageName::new("serde").unwrap());
836/// let targets = vec![ScanTarget::new(
837///     vuln_key("serde"),
838///     name.clone(),
839///     OsvVersion::new("1.0.0"),
840///     ConcreteVersion::new("1.0.0"),
841/// )];
842/// let map = osv_name_by_key(&targets);
843/// assert_eq!(map.get(&vuln_key("serde")), Some(&name));
844/// ```
845#[must_use]
846pub fn osv_name_by_key(
847    targets: &[deps_core::osv::ScanTarget],
848) -> HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName> {
849    targets
850        .iter()
851        .map(|t| (t.key.clone(), t.osv_name.clone()))
852        .collect()
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use crate::classify::resolved::collect_in_use_versions;
859    use deps_core::VersionReq;
860    use std::assert_matches;
861
862    mod osv_scan_target_tests {
863        use super::*;
864        use deps_core::Dependency;
865        use deps_core::lsp_helpers::{
866            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
867            RequirementResolution, SourcePolicy,
868        };
869        use deps_core::parser::DependencySource;
870        use deps_core::position::{Position, Range};
871        use deps_core::test_util::StubFormatter;
872        use std::any::Any;
873
874        struct MockDep {
875            name: PackageName,
876            version_req: Option<VersionReq>,
877            source: DependencySource,
878        }
879
880        impl Dependency for MockDep {
881            fn name(&self) -> &PackageName {
882                &self.name
883            }
884            fn name_range(&self) -> Range {
885                // Distinct per instance: `vulnerability_keys` (#394 S2) keys a
886                // `HashMap<Range, String>` by `name_range()` — a fixed range would
887                // make every `MockDep` collide on one map entry.
888                let addr = std::ptr::from_ref(self) as u32;
889                Range::new(Position::new(0, addr), Position::new(0, addr + 1))
890            }
891            fn version_requirement(&self) -> Option<&VersionReq> {
892                self.version_req.as_ref()
893            }
894            fn version_range(&self) -> Option<Range> {
895                None
896            }
897            fn source(&self) -> DependencySource {
898                self.source.clone()
899            }
900            fn as_any(&self) -> &dyn Any {
901                self
902            }
903        }
904
905        struct MockParseResult {
906            deps: Vec<MockDep>,
907        }
908
909        impl deps_core::ParseResult for MockParseResult {
910            fn dependencies(&self) -> Vec<&dyn Dependency> {
911                self.deps.iter().map(|d| d as &dyn Dependency).collect()
912            }
913            fn workspace_root(&self) -> Option<&std::path::Path> {
914                None
915            }
916            fn uri(&self) -> &url::Url {
917                static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
918                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
919            }
920            fn as_any(&self) -> &dyn Any {
921                self
922            }
923        }
924
925        use deps_core::osv::{ScanOutcome, SkipReason};
926
927        // `is_concrete_version`/`concrete_pin_version` unit tests moved to
928        // `deps-core`'s `lsp_helpers::in_use_version` module alongside the
929        // functions themselves (#394).
930
931        #[test]
932        fn build_scan_targets_step0_skips_non_registry_source_even_with_lockfile_version() {
933            // A git/path/patched fork must never be flagged with a CVE for a
934            // version it does not actually contain, even when its lockfile
935            // entry carries a plausible-looking version (critique C2).
936            let parse_result = MockParseResult {
937                deps: vec![MockDep {
938                    name: PackageName::new("time"),
939                    version_req: Some(VersionReq::new("0.1.43")),
940                    source: DependencySource::Git {
941                        url: "https://github.com/example/time".to_string(),
942                        rev: None,
943                    },
944                }],
945            };
946            let mut resolved = HashMap::new();
947            resolved.insert(PackageName::new("time"), "0.1.43".into());
948
949            let (targets, skipped) = build_scan_targets(
950                &parse_result,
951                &resolved,
952                &HashMap::new(),
953                &StubFormatter::DEFAULT,
954                EcosystemId::Cargo,
955            );
956            assert!(targets.is_empty());
957            assert_matches!(
958                skipped.get(&deps_core::test_util::vuln_key("time")),
959                Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
960            );
961        }
962
963        #[test]
964        fn build_scan_targets_step1_prefers_lockfile_resolved_version() {
965            let parse_result = MockParseResult {
966                deps: vec![MockDep {
967                    name: PackageName::new("serde"),
968                    version_req: Some(VersionReq::new("^1.0")),
969                    source: DependencySource::Registry,
970                }],
971            };
972            let mut resolved = HashMap::new();
973            resolved.insert(PackageName::new("serde"), "1.0.195".into());
974
975            let (targets, skipped) = build_scan_targets(
976                &parse_result,
977                &resolved,
978                &HashMap::new(),
979                &StubFormatter::DEFAULT,
980                EcosystemId::Cargo,
981            );
982            assert_eq!(targets.len(), 1);
983            assert_eq!(targets[0].version, "1.0.195");
984            assert!(skipped.is_empty());
985        }
986
987        /// Formatter stub mirroring `GoFormatter`'s override: every
988        /// dependency's manifest requirement is itself the resolved version
989        /// (#235's `manifest_requirement_is_resolved_version` unification).
990        const MOCK_GO_FORMATTER: StubFormatter = StubFormatter::new()
991            .with_package_url_prefix("https://pkg.go.dev/")
992            .with_manifest_requirement_as_resolved_version();
993
994        struct MockVPrefixFormatter;
995        impl PackageNaming for MockVPrefixFormatter {}
996
997        impl PackageRendering for MockVPrefixFormatter {
998            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
999                version.to_string()
1000            }
1001
1002            fn package_url(&self, name: &PackageName) -> String {
1003                format!("https://example.com/{}", name.as_str())
1004            }
1005        }
1006
1007        impl RequirementResolution for MockVPrefixFormatter {}
1008
1009        impl DiagnosticMessages for MockVPrefixFormatter {}
1010
1011        impl DiagnosticPolicy for MockVPrefixFormatter {}
1012
1013        impl SourcePolicy for MockVPrefixFormatter {}
1014
1015        impl OsvNaming for MockVPrefixFormatter {
1016            fn osv_version(&self, version: &ConcreteVersion) -> deps_core::osv::OsvVersion {
1017                let version = version.as_str();
1018                deps_core::osv::OsvVersion::new(version.strip_prefix('v').unwrap_or(version))
1019            }
1020        }
1021
1022        #[test]
1023        fn build_scan_targets_normalizes_version_via_formatter_osv_version_hook() {
1024            // Go's mandatory "v" prefix isn't valid OSV SEMVER (#228) — must route through
1025            // the formatter hook rather than sending the native spelling on the wire.
1026            let parse_result = MockParseResult {
1027                deps: vec![MockDep {
1028                    name: PackageName::new("github.com/gin-gonic/gin"),
1029                    version_req: Some(VersionReq::new("v1.9.0")),
1030                    source: DependencySource::Registry,
1031                }],
1032            };
1033            let mut resolved = HashMap::new();
1034            resolved.insert(
1035                PackageName::new("github.com/gin-gonic/gin"),
1036                "v1.9.0".into(),
1037            );
1038
1039            let (targets, skipped) = build_scan_targets(
1040                &parse_result,
1041                &resolved,
1042                &HashMap::new(),
1043                &MockVPrefixFormatter,
1044                EcosystemId::Go,
1045            );
1046            assert_eq!(targets.len(), 1);
1047            assert_eq!(targets[0].version, "1.9.0");
1048            // display_version keeps the ecosystem-native "v" spelling (S1
1049            // regression guard) — only the wire-format `version` is stripped.
1050            assert_eq!(targets[0].display_version, "v1.9.0");
1051            assert!(skipped.is_empty());
1052        }
1053
1054        #[test]
1055        fn build_scan_targets_leaves_version_unaffected_for_default_identity_formatter() {
1056            // Regression guard: ecosystems that do not override osv_version
1057            // must keep sending the native spelling verbatim (no regression
1058            // from introducing the hook).
1059            let parse_result = MockParseResult {
1060                deps: vec![MockDep {
1061                    name: PackageName::new("serde"),
1062                    version_req: Some(VersionReq::new("^1.0")),
1063                    source: DependencySource::Registry,
1064                }],
1065            };
1066            let mut resolved = HashMap::new();
1067            resolved.insert(PackageName::new("serde"), "1.0.195".into());
1068
1069            let (targets, skipped) = build_scan_targets(
1070                &parse_result,
1071                &resolved,
1072                &HashMap::new(),
1073                &StubFormatter::DEFAULT,
1074                EcosystemId::Cargo,
1075            );
1076            assert_eq!(targets.len(), 1);
1077            assert_eq!(targets[0].version, "1.0.195");
1078            assert_eq!(targets[0].display_version, "1.0.195");
1079            assert!(skipped.is_empty());
1080        }
1081
1082        #[test]
1083        fn build_scan_targets_go_ignores_stale_lockfile_version_uses_go_mod_requirement() {
1084            // go.sum is append-only (only `go mod tidy` prunes it) and sorted ascending by
1085            // semver, so a stale higher version from before a downgrade can sort last and
1086            // win last-occurrence-wins parsing. go.mod's `require` line is already an exact
1087            // pin, so for Go the manifest — not lockfile-derived `resolved_versions` — must
1088            // be authoritative for OSV scanning.
1089            let parse_result = MockParseResult {
1090                deps: vec![MockDep {
1091                    name: PackageName::new("github.com/pkg/errors"),
1092                    version_req: Some(VersionReq::new("v0.8.1")),
1093                    source: DependencySource::Registry,
1094                }],
1095            };
1096            let mut resolved = HashMap::new();
1097            // Stale entry: go.sum still records v0.9.1 from before a
1098            // downgrade back to v0.8.1 that only `go get` (not `go mod
1099            // tidy`) performed.
1100            resolved.insert(PackageName::new("github.com/pkg/errors"), "v0.9.1".into());
1101
1102            let (targets, skipped) = build_scan_targets(
1103                &parse_result,
1104                &resolved,
1105                &HashMap::new(),
1106                &MOCK_GO_FORMATTER,
1107                EcosystemId::Go,
1108            );
1109            assert_eq!(targets.len(), 1);
1110            // `.version` (the wire-format value) goes through `formatter.osv_version`,
1111            // whose shared default (`deps-core`) strips a leading `v`/`V` — `MOCK_GO_FORMATTER`
1112            // doesn't override it, unlike the real `GoFormatter`. `.display_version` is the
1113            // raw, untransformed value this test is actually about (manifest vs. lockfile
1114            // authority), so it keeps the native "v" spelling.
1115            assert_eq!(targets[0].version, "0.8.1");
1116            assert_eq!(targets[0].display_version, "v0.8.1");
1117            assert!(skipped.is_empty());
1118        }
1119
1120        /// #667 follow-up (impl-critic): before this reclassification, a Deno `jsr:`
1121        /// dependency's bare requirement always failed the version gate under
1122        /// `AlwaysRange` (`resolve_in_use_version` always `None`), so `DenoFormatter::
1123        /// osv_package_name`'s `_ => None` arm for `jsr:` never actually ran in a live
1124        /// scan. Now that Deno is `ConcreteIfFullVersion`, a bare-full-version-pinned
1125        /// `jsr:` dependency passes the version gate and correctness rests entirely on
1126        /// that match arm — this exercises it end-to-end through the real
1127        /// `DenoFormatter`, not just `osv_package_name`'s own unit test in isolation.
1128        #[cfg(feature = "deno")]
1129        #[test]
1130        fn build_scan_targets_deno_bare_pinned_jsr_dep_is_unmappable_name_skip() {
1131            let parse_result = MockParseResult {
1132                deps: vec![MockDep {
1133                    name: PackageName::new("jsr:@std/fs"),
1134                    version_req: Some(VersionReq::new("1.0.0")),
1135                    source: DependencySource::Registry,
1136                }],
1137            };
1138
1139            let (targets, skipped) = build_scan_targets(
1140                &parse_result,
1141                &HashMap::new(),
1142                &HashMap::new(),
1143                &deps_deno::DenoFormatter,
1144                EcosystemId::Deno,
1145            );
1146
1147            assert!(targets.is_empty(), "jsr: dep must never reach an OSV query");
1148            assert_eq!(skipped.len(), 1);
1149            // Key is the full scheme-qualified name: `DenoFormatter` doesn't override
1150            // `normalize_package_name`, unlike `osv_package_name` (which strips the
1151            // scheme only for `npm:` and returns `None` for everything else).
1152            assert_matches!(
1153                skipped.get(&deps_core::test_util::vuln_key("jsr:@std/fs")),
1154                Some(ScanOutcome::Skipped(SkipReason::UnmappableName))
1155            );
1156        }
1157
1158        /// #1545: `ComposerFormatter::osv_package_name`'s lowercase override had an
1159        /// incorrect `#[cfg(feature = "lsp-responses")]` gate, so a `deps-cli` build
1160        /// (which never enables `lsp-responses`) silently fell back to `OsvNaming`'s
1161        /// identity default and sent Packagist's mixed-case spelling to OSV.dev's
1162        /// case-sensitive API. Exercises the real `ComposerFormatter` end-to-end (not
1163        /// just `osv_package_name`'s own unit test in isolation) so this regression
1164        /// class — a gated `OsvNaming` override silently no-op'ing — is caught by CI
1165        /// under a non-`lsp-responses` build, mirroring the Deno/jsr precedent above.
1166        #[cfg(feature = "composer")]
1167        #[test]
1168        fn build_scan_targets_composer_mixed_case_name_is_lowercased_for_osv() {
1169            let parse_result = MockParseResult {
1170                deps: vec![MockDep {
1171                    name: PackageName::new("Symfony/Http-Kernel"),
1172                    version_req: Some(VersionReq::new("4.4.0")),
1173                    source: DependencySource::Registry,
1174                }],
1175            };
1176
1177            let (targets, skipped) = build_scan_targets(
1178                &parse_result,
1179                &HashMap::new(),
1180                &HashMap::new(),
1181                &deps_composer::ComposerFormatter,
1182                EcosystemId::Composer,
1183            );
1184
1185            assert_eq!(targets.len(), 1);
1186            assert_eq!(targets[0].osv_name.name(), "symfony/http-kernel");
1187            assert!(skipped.is_empty());
1188        }
1189
1190        /// #1663: a separator-only Poetry key normalizes to an empty OSV name, which OSV
1191        /// rejects with a batch-wide HTTP 400 — it must be skipped as `UnmappableName` while a
1192        /// valid sibling still yields its (PEP 503-normalized) query.
1193        #[cfg(feature = "pypi")]
1194        #[test]
1195        fn build_scan_targets_pypi_separator_only_key_is_skipped_and_sibling_still_queried() {
1196            let dep = |name: &str| MockDep {
1197                name: PackageName::new(name),
1198                version_req: Some(VersionReq::new("==1.0.0")),
1199                source: DependencySource::Registry,
1200            };
1201            let parse_result = MockParseResult {
1202                deps: vec![dep("Werkzeug"), dep("---")],
1203            };
1204
1205            let (targets, skipped) = build_scan_targets(
1206                &parse_result,
1207                &HashMap::new(),
1208                &HashMap::new(),
1209                &deps_pypi::PypiFormatter,
1210                EcosystemId::Pypi,
1211            );
1212
1213            assert_eq!(targets.len(), 1);
1214            assert_eq!(targets[0].osv_name.name(), "werkzeug");
1215            assert_eq!(skipped.len(), 1);
1216            // The skip is keyed by the normalized name, which is empty for `---`.
1217            assert_matches!(
1218                skipped.get(&deps_core::test_util::vuln_key("")),
1219                Some(ScanOutcome::Skipped(SkipReason::UnmappableName))
1220            );
1221        }
1222
1223        /// #1689: a Dart `+N` build-number pin with no lock file is a concrete OSV query target.
1224        #[cfg(feature = "dart")]
1225        #[test]
1226        fn build_scan_targets_dart_plus_build_number_pin_without_lockfile_is_queried() {
1227            let dep = |name: &str, req: &str| MockDep {
1228                name: PackageName::new(name),
1229                version_req: Some(VersionReq::new(req)),
1230                source: DependencySource::Registry,
1231            };
1232            let parse_result = MockParseResult {
1233                deps: vec![
1234                    dep("image_picker_android", "0.8.13+1"),
1235                    dep("caret_pkg", "^0.8.13+1"),
1236                ],
1237            };
1238
1239            let (targets, skipped) = build_scan_targets(
1240                &parse_result,
1241                &HashMap::new(),
1242                &HashMap::new(),
1243                &deps_dart::DartFormatter,
1244                EcosystemId::Dart,
1245            );
1246
1247            assert_eq!(targets.len(), 1, "{skipped:?}");
1248            assert_eq!(targets[0].display_version, "0.8.13+1");
1249        }
1250
1251        /// #1556 impl-critic S1: a GitHub Actions SHA pin whose `TagIndex`-resolved tag is
1252        /// itself a moving-major/partial name (here, `v1`, from a `# v1` comment) is NOT a
1253        /// queryable OSV version — `TagIndex.sha_to_tag` is first-wins over every tag
1254        /// pointing at that commit, so it can just as easily hand back `"v1"` or `"2.9"` as
1255        /// a genuine full tag, and querying OSV.dev with a fabricated version is exactly
1256        /// the #503 invariant this must not regress. `resolved_pin_version`'s raw output
1257        /// must still pass through the same full-semver-shape gate
1258        /// (`concrete_pin_version`) as manifest text before `resolve_in_use_version`
1259        /// accepts it — see the positive case below for the tag shape that IS accepted.
1260        #[cfg(feature = "github-actions")]
1261        #[test]
1262        fn build_scan_targets_github_actions_sha_pin_with_moving_major_comment_stays_skipped() {
1263            use deps_core::lsp_helpers::{CommitSha, TagIndex};
1264            use deps_core::osv::{ScanOutcome, SkipReason};
1265            use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1266            use std::sync::Arc;
1267
1268            let sha = "d".repeat(40);
1269            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1270            let content = format!("steps:\n  - uses: actions/checkout@{sha} # v1\n");
1271            let parse_result =
1272                deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1273
1274            let cache = Arc::new(deps_core::HttpCache::new());
1275            let registry = GithubActionsRegistry::new(cache);
1276            let tag_index = registry.tag_index();
1277            let mut index = TagIndex::default();
1278            index.insert_sha_pin(
1279                CommitSha::parse(&sha).unwrap(),
1280                deps_core::lsp_helpers::ResolvedPin::most_specific(
1281                    deps_core::ConcreteVersion::new("v1"),
1282                ),
1283            );
1284            tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
1285            let formatter = GithubActionsFormatter::new(tag_index);
1286
1287            let (targets, skipped) = build_scan_targets(
1288                &parse_result,
1289                &HashMap::new(),
1290                &HashMap::new(),
1291                &formatter,
1292                EcosystemId::GithubActions,
1293            );
1294
1295            assert!(
1296                targets.is_empty(),
1297                "a moving-major TagIndex-resolved tag must not reach OSV as a fabricated version: {targets:?}"
1298            );
1299            assert_matches!(
1300                skipped.get(&deps_core::test_util::vuln_key("actions/checkout")),
1301                Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
1302            );
1303        }
1304
1305        /// #1668: a SHA pin whose commit carries a two-component release tag (`v2.9`) plus its
1306        /// moving alias (`v2`) is scanned as `v2.9`; a commit carrying only the moving alias
1307        /// (`v2`) stays skipped, with the accurate "resolved tag is not a full version" reason.
1308        #[cfg(feature = "github-actions")]
1309        #[test]
1310        fn build_scan_targets_github_actions_sha_pin_two_component_release_tag() {
1311            use deps_core::lsp_helpers::{CommitSha, TagIndex};
1312            use deps_core::osv::{ScanOutcome, SkipReason};
1313            use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1314            use std::sync::Arc;
1315
1316            let sha = "f".repeat(40);
1317            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1318            let content = format!("steps:\n  - uses: actions/checkout@{sha} # v2.9\n");
1319            let parse_result =
1320                deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1321            let commit = CommitSha::parse(&sha).unwrap();
1322
1323            for (tags, expected) in [
1324                (vec!["v2", "v2.9"], Some("v2.9")),
1325                (vec!["v2.9", "v2.9.1"], Some("v2.9.1")),
1326                (vec!["v2.9"], Some("v2.9")),
1327                (vec!["v2"], None),
1328            ] {
1329                let cache = Arc::new(deps_core::HttpCache::new());
1330                let registry = GithubActionsRegistry::new(cache);
1331                let tag_index = registry.tag_index();
1332                tag_index.insert(
1333                    PackageName::new("actions/checkout"),
1334                    Arc::new(
1335                        TagIndex::from_tags(tags.iter().map(|t| (*t, &commit)))
1336                            .with_canonical_repo_name(
1337                                deps_core::github::CanonicalRepoName::from_commit_url(
1338                                    "https://api.github.com/repos/actions/checkout/commits/abc",
1339                                ),
1340                            ),
1341                    ),
1342                );
1343                let formatter = GithubActionsFormatter::new(tag_index);
1344
1345                let (targets, skipped) = build_scan_targets(
1346                    &parse_result,
1347                    &HashMap::new(),
1348                    &HashMap::new(),
1349                    &formatter,
1350                    EcosystemId::GithubActions,
1351                );
1352
1353                match expected {
1354                    Some(version) => {
1355                        assert_eq!(targets.len(), 1, "{tags:?}: {skipped:?}");
1356                        assert_eq!(targets[0].display_version, version);
1357                        assert!(skipped.is_empty());
1358                    }
1359                    None => {
1360                        assert!(targets.is_empty(), "{tags:?}: {targets:?}");
1361                        assert_matches!(
1362                            skipped.get(&deps_core::test_util::vuln_key("actions/checkout")),
1363                            Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
1364                        );
1365                    }
1366                }
1367            }
1368        }
1369
1370        /// #1556: the actually-intended fix — a GitHub Actions SHA pin whose `TagIndex`
1371        /// resolves it to a genuine full `major.minor.patch` tag (here `v1.3.0`, matching
1372        /// the issue's own `moonrepo/setup-rust@<sha> # v1` example where the SHA's real
1373        /// tag turns out to be a full release) must reach a real OSV scan target, even
1374        /// though the pin's own trailing `# v1` comment alone is not full-semver-shaped.
1375        /// Exercises the real `deps-github-actions` formatter end-to-end (not just
1376        /// `resolve_in_use_version`'s isolated unit tests), mirroring the Deno/Composer
1377        /// end-to-end precedent above.
1378        #[cfg(feature = "github-actions")]
1379        #[test]
1380        fn build_scan_targets_github_actions_sha_pin_tag_index_resolves_to_full_semver_tag() {
1381            use deps_core::lsp_helpers::{CommitSha, TagIndex};
1382            use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1383            use std::sync::Arc;
1384
1385            let sha = "e".repeat(40);
1386            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1387            let content = format!("steps:\n  - uses: actions/checkout@{sha} # v1\n");
1388            let parse_result =
1389                deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1390
1391            let cache = Arc::new(deps_core::HttpCache::new());
1392            let registry = GithubActionsRegistry::new(cache);
1393            let tag_index = registry.tag_index();
1394            let mut index = TagIndex::default();
1395            index.insert_sha_pin(
1396                CommitSha::parse(&sha).unwrap(),
1397                deps_core::lsp_helpers::ResolvedPin::most_specific(
1398                    deps_core::ConcreteVersion::new("v1.3.0"),
1399                ),
1400            );
1401            let index = index.with_canonical_repo_name(
1402                deps_core::github::CanonicalRepoName::from_commit_url(
1403                    "https://api.github.com/repos/actions/checkout/commits/abc",
1404                ),
1405            );
1406            tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
1407            let formatter = GithubActionsFormatter::new(tag_index);
1408
1409            let (targets, skipped) = build_scan_targets(
1410                &parse_result,
1411                &HashMap::new(),
1412                &HashMap::new(),
1413                &formatter,
1414                EcosystemId::GithubActions,
1415            );
1416
1417            assert_eq!(
1418                targets.len(),
1419                1,
1420                "expected the SHA pin to reach a real OSV scan target: {skipped:?}"
1421            );
1422            assert!(skipped.is_empty());
1423            assert_eq!(targets[0].display_version, "v1.3.0");
1424        }
1425
1426        /// #1709: every release tag on the pinned commit reaches the scan target, for a SHA pin
1427        /// and for an exact tag pin (primary stays the written tag); no sibling for a lone tag.
1428        #[cfg(feature = "github-actions")]
1429        #[test]
1430        fn build_scan_targets_github_actions_attaches_sibling_release_tags() {
1431            use deps_core::lsp_helpers::{CommitSha, TagIndex};
1432            use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
1433            use std::sync::Arc;
1434
1435            let sha = "e".repeat(40);
1436            let commit = CommitSha::parse(&sha).unwrap();
1437            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
1438            let content = format!(
1439                "steps:\n  - uses: actions/checkout@{sha}\n  - uses: other/action@v4.9.0\n"
1440            );
1441            let parse_result =
1442                deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
1443
1444            let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
1445            let tag_index = registry.tag_index();
1446            let canonical = |repo: &str| {
1447                deps_core::github::CanonicalRepoName::from_commit_url(&format!(
1448                    "https://api.github.com/repos/{repo}/commits/abc"
1449                ))
1450            };
1451            tag_index.insert(
1452                PackageName::new("actions/checkout"),
1453                Arc::new(
1454                    TagIndex::from_tags([("v4.8.0", &commit), ("v4.9.0", &commit)])
1455                        .with_canonical_repo_name(canonical("actions/checkout")),
1456                ),
1457            );
1458            tag_index.insert(
1459                PackageName::new("other/action"),
1460                Arc::new(
1461                    TagIndex::from_tags([("v4.9.0", &commit), ("v4.10.0", &commit)])
1462                        .with_canonical_repo_name(canonical("other/action")),
1463                ),
1464            );
1465            let formatter = GithubActionsFormatter::new(tag_index);
1466
1467            let (targets, skipped) = build_scan_targets(
1468                &parse_result,
1469                &HashMap::new(),
1470                &HashMap::new(),
1471                &formatter,
1472                EcosystemId::GithubActions,
1473            );
1474
1475            assert!(skipped.is_empty(), "{skipped:?}");
1476            let versions = |name: &str| {
1477                let target = targets
1478                    .iter()
1479                    .find(|t| t.key.as_str() == name)
1480                    .expect("target for name");
1481                (
1482                    target.display_version.to_string(),
1483                    target
1484                        .siblings()
1485                        .iter()
1486                        .map(|s| s.display_version().to_string())
1487                        .collect::<Vec<_>>(),
1488                )
1489            };
1490            assert_eq!(
1491                versions("actions/checkout"),
1492                ("v4.8.0".to_string(), vec!["v4.9.0".to_string()])
1493            );
1494            assert_eq!(
1495                versions("other/action"),
1496                ("v4.9.0".to_string(), vec!["v4.10.0".to_string()])
1497            );
1498        }
1499
1500        #[test]
1501        fn build_scan_targets_without_a_tag_pin_has_no_siblings() {
1502            let parse_result = MockParseResult {
1503                deps: vec![MockDep {
1504                    name: PackageName::new("log4j-core"),
1505                    version_req: Some(VersionReq::new("2.14.1")),
1506                    source: DependencySource::Registry,
1507                }],
1508            };
1509            let (targets, _) = build_scan_targets(
1510                &parse_result,
1511                &HashMap::new(),
1512                &HashMap::new(),
1513                &StubFormatter::DEFAULT,
1514                EcosystemId::Maven,
1515            );
1516            assert!(targets.iter().all(|t| t.siblings().is_empty()));
1517        }
1518
1519        #[test]
1520        fn build_scan_targets_step2_uses_concrete_requirement_verbatim() {
1521            let parse_result = MockParseResult {
1522                deps: vec![MockDep {
1523                    name: PackageName::new("log4j-core"),
1524                    version_req: Some(VersionReq::new("2.14.1")),
1525                    source: DependencySource::Registry,
1526                }],
1527            };
1528            let resolved = HashMap::new();
1529
1530            let (targets, skipped) = build_scan_targets(
1531                &parse_result,
1532                &resolved,
1533                &HashMap::new(),
1534                &StubFormatter::DEFAULT,
1535                EcosystemId::Maven,
1536            );
1537            assert_eq!(targets.len(), 1);
1538            assert_eq!(targets[0].version, "2.14.1");
1539            assert!(skipped.is_empty());
1540        }
1541
1542        #[test]
1543        fn build_scan_targets_step2_strips_pin_marker_for_operator_prefixed_requirements() {
1544            // impl-critic M2: `concrete_pin_version` (originally PyPI `==`-only) also
1545            // strips Cargo's `=` and NuGet's `[..]` exact-pin markers via the shared helper.
1546            let cargo_result = MockParseResult {
1547                deps: vec![MockDep {
1548                    name: PackageName::new("time"),
1549                    version_req: Some(VersionReq::new("=1.2.3")),
1550                    source: DependencySource::Registry,
1551                }],
1552            };
1553            let (targets, skipped) = build_scan_targets(
1554                &cargo_result,
1555                &HashMap::new(),
1556                &HashMap::new(),
1557                &StubFormatter::DEFAULT,
1558                EcosystemId::Cargo,
1559            );
1560            assert_eq!(targets.len(), 1);
1561            assert_eq!(targets[0].version, "1.2.3");
1562            assert!(skipped.is_empty());
1563
1564            let nuget_result = MockParseResult {
1565                deps: vec![MockDep {
1566                    name: PackageName::new("Newtonsoft.Json"),
1567                    version_req: Some(VersionReq::new("[1.0.0]")),
1568                    source: DependencySource::Registry,
1569                }],
1570            };
1571            let (targets, skipped) = build_scan_targets(
1572                &nuget_result,
1573                &HashMap::new(),
1574                &HashMap::new(),
1575                &StubFormatter::DEFAULT,
1576                EcosystemId::NuGet,
1577            );
1578            assert_eq!(targets.len(), 1);
1579            assert_eq!(targets[0].version, "1.0.0");
1580            assert!(skipped.is_empty());
1581        }
1582
1583        #[test]
1584        fn build_scan_targets_step3_skips_caret_range_with_no_lockfile_entry() {
1585            let parse_result = MockParseResult {
1586                deps: vec![MockDep {
1587                    name: PackageName::new("serde"),
1588                    version_req: Some(VersionReq::new("^1.0")),
1589                    source: DependencySource::Registry,
1590                }],
1591            };
1592            let resolved = HashMap::new();
1593
1594            let (targets, skipped) = build_scan_targets(
1595                &parse_result,
1596                &resolved,
1597                &HashMap::new(),
1598                &StubFormatter::DEFAULT,
1599                EcosystemId::Cargo,
1600            );
1601            assert!(targets.is_empty());
1602            assert_matches!(
1603                skipped.get(&deps_core::test_util::vuln_key("serde")),
1604                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
1605            );
1606        }
1607
1608        #[test]
1609        fn build_scan_targets_step3_skips_wildcard_with_no_lockfile_entry() {
1610            let parse_result = MockParseResult {
1611                deps: vec![MockDep {
1612                    name: PackageName::new("serde"),
1613                    version_req: Some(VersionReq::new("*")),
1614                    source: DependencySource::Registry,
1615                }],
1616            };
1617            let resolved = HashMap::new();
1618
1619            let (targets, skipped) = build_scan_targets(
1620                &parse_result,
1621                &resolved,
1622                &HashMap::new(),
1623                &StubFormatter::DEFAULT,
1624                EcosystemId::Cargo,
1625            );
1626            assert!(targets.is_empty());
1627            assert_matches!(
1628                skipped.get(&deps_core::test_util::vuln_key("serde")),
1629                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
1630            );
1631        }
1632
1633        #[test]
1634        fn build_scan_targets_all_non_registry_sources_are_skipped() {
1635            let sources = vec![
1636                DependencySource::Path {
1637                    path: "../local".to_string(),
1638                },
1639                DependencySource::Url {
1640                    url: "https://example.com/pkg.tgz".to_string(),
1641                },
1642                DependencySource::Sdk {
1643                    sdk: "flutter".to_string(),
1644                },
1645                DependencySource::Workspace,
1646                DependencySource::CustomRegistry {
1647                    url: "https://private.example.com".to_string(),
1648                },
1649            ];
1650
1651            for source in sources {
1652                let parse_result = MockParseResult {
1653                    deps: vec![MockDep {
1654                        name: PackageName::new("pkg"),
1655                        version_req: Some(VersionReq::new("1.0.0")),
1656                        source: source.clone(),
1657                    }],
1658                };
1659                let mut resolved = HashMap::new();
1660                resolved.insert(PackageName::new("pkg"), "1.0.0".into());
1661
1662                let (targets, skipped) = build_scan_targets(
1663                    &parse_result,
1664                    &resolved,
1665                    &HashMap::new(),
1666                    &StubFormatter::DEFAULT,
1667                    EcosystemId::Cargo,
1668                );
1669                assert!(targets.is_empty(), "{source:?} must be skipped (step 0)");
1670                assert_matches!(
1671                    skipped.get(&deps_core::test_util::vuln_key("pkg")),
1672                    Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
1673                );
1674            }
1675        }
1676
1677        #[test]
1678        fn build_scan_targets_never_drops_a_dependency_silently() {
1679            // Critique C1: every dependency considered must end up in either
1680            // `targets` or `skipped` — never absent from both.
1681            let parse_result = MockParseResult {
1682                deps: vec![
1683                    MockDep {
1684                        name: PackageName::new("concrete"),
1685                        version_req: Some(VersionReq::new("2.14.1")),
1686                        source: DependencySource::Registry,
1687                    },
1688                    MockDep {
1689                        name: PackageName::new("range-only"),
1690                        version_req: Some(VersionReq::new("^1.0")),
1691                        source: DependencySource::Registry,
1692                    },
1693                    MockDep {
1694                        name: PackageName::new("git-dep"),
1695                        version_req: Some(VersionReq::new("1.0.0")),
1696                        source: DependencySource::Git {
1697                            url: "https://example.com/git-dep".to_string(),
1698                            rev: None,
1699                        },
1700                    },
1701                ],
1702            };
1703            let resolved = HashMap::new();
1704
1705            let (targets, skipped) = build_scan_targets(
1706                &parse_result,
1707                &resolved,
1708                &HashMap::new(),
1709                &StubFormatter::DEFAULT,
1710                EcosystemId::Maven,
1711            );
1712
1713            assert_eq!(targets.len(), 1);
1714            assert_eq!(targets[0].key.as_str(), "concrete");
1715            assert_eq!(skipped.len(), 2);
1716            assert_matches!(
1717                skipped.get(&deps_core::test_util::vuln_key("range-only")),
1718                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
1719            );
1720            assert_matches!(
1721                skipped.get(&deps_core::test_util::vuln_key("git-dep")),
1722                Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
1723            );
1724        }
1725
1726        // `collect_in_use_versions` (§4.6) reuses the same `resolve_in_use_version`
1727        // ladder as `build_scan_targets` above, plus its own step-0 filter —
1728        // these tests exercise that reuse directly.
1729
1730        #[test]
1731        fn collect_in_use_versions_prefers_lockfile_resolved_version() {
1732            let parse_result = MockParseResult {
1733                deps: vec![MockDep {
1734                    name: PackageName::new("serde"),
1735                    version_req: Some(VersionReq::new("^1.0")),
1736                    source: DependencySource::Registry,
1737                }],
1738            };
1739            let mut resolved = HashMap::new();
1740            resolved.insert(PackageName::new("serde"), "1.0.195".into());
1741
1742            let in_use = collect_in_use_versions(
1743                &parse_result,
1744                &resolved,
1745                &HashMap::new(),
1746                &StubFormatter::DEFAULT,
1747                EcosystemId::Cargo,
1748            );
1749            assert_eq!(
1750                in_use.get(&PackageName::new("serde")),
1751                Some(&vec![ConcreteVersion::from("1.0.195")])
1752            );
1753        }
1754
1755        #[test]
1756        fn collect_in_use_versions_concrete_pin_without_lockfile() {
1757            // Closes the former R4 gap: an exact pin with no lock file must
1758            // still produce an in-use version for the yanked probe.
1759            let parse_result = MockParseResult {
1760                deps: vec![MockDep {
1761                    name: PackageName::new("log4j-core"),
1762                    version_req: Some(VersionReq::new("2.14.1")),
1763                    source: DependencySource::Registry,
1764                }],
1765            };
1766            let resolved = HashMap::new();
1767
1768            let in_use = collect_in_use_versions(
1769                &parse_result,
1770                &resolved,
1771                &HashMap::new(),
1772                &StubFormatter::DEFAULT,
1773                EcosystemId::Maven,
1774            );
1775            assert_eq!(
1776                in_use.get(&PackageName::new("log4j-core")),
1777                Some(&vec![ConcreteVersion::from("2.14.1")])
1778            );
1779        }
1780
1781        #[test]
1782        fn collect_in_use_versions_strips_pep440_double_equals_pin_for_pypi() {
1783            // The scenario the plan's R4 closure claim actually targets:
1784            // a PyPI `requirements.txt`-style `==` exact pin with no lock
1785            // file. `in_use.get(..)` must be the bare `"4.9.0"` so it can
1786            // ever match a real registry version string during the probe.
1787            let parse_result = MockParseResult {
1788                deps: vec![MockDep {
1789                    name: PackageName::new("typing_extensions"),
1790                    version_req: Some(VersionReq::new("==4.9.0")),
1791                    source: DependencySource::Registry,
1792                }],
1793            };
1794            let resolved = HashMap::new();
1795
1796            let in_use = collect_in_use_versions(
1797                &parse_result,
1798                &resolved,
1799                &HashMap::new(),
1800                &StubFormatter::DEFAULT,
1801                EcosystemId::Pypi,
1802            );
1803            assert_eq!(
1804                in_use.get(&PackageName::new("typing_extensions")),
1805                Some(&vec![ConcreteVersion::from("4.9.0")]),
1806                "pep440 '==' comparator must be stripped, not carried into the in-use version"
1807            );
1808        }
1809
1810        #[test]
1811        fn collect_in_use_versions_skips_non_concrete_requirement_with_no_lockfile() {
1812            let parse_result = MockParseResult {
1813                deps: vec![MockDep {
1814                    name: PackageName::new("serde"),
1815                    version_req: Some(VersionReq::new("^1.0")),
1816                    source: DependencySource::Registry,
1817                }],
1818            };
1819            let resolved = HashMap::new();
1820
1821            let in_use = collect_in_use_versions(
1822                &parse_result,
1823                &resolved,
1824                &HashMap::new(),
1825                &StubFormatter::DEFAULT,
1826                EcosystemId::Cargo,
1827            );
1828            assert!(in_use.is_empty());
1829        }
1830
1831        #[test]
1832        fn collect_in_use_versions_excludes_non_registry_source_even_with_lockfile_version() {
1833            // Step 0 (§4.5): a patched git/path fork must never be flagged
1834            // for a registry version it does not contain.
1835            let parse_result = MockParseResult {
1836                deps: vec![MockDep {
1837                    name: PackageName::new("time"),
1838                    version_req: Some(VersionReq::new("0.1.43")),
1839                    source: DependencySource::Git {
1840                        url: "https://github.com/example/time".to_string(),
1841                        rev: None,
1842                    },
1843                }],
1844            };
1845            let mut resolved = HashMap::new();
1846            resolved.insert(PackageName::new("time"), "0.1.43".into());
1847
1848            let in_use = collect_in_use_versions(
1849                &parse_result,
1850                &resolved,
1851                &HashMap::new(),
1852                &StubFormatter::DEFAULT,
1853                EcosystemId::Cargo,
1854            );
1855            assert!(in_use.is_empty());
1856        }
1857
1858        #[test]
1859        fn collect_in_use_versions_tracks_all_occurrences_of_duplicate_name() {
1860            // Regression guard for #394: two occurrences of the same name with different
1861            // pins (e.g. `[dependencies]` + `[dev-dependencies]`) must both surface — a
1862            // name-keyed `HashMap<PackageName, String>` would drop all but the last pin.
1863            let parse_result = MockParseResult {
1864                deps: vec![
1865                    MockDep {
1866                        name: PackageName::new("time"),
1867                        version_req: Some(VersionReq::new("=0.1.43")),
1868                        source: DependencySource::Registry,
1869                    },
1870                    MockDep {
1871                        name: PackageName::new("time"),
1872                        version_req: Some(VersionReq::new("=0.1.44")),
1873                        source: DependencySource::Registry,
1874                    },
1875                ],
1876            };
1877            let resolved = HashMap::new();
1878
1879            let in_use = collect_in_use_versions(
1880                &parse_result,
1881                &resolved,
1882                &HashMap::new(),
1883                &StubFormatter::DEFAULT,
1884                EcosystemId::Cargo,
1885            );
1886            assert_eq!(
1887                in_use.get(&PackageName::new("time")),
1888                Some(&vec![
1889                    ConcreteVersion::from("0.1.43"),
1890                    ConcreteVersion::from("0.1.44")
1891                ]),
1892                "both occurrences' in-use versions must be tracked, not just the last one"
1893            );
1894        }
1895    }
1896
1897    /// #1624 tester gap 1: `build_latest_check_targets`/`build_candidate_check_targets`'s
1898    /// structural-skip insert paths and `build_candidate_check_targets`'s per-rank
1899    /// round-bucketing loop, previously entirely unexercised by any unit test (only the one
1900    /// doctest above, which covers just the "real target" happy path).
1901    mod build_check_targets_tests {
1902        use super::*;
1903        use deps_core::Dependency;
1904        use deps_core::lsp_helpers::{
1905            DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1906            PackageVersions, RequirementResolution, SourcePolicy,
1907        };
1908        use deps_core::osv::{CandidateStatuses, StructuralSkipReason, UpgradeStatus};
1909        use deps_core::parser::DependencySource;
1910        use deps_core::position::{Position, Range};
1911        use deps_core::test_util::StubFormatter;
1912        use std::any::Any;
1913        use std::sync::Arc;
1914
1915        struct MockDep {
1916            name: PackageName,
1917            source: DependencySource,
1918        }
1919
1920        impl Dependency for MockDep {
1921            fn name(&self) -> &PackageName {
1922                &self.name
1923            }
1924            fn name_range(&self) -> Range {
1925                let addr = std::ptr::from_ref(self) as u32;
1926                Range::new(Position::new(0, addr), Position::new(0, addr + 1))
1927            }
1928            fn version_requirement(&self) -> Option<&VersionReq> {
1929                None
1930            }
1931            fn version_range(&self) -> Option<Range> {
1932                None
1933            }
1934            fn source(&self) -> DependencySource {
1935                self.source.clone()
1936            }
1937            fn as_any(&self) -> &dyn Any {
1938                self
1939            }
1940        }
1941
1942        struct MockParseResult {
1943            deps: Vec<MockDep>,
1944        }
1945
1946        impl deps_core::ParseResult for MockParseResult {
1947            fn dependencies(&self) -> Vec<&dyn Dependency> {
1948                self.deps.iter().map(|d| d as &dyn Dependency).collect()
1949            }
1950            fn workspace_root(&self) -> Option<&std::path::Path> {
1951                None
1952            }
1953            fn uri(&self) -> &url::Url {
1954                static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
1955                URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
1956            }
1957            fn as_any(&self) -> &dyn Any {
1958                self
1959            }
1960        }
1961
1962        /// A formatter whose package name is never mappable to an OSV ecosystem name —
1963        /// `StubFormatter`'s `OsvNaming` default (always `Some`) can't drive the
1964        /// `UnmappableName` structural-skip branch.
1965        struct UnmappableNameFormatter;
1966        impl PackageNaming for UnmappableNameFormatter {}
1967        impl PackageRendering for UnmappableNameFormatter {
1968            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1969                version.to_string()
1970            }
1971            fn package_url(&self, name: &PackageName) -> String {
1972                name.as_str().to_string()
1973            }
1974        }
1975        impl RequirementResolution for UnmappableNameFormatter {}
1976        impl DiagnosticMessages for UnmappableNameFormatter {}
1977        impl DiagnosticPolicy for UnmappableNameFormatter {}
1978        impl SourcePolicy for UnmappableNameFormatter {}
1979        impl OsvNaming for UnmappableNameFormatter {
1980            fn osv_package_name(
1981                &self,
1982                _dep: &dyn Dependency,
1983            ) -> Option<deps_core::osv::OsvPackageName> {
1984                None
1985            }
1986        }
1987
1988        /// A formatter whose OSV name is derived from registry data that has not landed yet;
1989        /// `with_fallback` offers the written name as a provisional query name (#1694).
1990        #[derive(Default)]
1991        struct AwaitingNameFormatter {
1992            with_fallback: bool,
1993        }
1994        impl PackageNaming for AwaitingNameFormatter {}
1995        impl PackageRendering for AwaitingNameFormatter {
1996            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1997                version.to_string()
1998            }
1999            fn package_url(&self, name: &PackageName) -> String {
2000                name.as_str().to_string()
2001            }
2002        }
2003        impl RequirementResolution for AwaitingNameFormatter {}
2004        impl DiagnosticMessages for AwaitingNameFormatter {}
2005        impl DiagnosticPolicy for AwaitingNameFormatter {}
2006        impl SourcePolicy for AwaitingNameFormatter {}
2007        impl OsvNaming for AwaitingNameFormatter {
2008            fn osv_name_availability(
2009                &self,
2010                dep: &dyn Dependency,
2011            ) -> deps_core::lsp_helpers::OsvNameAvailability {
2012                deps_core::lsp_helpers::OsvNameAvailability::AwaitingRegistryData {
2013                    written_fallback: self
2014                        .with_fallback
2015                        .then(|| deps_core::osv::OsvPackageName::new_or_skip(dep.name().as_str()))
2016                        .flatten(),
2017                }
2018            }
2019        }
2020
2021        fn awaiting_name_fixture() -> (
2022            MockParseResult,
2023            deps_core::osv::VulnKeys,
2024            HashMap<PackageName, PackageVersions>,
2025        ) {
2026            let parse_result = MockParseResult {
2027                deps: vec![MockDep {
2028                    name: PackageName::new("gha-action"),
2029                    source: DependencySource::Registry,
2030                }],
2031            };
2032            let vuln_keys = vuln_keys_for(&parse_result, &AwaitingNameFormatter::default());
2033            let mut cached_versions = HashMap::new();
2034            cached_versions.insert(
2035                PackageName::new("gha-action"),
2036                PackageVersions::latest_only("1.0.0"),
2037            );
2038            (parse_result, vuln_keys, cached_versions)
2039        }
2040
2041        #[test]
2042        fn build_latest_check_targets_awaiting_osv_name_is_unverified_not_structural() {
2043            let (parse_result, vuln_keys, cached_versions) = awaiting_name_fixture();
2044
2045            let (targets, statuses) = build_latest_check_targets(
2046                &parse_result,
2047                &cached_versions,
2048                &vuln_keys,
2049                &AwaitingNameFormatter::default(),
2050            );
2051
2052            assert!(targets.is_empty());
2053            assert_eq!(
2054                statuses.get(&deps_core::test_util::vuln_key("gha-action")),
2055                Some(&UpgradeStatus::CandidateUnverified {
2056                    version: ConcreteVersion::new("1.0.0"),
2057                    reason: deps_core::osv::SkipReason::CanonicalNameUnconfirmed,
2058                })
2059            );
2060        }
2061
2062        #[test]
2063        fn build_candidate_check_targets_awaiting_osv_name_records_no_entry() {
2064            let (parse_result, vuln_keys, cached_versions) = awaiting_name_fixture();
2065
2066            let (rounds, statuses) = build_candidate_check_targets(
2067                &parse_result,
2068                &cached_versions,
2069                &vuln_keys,
2070                &AwaitingNameFormatter::default(),
2071            );
2072
2073            assert!(rounds.iter().all(Vec::is_empty));
2074            assert!(
2075                statuses.is_empty(),
2076                "a transient name gap must never be stored as structural: {statuses:?}"
2077            );
2078        }
2079
2080        /// #1694: an unconfirmed casing queries the written name as a provisional target
2081        /// (only a positive answer is trusted); a confirmed one is the authoritative target.
2082        #[cfg(feature = "github-actions")]
2083        #[test]
2084        fn build_scan_targets_github_actions_unconfirmed_name_is_provisional() {
2085            use deps_core::lsp_helpers::TagIndex;
2086            use deps_core::osv::OsvQueryName;
2087            use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
2088            use std::sync::Arc;
2089
2090            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
2091            let parse_result = deps_github_actions::parse_workflow_yaml(
2092                "steps:\n  - uses: azure/setup-kubectl@v4.1.2\n",
2093                &uri,
2094            )
2095            .expect("valid yaml");
2096            let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
2097            let written = deps_core::osv::OsvPackageName::new("azure/setup-kubectl").unwrap();
2098            let canonical = deps_core::osv::OsvPackageName::new("Azure/setup-kubectl").unwrap();
2099
2100            for warm_without_canonical in [false, true] {
2101                let tag_index = registry.tag_index();
2102                tag_index.clear();
2103                if warm_without_canonical {
2104                    tag_index.insert(
2105                        PackageName::new("azure/setup-kubectl"),
2106                        Arc::new(TagIndex::default()),
2107                    );
2108                }
2109                let formatter = GithubActionsFormatter::new(tag_index);
2110
2111                let (targets, skipped) = build_scan_targets(
2112                    &parse_result,
2113                    &HashMap::new(),
2114                    &HashMap::new(),
2115                    &formatter,
2116                    EcosystemId::GithubActions,
2117                );
2118
2119                assert!(skipped.is_empty(), "{skipped:?}");
2120                assert_eq!(targets.len(), 1);
2121                assert_eq!(
2122                    targets[0].osv_name,
2123                    OsvQueryName::Provisional(written.clone()),
2124                    "warm_without_canonical={warm_without_canonical}"
2125                );
2126            }
2127
2128            let tag_index = registry.tag_index();
2129            tag_index.insert(
2130                PackageName::new("azure/setup-kubectl"),
2131                Arc::new(TagIndex::default().with_canonical_repo_name(
2132                    deps_core::github::CanonicalRepoName::from_commit_url(
2133                        "https://api.github.com/repos/Azure/setup-kubectl/commits/abc",
2134                    ),
2135                )),
2136            );
2137            let formatter = GithubActionsFormatter::new(tag_index);
2138            let (targets, _) = build_scan_targets(
2139                &parse_result,
2140                &HashMap::new(),
2141                &HashMap::new(),
2142                &formatter,
2143                EcosystemId::GithubActions,
2144            );
2145            assert_eq!(targets[0].osv_name, OsvQueryName::Confirmed(canonical));
2146        }
2147
2148        /// #1684: a floating `@v4` scans the release its tag's commit carries, and stays an
2149        /// honest skip while the index is cold or the commit only carries the floating tag.
2150        #[cfg(feature = "github-actions")]
2151        #[test]
2152        fn build_scan_targets_github_actions_floating_tag_resolves_via_commit() {
2153            use deps_core::lsp_helpers::{CommitSha, TagIndex};
2154            use deps_core::osv::{OsvQueryName, ScanOutcome, SkipReason};
2155            use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
2156            use std::sync::Arc;
2157
2158            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
2159            let parse_result = deps_github_actions::parse_workflow_yaml(
2160                "steps:\n  - uses: actions/checkout@v4\n",
2161                &uri,
2162            )
2163            .expect("valid yaml");
2164            let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
2165            let key = deps_core::test_util::vuln_key("actions/checkout");
2166            let commit = CommitSha::parse(&"a".repeat(40)).unwrap();
2167            let scan = |tags: &[&str]| {
2168                let tag_index = registry.tag_index();
2169                tag_index.clear();
2170                if !tags.is_empty() {
2171                    let index = TagIndex::from_tags(tags.iter().map(|t| (*t, &commit)))
2172                        .with_canonical_repo_name(
2173                            deps_core::github::CanonicalRepoName::from_commit_url(
2174                                "https://api.github.com/repos/actions/checkout/commits/abc",
2175                            ),
2176                        );
2177                    tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
2178                }
2179                build_scan_targets(
2180                    &parse_result,
2181                    &HashMap::new(),
2182                    &HashMap::new(),
2183                    &GithubActionsFormatter::new(tag_index),
2184                    EcosystemId::GithubActions,
2185                )
2186            };
2187
2188            let (targets, skipped) = scan(&["v4", "v4.2.2"]);
2189            assert!(skipped.is_empty(), "{skipped:?}");
2190            assert_eq!(targets.len(), 1);
2191            assert_eq!(targets[0].version, "4.2.2");
2192            assert_matches!(targets[0].osv_name, OsvQueryName::Confirmed(_));
2193
2194            let (targets, skipped) = scan(&[]);
2195            assert!(targets.is_empty());
2196            assert_matches!(
2197                skipped.get(&key),
2198                Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
2199            );
2200
2201            let (targets, skipped) = scan(&["v4"]);
2202            assert!(targets.is_empty());
2203            assert_matches!(
2204                skipped.get(&key),
2205                Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
2206            );
2207        }
2208
2209        /// #1694: a written name that is not a valid OSV name stays a fail-closed skip.
2210        #[test]
2211        fn build_scan_targets_awaiting_name_without_fallback_is_unconfirmed_skip() {
2212            let parse_result = MockParseResult {
2213                deps: vec![MockDep {
2214                    name: PackageName::new("gha-action"),
2215                    source: DependencySource::Registry,
2216                }],
2217            };
2218            let mut resolved = HashMap::new();
2219            resolved.insert(
2220                PackageName::new("gha-action"),
2221                ConcreteVersion::new("1.0.0"),
2222            );
2223
2224            let (targets, skipped) = build_scan_targets(
2225                &parse_result,
2226                &resolved,
2227                &HashMap::new(),
2228                &AwaitingNameFormatter::default(),
2229                EcosystemId::Cargo,
2230            );
2231
2232            assert!(targets.is_empty());
2233            assert_matches!(
2234                skipped.get(&deps_core::test_util::vuln_key("gha-action")),
2235                Some(deps_core::osv::ScanOutcome::Skipped(
2236                    deps_core::osv::SkipReason::CanonicalNameUnconfirmed
2237                ))
2238            );
2239        }
2240
2241        /// A formatter reporting a different name availability per successive dependency.
2242        struct SequencedNameFormatter {
2243            ready: [bool; 2],
2244            calls: std::sync::atomic::AtomicUsize,
2245        }
2246        impl PackageNaming for SequencedNameFormatter {}
2247        impl PackageRendering for SequencedNameFormatter {
2248            fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2249                version.to_string()
2250            }
2251            fn package_url(&self, name: &PackageName) -> String {
2252                name.as_str().to_string()
2253            }
2254        }
2255        impl RequirementResolution for SequencedNameFormatter {}
2256        impl DiagnosticMessages for SequencedNameFormatter {}
2257        impl DiagnosticPolicy for SequencedNameFormatter {}
2258        impl SourcePolicy for SequencedNameFormatter {}
2259        impl OsvNaming for SequencedNameFormatter {
2260            fn osv_name_availability(
2261                &self,
2262                dep: &dyn Dependency,
2263            ) -> deps_core::lsp_helpers::OsvNameAvailability {
2264                let idx = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2265                if self.ready[idx] {
2266                    deps_core::lsp_helpers::OsvNameAvailability::Ready
2267                } else {
2268                    deps_core::lsp_helpers::OsvNameAvailability::AwaitingRegistryData {
2269                        written_fallback: deps_core::osv::OsvPackageName::new_or_skip(
2270                            dep.name().as_str(),
2271                        ),
2272                    }
2273                }
2274            }
2275        }
2276
2277        /// #1694 (critic M1): occurrences sharing a key collapse to one target, and a
2278        /// confirmed name wins over a provisional one whichever comes first.
2279        #[test]
2280        fn build_scan_targets_dedups_per_key_preferring_confirmed_name() {
2281            use deps_core::osv::OsvQueryName;
2282
2283            let mut resolved = HashMap::new();
2284            resolved.insert(PackageName::new("dup"), ConcreteVersion::new("1.0.0"));
2285            let name = deps_core::osv::OsvPackageName::new("dup").unwrap();
2286
2287            for (ready, expected) in [
2288                ([false, true], OsvQueryName::Confirmed(name.clone())),
2289                ([true, false], OsvQueryName::Confirmed(name.clone())),
2290                ([false, false], OsvQueryName::Provisional(name)),
2291            ] {
2292                let parse_result = MockParseResult {
2293                    deps: ["dup", "dup"]
2294                        .into_iter()
2295                        .map(|n| MockDep {
2296                            name: PackageName::new(n),
2297                            source: DependencySource::Registry,
2298                        })
2299                        .collect(),
2300                };
2301                let formatter = SequencedNameFormatter {
2302                    ready,
2303                    calls: std::sync::atomic::AtomicUsize::new(0),
2304                };
2305                let (targets, _) = build_scan_targets(
2306                    &parse_result,
2307                    &resolved,
2308                    &HashMap::new(),
2309                    &formatter,
2310                    EcosystemId::Cargo,
2311                );
2312                assert_eq!(targets.len(), 1, "ready={ready:?}");
2313                assert_eq!(targets[0].osv_name, expected, "ready={ready:?}");
2314            }
2315        }
2316
2317        fn vuln_keys_for(
2318            parse_result: &dyn deps_core::ParseResult,
2319            formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
2320        ) -> deps_core::osv::VulnKeys {
2321            deps_core::osv::vulnerability_keys(
2322                parse_result,
2323                &HashMap::new(),
2324                None,
2325                formatter,
2326                EcosystemId::Cargo,
2327            )
2328        }
2329
2330        #[test]
2331        fn build_latest_check_targets_non_registry_source_is_structurally_unchecked() {
2332            let parse_result = MockParseResult {
2333                deps: vec![MockDep {
2334                    name: PackageName::new("vendored"),
2335                    source: DependencySource::Path {
2336                        path: "../vendored".to_string(),
2337                    },
2338                }],
2339            };
2340            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2341
2342            let (targets, structural) = build_latest_check_targets(
2343                &parse_result,
2344                &HashMap::new(),
2345                &vuln_keys,
2346                &StubFormatter::DEFAULT,
2347            );
2348
2349            assert!(targets.is_empty());
2350            assert_eq!(
2351                structural.get(&deps_core::test_util::vuln_key("vendored")),
2352                Some(&UpgradeStatus::StructurallyUnchecked(
2353                    StructuralSkipReason::NonRegistrySource
2354                ))
2355            );
2356        }
2357
2358        #[test]
2359        fn build_candidate_check_targets_non_registry_source_is_structural() {
2360            let parse_result = MockParseResult {
2361                deps: vec![MockDep {
2362                    name: PackageName::new("vendored"),
2363                    source: DependencySource::Path {
2364                        path: "../vendored".to_string(),
2365                    },
2366                }],
2367            };
2368            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2369
2370            let (rounds, structural) = build_candidate_check_targets(
2371                &parse_result,
2372                &HashMap::new(),
2373                &vuln_keys,
2374                &StubFormatter::DEFAULT,
2375            );
2376
2377            assert!(
2378                rounds.iter().all(Vec::is_empty),
2379                "no round may hold a non-registry dependency"
2380            );
2381            assert_eq!(
2382                structural.get(&deps_core::test_util::vuln_key("vendored")),
2383                Some(&CandidateStatuses::Structural(
2384                    StructuralSkipReason::NonRegistrySource
2385                ))
2386            );
2387        }
2388
2389        #[test]
2390        fn build_candidate_check_targets_unmappable_name_is_structural() {
2391            let parse_result = MockParseResult {
2392                deps: vec![MockDep {
2393                    name: PackageName::new("jsr-pinned"),
2394                    source: DependencySource::Registry,
2395                }],
2396            };
2397            let vuln_keys = vuln_keys_for(&parse_result, &UnmappableNameFormatter);
2398            let mut cached_versions = HashMap::new();
2399            cached_versions.insert(
2400                PackageName::new("jsr-pinned"),
2401                PackageVersions::latest_only("1.0.0"),
2402            );
2403
2404            let (rounds, structural) = build_candidate_check_targets(
2405                &parse_result,
2406                &cached_versions,
2407                &vuln_keys,
2408                &UnmappableNameFormatter,
2409            );
2410
2411            assert!(
2412                rounds.iter().all(Vec::is_empty),
2413                "an unmappable name has no OSV-checkable round targets"
2414            );
2415            assert_eq!(
2416                structural.get(&deps_core::test_util::vuln_key("jsr-pinned")),
2417                Some(&CandidateStatuses::Structural(
2418                    StructuralSkipReason::UnmappableName
2419                ))
2420            );
2421        }
2422
2423        fn duplicated_registry_dep() -> MockParseResult {
2424            MockParseResult {
2425                deps: vec![
2426                    MockDep {
2427                        name: PackageName::new("lodash"),
2428                        source: DependencySource::Registry,
2429                    },
2430                    MockDep {
2431                        name: PackageName::new("lodash"),
2432                        source: DependencySource::Registry,
2433                    },
2434                ],
2435            }
2436        }
2437
2438        #[test]
2439        fn build_latest_check_targets_dedups_duplicate_key_occurrences() {
2440            let parse_result = duplicated_registry_dep();
2441            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2442            let mut cached_versions = HashMap::new();
2443            cached_versions.insert(
2444                PackageName::new("lodash"),
2445                PackageVersions::latest_only("4.17.21"),
2446            );
2447
2448            let (targets, structural) = build_latest_check_targets(
2449                &parse_result,
2450                &cached_versions,
2451                &vuln_keys,
2452                &StubFormatter::DEFAULT,
2453            );
2454
2455            assert_eq!(
2456                targets.len(),
2457                1,
2458                "two same-key occurrences are checked once"
2459            );
2460            assert!(structural.is_empty());
2461        }
2462
2463        #[test]
2464        fn build_candidate_check_targets_dedups_duplicate_key_occurrences() {
2465            let parse_result = duplicated_registry_dep();
2466            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2467            let mut cached_versions = HashMap::new();
2468            let available: Arc<[ConcreteVersion]> = Arc::from(vec![
2469                ConcreteVersion::new("2.0.0"),
2470                ConcreteVersion::new("1.0.0"),
2471            ]);
2472            cached_versions.insert(
2473                PackageName::new("lodash"),
2474                PackageVersions::new(ConcreteVersion::new("2.0.0"), available),
2475            );
2476
2477            let (rounds, _) = build_candidate_check_targets(
2478                &parse_result,
2479                &cached_versions,
2480                &vuln_keys,
2481                &StubFormatter::DEFAULT,
2482            );
2483
2484            assert_eq!(rounds[0].len(), 1);
2485            assert_eq!(rounds[1].len(), 1);
2486        }
2487
2488        fn dup_other_dup() -> MockParseResult {
2489            let dep = |name: &str| MockDep {
2490                name: PackageName::new(name),
2491                source: DependencySource::Registry,
2492            };
2493            MockParseResult {
2494                deps: vec![dep("dup"), dep("other"), dep("dup")],
2495            }
2496        }
2497
2498        #[test]
2499        fn build_latest_check_targets_keeps_first_occurrence_order() {
2500            let parse_result = dup_other_dup();
2501            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2502            let mut cached_versions = HashMap::new();
2503            cached_versions.insert(
2504                PackageName::new("dup"),
2505                PackageVersions::latest_only("2.0.0"),
2506            );
2507            cached_versions.insert(
2508                PackageName::new("other"),
2509                PackageVersions::latest_only("3.0.0"),
2510            );
2511
2512            let (targets, _) = build_latest_check_targets(
2513                &parse_result,
2514                &cached_versions,
2515                &vuln_keys,
2516                &StubFormatter::DEFAULT,
2517            );
2518
2519            let keys: Vec<_> = targets.iter().map(|t| t.key.clone()).collect();
2520            assert_eq!(
2521                keys,
2522                [
2523                    deps_core::test_util::vuln_key("dup"),
2524                    deps_core::test_util::vuln_key("other")
2525                ]
2526            );
2527            assert_eq!(targets[0].display_version, "2.0.0");
2528            assert_eq!(targets[1].display_version, "3.0.0");
2529        }
2530
2531        #[test]
2532        fn build_candidate_check_targets_keeps_first_occurrence_order() {
2533            let parse_result = dup_other_dup();
2534            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2535            let mut cached_versions = HashMap::new();
2536            for (name, newest) in [("dup", "2.0.0"), ("other", "3.0.0")] {
2537                let available: Arc<[ConcreteVersion]> =
2538                    Arc::from(vec![ConcreteVersion::new(newest)]);
2539                cached_versions.insert(
2540                    PackageName::new(name),
2541                    PackageVersions::new(ConcreteVersion::new(newest), available),
2542                );
2543            }
2544
2545            let (rounds, _) = build_candidate_check_targets(
2546                &parse_result,
2547                &cached_versions,
2548                &vuln_keys,
2549                &StubFormatter::DEFAULT,
2550            );
2551
2552            let keys: Vec<_> = rounds[0].iter().map(|t| t.key.clone()).collect();
2553            assert_eq!(
2554                keys,
2555                [
2556                    deps_core::test_util::vuln_key("dup"),
2557                    deps_core::test_util::vuln_key("other")
2558                ]
2559            );
2560            assert_eq!(rounds[0][0].display_version, "2.0.0");
2561            assert_eq!(rounds[0][1].display_version, "3.0.0");
2562            assert!(rounds[1..].iter().all(Vec::is_empty));
2563        }
2564
2565        #[test]
2566        fn build_candidate_check_targets_buckets_newest_first_by_round() {
2567            let parse_result = MockParseResult {
2568                deps: vec![MockDep {
2569                    name: PackageName::new("pkg"),
2570                    source: DependencySource::Registry,
2571                }],
2572            };
2573            let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
2574            let mut cached_versions = HashMap::new();
2575            let available: Arc<[ConcreteVersion]> = Arc::from(vec![
2576                ConcreteVersion::new("3.0.0"),
2577                ConcreteVersion::new("2.0.0"),
2578                ConcreteVersion::new("1.0.0"),
2579            ]);
2580            cached_versions.insert(
2581                PackageName::new("pkg"),
2582                PackageVersions::new(ConcreteVersion::new("3.0.0"), available),
2583            );
2584
2585            let (rounds, structural) = build_candidate_check_targets(
2586                &parse_result,
2587                &cached_versions,
2588                &vuln_keys,
2589                &StubFormatter::DEFAULT,
2590            );
2591
2592            assert!(structural.is_empty());
2593            assert_eq!(
2594                rounds[0].len(),
2595                1,
2596                "round 0 must hold this dependency's newest candidate"
2597            );
2598            assert_eq!(rounds[0][0].display_version, "3.0.0");
2599            assert_eq!(rounds[1].len(), 1);
2600            assert_eq!(rounds[1][0].display_version, "2.0.0");
2601            assert_eq!(rounds[2].len(), 1);
2602            assert_eq!(rounds[2][0].display_version, "1.0.0");
2603            assert!(
2604                rounds[3..].iter().all(Vec::is_empty),
2605                "only 3 candidate versions were available, so later rounds must stay empty"
2606            );
2607        }
2608    }
2609
2610    /// #462: `resolve_fix_target`'s pure per-dependency decision logic (reuse / provably
2611    /// clean / needs a live check / skip), and `apply_live_fix_target_statuses`'s handling of
2612    /// a live-check result map that may be missing keys (timeout/outage).
2613    mod fix_target_verification_tests {
2614        use super::*;
2615        use deps_core::osv::{
2616            Advisory, Capped, DependencyVulnerabilities, OsvVersion, ScanOutcome, UpgradeStatus,
2617            VulnSeverity, VulnerabilityMap,
2618        };
2619        use deps_core::test_util::StubFormatter;
2620        use std::sync::Arc;
2621
2622        fn advisory(id: &str, fixed_versions: &[&str]) -> Arc<Advisory> {
2623            Arc::new(
2624                Advisory::new(
2625                    id.to_string(),
2626                    "2023-01-01T00:00:00Z".to_string(),
2627                    VulnSeverity::High,
2628                )
2629                .expect("valid osv id")
2630                .with_fixed_versions(
2631                    fixed_versions
2632                        .iter()
2633                        .copied()
2634                        .map(OsvVersion::new)
2635                        .collect(),
2636                ),
2637            )
2638        }
2639
2640        fn dv(advisories: Vec<Arc<Advisory>>) -> DependencyVulnerabilities {
2641            let total = advisories.len();
2642            DependencyVulnerabilities::new(Capped::new(advisories, total))
2643        }
2644
2645        /// Builds a one-entry [`deps_core::osv::LatestStatusMap`] for `"pkg"`.
2646        fn latest_status_map(status: UpgradeStatus) -> deps_core::osv::LatestStatusMap {
2647            let mut map = deps_core::osv::LatestStatusMap::new();
2648            map.insert(deps_core::test_util::vuln_key("pkg"), status);
2649            map
2650        }
2651
2652        #[test]
2653        fn resolve_fix_target_skips_when_no_fix_is_recommended() {
2654            // No advisory has a known fix, so `recommended_fix()` returns `None`.
2655            let dv = dv(vec![advisory("A1", &[])]);
2656            let resolution = resolve_fix_target(
2657                &dv,
2658                &deps_core::test_util::vuln_key("pkg"),
2659                &HashMap::new(),
2660                &HashMap::new(),
2661                &StubFormatter::DEFAULT,
2662            );
2663            assert_eq!(resolution, FixTargetResolution::Skip);
2664        }
2665
2666        #[test]
2667        fn resolve_fix_target_reuses_latest_when_f_equals_latest() {
2668            // Case (c): F (1.2.0, the only advisory's fix) coincides with the already-checked
2669            // "latest" candidate — reuse its result, no live check queued.
2670            let latest_status = UpgradeStatus::CandidateClean {
2671                version: ConcreteVersion::new("1.2.0"),
2672            };
2673            let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2674            let latest_status_map = latest_status_map(latest_status.clone());
2675
2676            let resolution = resolve_fix_target(
2677                &dv,
2678                &deps_core::test_util::vuln_key("pkg"),
2679                &latest_status_map,
2680                &HashMap::new(),
2681                &StubFormatter::DEFAULT,
2682            );
2683            assert_eq!(resolution, FixTargetResolution::Resolved(latest_status));
2684        }
2685
2686        #[test]
2687        fn resolve_fix_target_always_needs_live_check_when_f_differs_from_latest() {
2688            // #462 critic C1: no data-derived shortcut — F is *computed from* these exact
2689            // advisories, so "F <= advisories' fix" is a tautology that proves nothing about
2690            // an advisory phase A never fetched. F (1.2.0) != latest (3.0.0) must always
2691            // queue a live check.
2692            let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2693            let latest_status_map = latest_status_map(UpgradeStatus::CandidateClean {
2694                version: ConcreteVersion::new("3.0.0"),
2695            });
2696            let mut osv_name_by_key = HashMap::new();
2697            osv_name_by_key.insert(
2698                deps_core::test_util::vuln_key("pkg"),
2699                deps_core::osv::OsvQueryName::Confirmed(
2700                    deps_core::osv::OsvPackageName::new("pkg").unwrap(),
2701                ),
2702            );
2703
2704            let resolution = resolve_fix_target(
2705                &dv,
2706                &deps_core::test_util::vuln_key("pkg"),
2707                &latest_status_map,
2708                &osv_name_by_key,
2709                &StubFormatter::DEFAULT,
2710            );
2711            assert_eq!(
2712                resolution,
2713                FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget::new(
2714                    deps_core::test_util::vuln_key("pkg"),
2715                    deps_core::osv::OsvQueryName::Confirmed(
2716                        deps_core::osv::OsvPackageName::new("pkg").unwrap()
2717                    ),
2718                    OsvVersion::new("1.2.0"),
2719                    ConcreteVersion::new("1.2.0"),
2720                ))
2721            );
2722        }
2723
2724        #[test]
2725        fn resolve_fix_target_never_reuses_a_candidate_unverified_latest() {
2726            // Issue #1517: a transient (or structural) `CandidateUnverified` latest-check
2727            // result must never be mistaken for a resolved "F == latest" match — even when
2728            // its own `version` field happens to equal F, that field is not a confirmed
2729            // clean/vulnerable verdict, so this must still queue a live check.
2730            let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2731            let latest_status_map = latest_status_map(UpgradeStatus::CandidateUnverified {
2732                version: ConcreteVersion::new("1.2.0"),
2733                reason: deps_core::osv::SkipReason::QueryFailed,
2734            });
2735            let mut osv_name_by_key = HashMap::new();
2736            osv_name_by_key.insert(
2737                deps_core::test_util::vuln_key("pkg"),
2738                deps_core::osv::OsvQueryName::Confirmed(
2739                    deps_core::osv::OsvPackageName::new("pkg").unwrap(),
2740                ),
2741            );
2742
2743            let resolution = resolve_fix_target(
2744                &dv,
2745                &deps_core::test_util::vuln_key("pkg"),
2746                &latest_status_map,
2747                &osv_name_by_key,
2748                &StubFormatter::DEFAULT,
2749            );
2750            assert_eq!(
2751                resolution,
2752                FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget::new(
2753                    deps_core::test_util::vuln_key("pkg"),
2754                    deps_core::osv::OsvQueryName::Confirmed(
2755                        deps_core::osv::OsvPackageName::new("pkg").unwrap()
2756                    ),
2757                    OsvVersion::new("1.2.0"),
2758                    ConcreteVersion::new("1.2.0"),
2759                ))
2760            );
2761        }
2762
2763        /// #1694: a fix target can never be verified through an unconfirmed name — a clean
2764        /// answer there is not evidence, so the fix stays unverified and no query is queued.
2765        #[test]
2766        fn resolve_fix_target_skips_provisional_name_and_queues_no_live_check() {
2767            let dv = dv(vec![advisory("A1", &["1.2.0"])]);
2768            let key = deps_core::test_util::vuln_key("pkg");
2769            let latest_status_map = latest_status_map(UpgradeStatus::CandidateClean {
2770                version: ConcreteVersion::new("3.0.0"),
2771            });
2772            let mut osv_name_by_key = HashMap::new();
2773            osv_name_by_key.insert(
2774                key.clone(),
2775                deps_core::osv::OsvQueryName::Provisional(
2776                    deps_core::osv::OsvPackageName::new("pkg").unwrap(),
2777                ),
2778            );
2779
2780            assert_eq!(
2781                resolve_fix_target(
2782                    &dv,
2783                    &key,
2784                    &latest_status_map,
2785                    &osv_name_by_key,
2786                    &StubFormatter::DEFAULT,
2787                ),
2788                FixTargetResolution::Skip
2789            );
2790
2791            let mut vulnerabilities = VulnerabilityMap::new();
2792            vulnerabilities.insert(key.clone(), ScanOutcome::Vulnerable(dv));
2793            let (resolved, live_check_candidates) = collect_fix_target_resolutions(
2794                &vulnerabilities,
2795                &[key],
2796                &osv_name_by_key,
2797                &latest_status_map,
2798                &StubFormatter::DEFAULT,
2799            );
2800            assert!(resolved.is_empty(), "{resolved:?}");
2801            assert!(
2802                live_check_candidates.is_empty(),
2803                "{live_check_candidates:?}"
2804            );
2805        }
2806
2807        #[test]
2808        fn resolve_fix_target_skips_when_osv_name_is_unavailable() {
2809            // A live check is needed (F != latest) but no `osv_name` is on record for this
2810            // key — nothing to query, so this degrades to `Skip` rather than panicking or
2811            // building a `ScanTarget` with an empty name.
2812            let dv = dv(vec![advisory("A1", &["1.0.0"])]);
2813            let resolution = resolve_fix_target(
2814                &dv,
2815                &deps_core::test_util::vuln_key("pkg"),
2816                &HashMap::new(),
2817                &HashMap::new(),
2818                &StubFormatter::DEFAULT,
2819            );
2820            assert_eq!(resolution, FixTargetResolution::Skip);
2821        }
2822
2823        #[test]
2824        fn resolve_fix_target_skips_when_f_is_not_a_safe_version_string() {
2825            // A malformed `fixed_versions` entry (as if it somehow reached this dependency's
2826            // `advisories` despite OSV's own wire-boundary validation) must never be queued
2827            // for a live check or treated as any kind of resolvable target — `is_safe_version_string`
2828            // rejects it before anything else runs.
2829            let dv = dv(vec![advisory("A1", &["1.2.0\", \"evil\": \"true"])]);
2830            let resolution = resolve_fix_target(
2831                &dv,
2832                &deps_core::test_util::vuln_key("pkg"),
2833                &HashMap::new(),
2834                &HashMap::new(),
2835                &StubFormatter::DEFAULT,
2836            );
2837            assert_eq!(resolution, FixTargetResolution::Skip);
2838        }
2839
2840        #[test]
2841        fn collect_fix_target_resolutions_batches_multiple_dependencies_needing_live_check_into_one_vec()
2842         {
2843            // #462 NFR-001: three vulnerable dependencies — "reused" (F == latest, resolved
2844            // without a call), "live-a" and "live-b" (F != latest, both need a live check) —
2845            // must collapse into exactly one `resolved` entry and one `live_check_candidates`
2846            // Vec of length 2, proving multiple dependencies needing verification are batched
2847            // into a single prospective `check_candidates` call rather than one per dependency.
2848            let mut vulnerabilities = VulnerabilityMap::new();
2849            vulnerabilities.insert(
2850                deps_core::test_util::vuln_key("reused"),
2851                ScanOutcome::Vulnerable(dv(vec![advisory("A1", &["1.0.0"])])),
2852            );
2853            vulnerabilities.insert(
2854                deps_core::test_util::vuln_key("live-a"),
2855                ScanOutcome::Vulnerable(dv(vec![advisory("A2", &["1.2.0"])])),
2856            );
2857            vulnerabilities.insert(
2858                deps_core::test_util::vuln_key("live-b"),
2859                ScanOutcome::Vulnerable(dv(vec![advisory("A3", &["2.2.0"])])),
2860            );
2861
2862            let vulnerable_keys = vec![
2863                deps_core::test_util::vuln_key("reused"),
2864                deps_core::test_util::vuln_key("live-a"),
2865                deps_core::test_util::vuln_key("live-b"),
2866            ];
2867            let mut latest_status: deps_core::osv::LatestStatusMap = HashMap::new();
2868            latest_status.insert(
2869                deps_core::test_util::vuln_key("reused"),
2870                UpgradeStatus::CandidateClean {
2871                    version: ConcreteVersion::new("1.0.0"),
2872                },
2873            );
2874            latest_status.insert(
2875                deps_core::test_util::vuln_key("live-a"),
2876                UpgradeStatus::CandidateClean {
2877                    version: ConcreteVersion::new("9.0.0"),
2878                },
2879            );
2880            latest_status.insert(
2881                deps_core::test_util::vuln_key("live-b"),
2882                UpgradeStatus::CandidateClean {
2883                    version: ConcreteVersion::new("9.0.0"),
2884                },
2885            );
2886            let mut osv_name_by_key = HashMap::new();
2887            osv_name_by_key.insert(
2888                deps_core::test_util::vuln_key("reused"),
2889                deps_core::osv::OsvQueryName::Confirmed(
2890                    deps_core::osv::OsvPackageName::new("reused").unwrap(),
2891                ),
2892            );
2893            osv_name_by_key.insert(
2894                deps_core::test_util::vuln_key("live-a"),
2895                deps_core::osv::OsvQueryName::Confirmed(
2896                    deps_core::osv::OsvPackageName::new("live-a").unwrap(),
2897                ),
2898            );
2899            osv_name_by_key.insert(
2900                deps_core::test_util::vuln_key("live-b"),
2901                deps_core::osv::OsvQueryName::Confirmed(
2902                    deps_core::osv::OsvPackageName::new("live-b").unwrap(),
2903                ),
2904            );
2905
2906            let (resolved, live_check_candidates) = collect_fix_target_resolutions(
2907                &vulnerabilities,
2908                &vulnerable_keys,
2909                &osv_name_by_key,
2910                &latest_status,
2911                &StubFormatter::DEFAULT,
2912            );
2913
2914            assert_eq!(resolved.len(), 1, "{resolved:?}");
2915            assert_eq!(resolved[0].0.as_str(), "reused");
2916
2917            assert_eq!(live_check_candidates.len(), 2, "{live_check_candidates:?}");
2918            let keys: std::collections::HashSet<&str> = live_check_candidates
2919                .iter()
2920                .map(|t| t.key.as_str())
2921                .collect();
2922            assert!(keys.contains("live-a"));
2923            assert!(keys.contains("live-b"));
2924        }
2925
2926        #[test]
2927        fn apply_live_fix_target_statuses_sets_only_matching_keys_leaving_others_untouched() {
2928            // Case (e): a live-check batch that timed out for one dependency simply omits
2929            // its key from `statuses` — that dependency's `fix_target_status` must stay
2930            // `NotChecked` afterward, with no panic, while a dependency whose result did
2931            // arrive gets it applied.
2932            let mut vulnerabilities = VulnerabilityMap::new();
2933            vulnerabilities.insert(
2934                deps_core::test_util::vuln_key("checked"),
2935                ScanOutcome::Vulnerable(dv(vec![advisory("A1", &["1.0.0"])])),
2936            );
2937            vulnerabilities.insert(
2938                deps_core::test_util::vuln_key("timed-out"),
2939                ScanOutcome::Vulnerable(dv(vec![advisory("A2", &["1.0.0"])])),
2940            );
2941
2942            let mut statuses = HashMap::new();
2943            statuses.insert(
2944                deps_core::test_util::vuln_key("checked"),
2945                UpgradeStatus::CandidateClean {
2946                    version: ConcreteVersion::new("1.0.0"),
2947                },
2948            );
2949            // "timed-out" deliberately has no entry in `statuses`.
2950
2951            apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
2952
2953            let ScanOutcome::Vulnerable(checked) = vulnerabilities
2954                .get(&deps_core::test_util::vuln_key("checked"))
2955                .unwrap()
2956            else {
2957                panic!("expected Vulnerable");
2958            };
2959            assert_eq!(
2960                checked.fix_target_status,
2961                UpgradeStatus::CandidateClean {
2962                    version: ConcreteVersion::new("1.0.0")
2963                }
2964            );
2965
2966            let ScanOutcome::Vulnerable(timed_out) = vulnerabilities
2967                .get(&deps_core::test_util::vuln_key("timed-out"))
2968                .unwrap()
2969            else {
2970                panic!("expected Vulnerable");
2971            };
2972            assert_eq!(timed_out.fix_target_status, UpgradeStatus::NotChecked);
2973        }
2974    }
2975}