deps_cli/analyze.rs
1//! Shared parse -> lockfile -> fetch -> OSV pipeline.
2//!
3//! Extracted out of [`crate::report::check_manifest`] (spec 062) so `deps-cli update` (spec
4//! 068, #1329) can reuse the identical classification inputs `check` builds, without
5//! duplicating any of it.
6//!
7//! [`check_manifest`](crate::report::check_manifest) is now [`analyze_manifest`] followed by
8//! `generate_diagnostics`/`to_finding` — no behavior change to `check` itself.
9
10use deps_core::licenses::LicensePolicy;
11use deps_core::lsp_helpers::{
12 CooldownDisposition, DependencyOutcomes, LatestVerdict, PackageVersions, RequirementGate,
13 cooldown_disposition, latest_verdict,
14};
15use deps_core::osv::{LatestStatusMap, VulnerabilityMap};
16use deps_core::{
17 ConcreteVersion, Ecosystem, EcosystemId, FreshnessSettings, GossipFindings, LicenseSource,
18 PackageName, PublishTime, VersionData,
19};
20use deps_engine::classify::diff::{
21 merge_deprecations_after_fetch, merge_no_comparable_versions_after_fetch,
22};
23use deps_engine::classify::fetch::{
24 apply_fetch_outcomes, fetch_latest_versions_parallel, prepare_fetch,
25};
26use deps_engine::classify::license::prefetch_tier3_licenses;
27use deps_engine::classify::osv::{build_latest_check_targets, build_scan_targets};
28use deps_engine::classify::resolved::load_resolved_versions;
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31use std::sync::Arc;
32use std::time::Duration;
33
34use crate::report::{CheckContext, CheckError};
35
36/// Ceiling on the OSV scan timeout, independent of the configured `fetch_timeout_secs` —
37/// mirrors `deps-lsp`'s `document::osv_scan::OSV_SCAN_TIMEOUT_CEILING_SECS` and
38/// `report.rs`'s own (now-removed) copy of the same constant: the shared `reqwest` client
39/// behind [`deps_core::HttpCache`] already imposes its own client-wide 30s timeout, so a
40/// longer per-phase timeout would never actually bind.
41const OSV_SCAN_TIMEOUT_CEILING_SECS: u64 = 30;
42
43/// Which of [`analyze_manifest`]'s independent, network-touching phases (beyond the mandatory
44/// registry version fetch) actually run.
45///
46/// Code review finding 6: before this type existed, `analyze_manifest` always ran both the
47/// tier-3 license prefetch and the OSV scan, even for `deps-cli update`'s default mode — which
48/// reads neither `ManifestAnalysis::licenses` nor `ManifestAnalysis::vulnerabilities` at all —
49/// wasting a network round trip per dependency and burning shared rate-limit budgets (e.g.
50/// Swift's 60 req/h unauthenticated GitHub budget) on every plain `deps-cli update` run.
51///
52/// # Examples
53///
54/// ```
55/// use deps_cli::analyze::AnalysisScope;
56///
57/// let update_default_mode = AnalysisScope::update_default();
58/// assert!(!update_default_mode.licenses);
59/// assert!(!update_default_mode.vulnerabilities);
60/// assert!(update_default_mode.gossip);
61/// assert!(update_default_mode.cooldown_fallback);
62///
63/// let update_security_only = AnalysisScope::vulnerabilities_only();
64/// assert!(!update_security_only.licenses);
65/// assert!(update_security_only.vulnerabilities);
66/// assert!(!update_security_only.gossip);
67/// assert!(!update_security_only.cooldown_fallback);
68/// ```
69#[expect(
70 clippy::struct_excessive_bools,
71 reason = "each field is an independent phase-gate knob (license/vulnerability/gossip/\
72 cooldown-fallback), not overlapping state a two-variant enum could express \
73 more clearly — mirrors StubFormatter's identical rationale"
74)]
75#[derive(Debug, Clone, Copy)]
76pub struct AnalysisScope {
77 /// Whether to run the tier-3 license prefetch (Dart/Swift/Gradle/Deno — a no-op for every
78 /// other ecosystem regardless of this flag). Only [`crate::report::check_manifest`] reads
79 /// license data; no `update` mode does.
80 pub licenses: bool,
81 /// Whether to run the OSV vulnerability scan, subject to the existing
82 /// `diagnostics.vulnerabilities_enabled`/`network.offline` policy gates either way. `check`
83 /// and `update --security-only` both need this; `update`'s default mode does not.
84 pub vulnerabilities: bool,
85 /// Whether to run the spec 074 GOSSIP prefetch, subject to the existing `[gossip].enabled`
86 /// policy gate either way (issue #1521 item 4). **Deliberately independent of
87 /// `[freshness].enabled`** — spec 074 FR-002/FR-009/NFR-004 enumerate GOSSIP's gates
88 /// exhaustively (`[gossip].enabled`, not offline, a `deps_dev_system`-covered ecosystem, a
89 /// public-registry-content source) and never include freshness; GOSSIP and the local
90 /// `freshness.cooldown_secs` heuristic are deliberately independent signals (NFR-004),
91 /// mirroring `deps-lsp`'s own `run_gossip_prefetch`, which likewise gates only on
92 /// `is_gossip_enabled()`/offline. `false` under `update --security-only`: that mode's fix
93 /// target comes from the advisory's `recommended_fix()`, never the freshness/GOSSIP-filtered
94 /// registry pick (FR-014), so the prefetch's result would never be read — an avoidable
95 /// network call.
96 pub gossip: bool,
97 /// Whether to run spec 075 FR-010's extra, uncapped OSV round verifying every occurrence's
98 /// cooldown-fallback candidate. Only `true` for `update`'s default mode: `check` never
99 /// writes a fallback candidate (spec 075 §1 Out of Scope) and `--security-only`'s fix
100 /// target always comes from the advisory, never a freshness/GOSSIP-filtered pick — so
101 /// neither reads [`ManifestAnalysis::fallback_status`], and running this round for them
102 /// would be a wasted network call (NFR-004).
103 pub cooldown_fallback: bool,
104}
105
106impl AnalysisScope {
107 /// Both phases run — [`crate::report::check_manifest`]'s scope, and the default for any
108 /// caller that reads everything `ManifestAnalysis` can carry.
109 #[must_use]
110 pub const fn all() -> Self {
111 Self {
112 licenses: true,
113 vulnerabilities: true,
114 gossip: true,
115 cooldown_fallback: false,
116 }
117 }
118
119 /// Only the OSV scan runs — `deps-cli update --security-only`'s scope. `gossip` is `false`
120 /// (issue #1521 item 4): see [`Self::gossip`]'s doc.
121 #[must_use]
122 pub const fn vulnerabilities_only() -> Self {
123 Self {
124 licenses: false,
125 vulnerabilities: true,
126 gossip: false,
127 cooldown_fallback: false,
128 }
129 }
130
131 /// Neither the license nor the vulnerability-classification phase runs —
132 /// `deps-cli update`'s default-mode scope. Named for that one caller (not `none()`,
133 /// its pre-#1517 name) since [`analyze_manifest`] still unconditionally runs the OSV
134 /// **latest-check** (issue #1517) regardless of this scope: `update`'s default mode
135 /// must never write a flagged/unverified `latest` into the manifest, so that check is
136 /// not one of the two phases this scope can opt out of. `gossip` is `true`: default mode
137 /// is exactly the consumer spec 074's cooldown filter exists for. `cooldown_fallback` is
138 /// `true`: spec 075's fallback-candidate OSV round.
139 #[must_use]
140 pub const fn update_default() -> Self {
141 Self {
142 licenses: false,
143 vulnerabilities: false,
144 gossip: true,
145 cooldown_fallback: true,
146 }
147 }
148}
149
150/// One manifest's fully-assembled classification inputs.
151///
152/// The parsed dependencies, every lock-file/registry/OSV-derived map [`VersionData`]
153/// borrows, and the two registry/license-fetch incompleteness signals `check`/`update` both
154/// need for their own exit-code decisions.
155///
156/// Built by [`analyze_manifest`]; call [`Self::version_data`] to get the borrowed
157/// [`VersionData`] view [`deps_core::Ecosystem::generate_diagnostics`] and
158/// [`deps_core::edit::collect_update_edits`]/[`deps_core::edit::plan_vulnerability_fix`] all
159/// take.
160pub struct ManifestAnalysis {
161 /// The parsed manifest.
162 pub parse_result: Box<dyn deps_core::ParseResult>,
163 /// The manifest's file URI (derived from the path `analyze_manifest` was given).
164 pub uri: url::Url,
165 /// The instant this analysis ran, captured once (fix-cycle item 9/security L3).
166 ///
167 /// `analyze_manifest`'s own fallback-candidate OSV round (FR-010) and `deps-cli update`'s
168 /// planner (`plan_updates`) must evaluate `cooldown_disposition` against the SAME `now` —
169 /// two independent `PublishTime::now()` calls straddling the OSV round could, under
170 /// backward clock skew, let the planner see a dependency as `Blocked` that the OSV-gating
171 /// pass never verified a fallback for (`fallback_status` would then read `None`, degrading
172 /// to `NotApplicable` and writing an unverified fallback). Callers building `plan_updates`'s
173 /// `now` argument should use this field rather than calling `PublishTime::now()` again.
174 pub now: PublishTime,
175 /// The manifest's ecosystem.
176 pub ecosystem_id: EcosystemId,
177 /// Latest known versions and full version lists from the registry.
178 pub cached_versions: HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
179 /// Versions actually resolved in the lock file.
180 pub resolved_versions: HashMap<PackageName, ConcreteVersion>,
181 /// Every lock-file-resolved version for a package name, when more than one is retained
182 /// (issue #649).
183 pub resolved_version_candidates: HashMap<PackageName, Vec<ConcreteVersion>>,
184 /// Yanked/deprecation/fetch-failure/no-comparable-versions findings from the fetch.
185 pub outcomes: DependencyOutcomes,
186 /// OSV scan results, when the scan ran (enabled and not offline).
187 pub vulnerabilities: Option<VulnerabilityMap>,
188 /// Phase B's per-key "latest" check result (issue #1517), when the check ran (enabled and
189 /// not offline) — populated regardless of `AnalysisScope`, since `update`'s default mode
190 /// needs this even though it opts out of both `licenses` and `vulnerabilities`. See
191 /// [`deps_core::osv::LatestStatusMap`] for the map's fail-closed-on-absence contract.
192 pub latest_status: Option<LatestStatusMap>,
193 /// Spec 075 FR-010: a separate OSV verdict map for every occurrence's cooldown-fallback
194 /// candidate, keyed identically to [`Self::latest_status`] but populated from a
195 /// fallback-substituted version view — NEVER merged into [`Self::latest_status`], since
196 /// [`LatestStatusMap`] has no version key and a merge would silently overwrite `latest`'s
197 /// own verdict. `None` when [`AnalysisScope::cooldown_fallback`] is `false`, the OSV
198 /// check is disabled/offline, or no dependency has a stored fallback candidate to verify
199 /// (NFR-004: this round costs zero extra network calls in that case).
200 pub fallback_status: Option<LatestStatusMap>,
201 /// Spec 075 FR-010's fallback-substituted view of [`Self::cached_versions`] (only
202 /// `latest` swapped for each dependency's stored cooldown-fallback candidate, when one
203 /// exists) — the exact view this OSV round already built to verify a fallback candidate
204 /// against, retained here so `deps-cli update`'s planner (`plan_updates`) can reuse it
205 /// instead of recomputing an identical `cooldown_fallback_view` from scratch (issue
206 /// #1551 finding 3). Same gate as [`Self::fallback_status`]: `None` when
207 /// [`AnalysisScope::cooldown_fallback`] is `false`, or no dependency's [`cooldown_disposition`]
208 /// actually differs from [`Self::cached_versions`] (NFR-004: nothing to substitute).
209 pub cooldown_fallback_view: Option<HashMap<PackageName, PackageVersions>>,
210 /// Already-computed GOSSIP findings (spec 074/075 FR-006), retained here instead of being
211 /// discarded after the registry fetch — fixes `check`'s dead `with_gossip_prefetch` branch
212 /// (`ManifestAnalysis::version_data` never called it before this field existed), so `check`
213 /// and `update` consult the same [`deps_core::lsp_helpers::cooldown_disposition`] precedence
214 /// end to end. Empty when GOSSIP is disabled/offline/unsupported, never absent.
215 pub gossip_findings: HashMap<PackageName, deps_core::GossipFindings>,
216 /// License data backfilled from the registry fetch (tier 1) and the tier-3 prefetch,
217 /// keyed by raw package name.
218 pub licenses: HashMap<PackageName, Vec<String>>,
219 /// The resolved SPDX allow/deny license policy for this run.
220 pub license_policy: LicensePolicy,
221 /// How license strings were sourced (registry-declared SPDX vs. free text).
222 pub license_source: LicenseSource,
223 /// The [`deps_core::NetworkMode`] set for this run.
224 pub network: deps_core::NetworkMode,
225 /// Raw package names whose registry fetch errored or timed out (`FetchResult::fetch_failed`,
226 /// captured before [`apply_fetch_outcomes`] consumes it) — the two-signal input
227 /// `deps-cli update --security-only`'s `Unfixable` classification needs (FR-011): a
228 /// dependency is `Unfixable` when it appears here **or** has no [`Self::cached_versions`]
229 /// entry.
230 pub fetch_failed: HashSet<PackageName>,
231 /// Whether at least one dependency's *version* registry fetch failed while not offline.
232 pub registry_unreachable: bool,
233 /// Whether at least one dependency's tier-3 license fetch timed out while not offline.
234 pub license_fetch_incomplete: bool,
235}
236
237impl ManifestAnalysis {
238 /// The borrowed [`VersionData`] view over this analysis's maps.
239 #[must_use]
240 pub fn version_data(&self) -> VersionData<'_> {
241 let mut version_data = VersionData::new(&self.cached_versions, &self.resolved_versions)
242 .with_resolved_version_candidates(&self.resolved_version_candidates)
243 .with_outcomes(&self.outcomes)
244 .with_ecosystem(self.ecosystem_id)
245 .with_network(self.network)
246 .with_license_source(self.license_source)
247 .with_license_policy(&self.license_policy)
248 .with_license_prefetch(&self.licenses)
249 .with_gossip_prefetch(&self.gossip_findings);
250 if let Some(vulnerabilities) = self.vulnerabilities.as_ref() {
251 version_data = version_data.with_vulnerabilities(vulnerabilities);
252 }
253 if let Some(latest_status) = self.latest_status.as_ref() {
254 version_data = version_data.with_latest_status(latest_status);
255 }
256 version_data
257 }
258
259 /// Whether any [`deps_core::lsp_helpers::RequirementStatus::Outdated`] dependency actually
260 /// in scope for this run's plan (per `package_filter`/`ignore_rules`, issue #1517 critique
261 /// S4) came back [`LatestVerdict::Unverified`] (issue #1517) — a transient OSV
262 /// failure/timeout, or the check never having run at all despite being enabled. Callers
263 /// (`deps-cli update`'s default mode) treat this the same as
264 /// [`Self::registry_unreachable`]: abort the whole run rather than silently omitting just
265 /// the affected dependency from the plan, since an unverifiable check must never be
266 /// mistaken for a clean one.
267 ///
268 /// `package_filter` is matched via `crate::update::is_requested`, and `ignore_rules` via
269 /// [`crate::update::ignore::IgnoreRules::matches_name`] (a name-only match, deliberately not
270 /// [`crate::update::ignore::IgnoreRules::skip_reason`]'s kind-scoped one: this abort-check
271 /// runs before any concrete `current`/target pair exists to classify an [`UpdateKind`] from).
272 /// A dependency this widens past scope (a kind-scoped ignore rule that would not actually
273 /// have matched this update) is not a regression: `deps_core::edit::collect_update_candidates`
274 /// applies the exact same [`latest_verdict`] gate per-candidate independently, so an
275 /// unverified `latest` for it still surfaces as `Skipped(NotSafelyEditable(LatestUnverified))`
276 /// in the final plan (a nonzero exit) rather than silently vanishing — this check only
277 /// controls whether the *whole run* aborts early with a clearer message, not whether the
278 /// unverified dependency itself is ever caught.
279 ///
280 /// [`UpdateKind`]: deps_core::edit::UpdateKind
281 #[must_use]
282 pub fn has_unverified_latest_check(
283 &self,
284 formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
285 package_filter: &[String],
286 ignore_rules: &crate::update::ignore::IgnoreRules,
287 ) -> bool {
288 let vuln_keys = deps_core::osv::vulnerability_keys(
289 self.parse_result.as_ref(),
290 &self.resolved_versions,
291 Some(&self.resolved_version_candidates),
292 formatter,
293 self.ecosystem_id,
294 );
295 self.parse_result.dependencies().into_iter().any(|dep| {
296 let normalized_name = formatter.normalize_package_name(dep.name());
297 if !crate::update::is_requested(package_filter, &normalized_name, formatter)
298 || ignore_rules.matches_name(&normalized_name)
299 {
300 return false;
301 }
302 let Some(latest) = self
303 .cached_versions
304 .get(normalized_name.as_str())
305 .or_else(|| self.cached_versions.get(dep.name()))
306 .map(|v| &v.latest)
307 else {
308 return false;
309 };
310 let Some(version_req) = dep.version_requirement() else {
311 return false;
312 };
313 if formatter.requirement_status_for(dep, version_req, latest)
314 != deps_core::lsp_helpers::RequirementStatus::Outdated
315 {
316 return false;
317 }
318 matches!(
319 latest_verdict(
320 self.latest_status.as_ref(),
321 dep,
322 Some(&vuln_keys),
323 &normalized_name,
324 latest.as_str(),
325 formatter,
326 ),
327 LatestVerdict::Unverified
328 )
329 })
330 }
331}
332
333/// Builds a fallback view of `cached_versions`: every package whose [`cooldown_disposition`]
334/// is `Blocked { fallback: Some(_), .. }` has its `latest` swapped for the stored
335/// [`deps_core::lsp_helpers::CooldownFallback`] candidate, everything else left unchanged.
336///
337/// Feeds both spec 075 FR-010's OSV verification round (this module) and FR-007's unified
338/// planner pipeline (`crate::update`) the exact same substituted view, via
339/// [`deps_core::edit::collect_update_candidates`]/[`build_latest_check_targets`] reading
340/// `PackageVersions::latest` as they always do — so a fallback candidate is verified and
341/// planned against identically, with no separate code path to drift out of sync.
342pub(crate) fn cooldown_fallback_view(
343 cached_versions: &HashMap<PackageName, PackageVersions>,
344 gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
345 freshness: FreshnessSettings,
346 now: PublishTime,
347) -> HashMap<PackageName, PackageVersions> {
348 cached_versions
349 .iter()
350 .map(|(name, versions)| {
351 let mut substituted = versions.clone();
352 if let CooldownDisposition::Blocked {
353 fallback: Some(fallback),
354 ..
355 } = cooldown_disposition(versions, name, freshness, gossip_prefetch, now)
356 {
357 substituted.latest = fallback.version.clone();
358 }
359 (name.clone(), substituted)
360 })
361 .collect()
362}
363
364/// Parses `content`, resolves in-use/lock-file versions, and fetches latest registry versions.
365///
366/// Per `scope`, also runs the tier-3 license prefetch and/or an OSV scan (each additionally
367/// subject to its own existing policy/offline gates either way).
368///
369/// The shared prefix [`crate::report::check_manifest`] and `deps-cli update` both need,
370/// extracted verbatim from `check_manifest`'s former body (spec 068 T007). `scope` exists so
371/// `update`'s default mode (which reads neither `licenses` nor `vulnerabilities`) does not pay
372/// for phases nothing in its plan consumes — see [`AnalysisScope`]'s doc (code review finding
373/// 6).
374///
375/// # Errors
376///
377/// Returns [`CheckError::InvalidPath`] if `manifest_path` cannot be represented as a file
378/// URI, or [`CheckError::Parse`] if `content` fails to parse as this ecosystem's manifest
379/// format.
380pub async fn analyze_manifest(
381 ecosystem: &Arc<dyn Ecosystem>,
382 manifest_path: &Path,
383 content: &str,
384 ctx: &CheckContext,
385 scope: AnalysisScope,
386) -> Result<ManifestAnalysis, CheckError> {
387 // Fix-cycle item 9/security L3: captured once, threaded through this function's own
388 // fallback-OSV-round gate below and stored on the returned `ManifestAnalysis` for
389 // `plan_updates` to reuse — never a second independent `PublishTime::now()` call.
390 let now = PublishTime::now();
391 let uri = crate::report::path_to_uri(manifest_path).ok_or_else(|| CheckError::InvalidPath {
392 path: manifest_path.to_path_buf(),
393 })?;
394 let parse_result = deps_core::parse_manifest_blocking(ecosystem, content, &uri)
395 .await
396 .map_err(|source| CheckError::Parse {
397 path: manifest_path.to_path_buf(),
398 source,
399 })?;
400 let formatter = ecosystem.formatter();
401 let ecosystem_id = ecosystem.ecosystem_id();
402
403 // A one-shot check has no prior in-memory resolved-version state to protect from a
404 // transient parse failure the way `deps-lsp` does, so the reload-ok signal (issue
405 // #1407) isn't needed here.
406 let (resolved_versions, resolved_version_candidates) =
407 load_resolved_versions(&uri, &ctx.lockfile_cache, ecosystem.as_ref())
408 .await
409 .into_maps();
410
411 let prep = prepare_fetch(
412 parse_result.as_ref(),
413 formatter,
414 ecosystem_id,
415 &resolved_versions,
416 &resolved_version_candidates,
417 );
418 let collided_names = prep.collided_names;
419 let attempted_names: Vec<PackageName> = prep
420 .dep_sources
421 .iter()
422 .map(|(name, _)| name.clone())
423 .collect();
424
425 // Spec 074 FR-002: one batch prefetch per manifest, mirroring `prefetch_tier3_licenses`'s
426 // own "prefetch once, thread the result through" shape below. `None` when
427 // `!ctx.policy.gossip.enabled` short-circuits `fetch_gossip_findings_batch` before any
428 // HTTP call (FR-009) — construction of `ctx.deps_dev` itself is unconditional (FR-001).
429 // Issue #1521 item 4: also `None` under `scope.gossip == false` (`update --security-only`,
430 // whose fix target never reads a GOSSIP-filtered `latest` at all) — see `AnalysisScope::gossip`'s
431 // doc for why this is *not* additionally gated on `ctx.policy.freshness.enabled`.
432 let network = ctx.policy.network.mode();
433 let gossip_client = (scope.gossip && ctx.policy.gossip.enabled).then_some(&ctx.deps_dev);
434 let gossip_findings = deps_core::lsp_helpers::fetch_gossip_findings_batch(
435 ecosystem_id,
436 parse_result.as_ref(),
437 formatter,
438 network,
439 gossip_client,
440 )
441 .await;
442
443 let fetch_result = fetch_latest_versions_parallel(
444 ecosystem.registry(),
445 prep.dep_sources,
446 &prep.in_use,
447 None,
448 ctx.policy.freshness.to_freshness(),
449 ctx.policy.cache.fetch_timeout_secs,
450 ctx.policy.cache.max_concurrent_fetches,
451 &prep.selection_context,
452 Some(&gossip_findings),
453 )
454 .await;
455 // `fetch_failed` (genuine failures only), not `failure_summary`'s count, which also
456 // includes not-found lookups — using that here made a typo'd dependency exit 2 every run.
457 let registry_unreachable = network.is_online() && !fetch_result.fetch_failed.is_empty();
458 // Captured before `apply_fetch_outcomes` consumes `fetch_result.fetch_failed` below —
459 // `deps-cli update --security-only`'s FR-011 two-signal `Unfixable` rule needs the raw
460 // set independently of the yanked/deprecation-merged `DependencyOutcomes`.
461 let fetch_failed: HashSet<PackageName> = fetch_result.fetch_failed.keys().cloned().collect();
462
463 let mut outcomes = DependencyOutcomes::new();
464 let fetched_names: Vec<PackageName> = fetch_result.versions.keys().cloned().collect();
465 apply_fetch_outcomes(
466 &mut outcomes,
467 fetch_result.yanked_versions,
468 fetch_result.fetch_failed,
469 collided_names,
470 formatter,
471 );
472 merge_deprecations_after_fetch(
473 &mut outcomes,
474 &fetched_names,
475 fetch_result.deprecations,
476 formatter,
477 );
478 merge_no_comparable_versions_after_fetch(
479 &mut outcomes,
480 &attempted_names,
481 fetch_result.no_comparable_versions,
482 formatter,
483 );
484 let cached_versions = fetch_result.versions;
485 // Tier-1 license backfill (issue #660/#661 precedent): populated for native-list
486 // ecosystems (PyPI, Composer) whose registry response carries a license field.
487 let mut licenses = fetch_result.licenses;
488
489 // Hoisted so both the tier-3 gate below and the returned `ManifestAnalysis` share one
490 // computed policy (issue #1133 critic M1).
491 let license_policy = ctx.policy.license_policy.to_policy();
492
493 // Tier-3 license prefetch (issue #1133, populated for Dart/Swift/Gradle/Deno, a no-op
494 // for every other ecosystem — see `deps_engine::classify::license`'s doc) and the OSV
495 // scan are independent (OSV never reads licenses) and both make network round trips, so
496 // they run concurrently via `tokio::join!` rather than sequentially (critic M2) —
497 // mirrors `deps-lsp`, which spawns both as separate concurrent tasks.
498 //
499 // Only `!offline` is gated here — the non-empty-`license_policy` check (critic M1) is
500 // enforced *inside* `prefetch_tier3_licenses` itself (code-review finding #3), not
501 // re-derived at this call site, so it can't be silently forgotten by a future caller:
502 // `licenses`' only consumer in this crate is `apply_license_policy_rule`, a no-op when
503 // no policy is configured, so with the default (empty) policy this call would otherwise
504 // issue N network round trips for a result nothing reads — burning Swift's
505 // unauthenticated 60 req/h GitHub budget among others for nothing. `scope.licenses` adds a
506 // second, caller-declared reason to skip this entirely (code review finding 6) — no
507 // `update` mode ever reads `ManifestAnalysis::licenses`.
508 let run_tier3_prefetch = scope.licenses && network.is_online();
509 let tier3_license_fetch = async {
510 if run_tier3_prefetch {
511 prefetch_tier3_licenses(
512 ecosystem.as_ref(),
513 parse_result.as_ref(),
514 &resolved_versions,
515 &resolved_version_candidates,
516 &license_policy,
517 ctx.policy.cache.fetch_timeout_secs,
518 ctx.policy.cache.max_concurrent_fetches,
519 )
520 .await
521 } else {
522 deps_engine::classify::license::TierThreeLicenseFetch::default()
523 }
524 };
525
526 // `scope.vulnerabilities` (code review finding 6): `update`'s default mode never reads
527 // `ManifestAnalysis::vulnerabilities`, so it declares this scope out entirely rather than
528 // paying for a scan nothing consumes.
529 let run_osv_scan = scope.vulnerabilities
530 && ctx.policy.diagnostics.vulnerabilities_enabled
531 && network.is_online();
532 let osv_scan = async {
533 if run_osv_scan {
534 let (targets, skipped) = build_scan_targets(
535 parse_result.as_ref(),
536 &resolved_versions,
537 &resolved_version_candidates,
538 formatter,
539 ecosystem_id,
540 );
541 let mut vulns = skipped;
542 if !targets.is_empty() {
543 let timeout = Duration::from_secs(
544 ctx.policy
545 .cache
546 .fetch_timeout_secs
547 .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
548 );
549 let scanned = ctx.osv.scan(ecosystem_id, &targets, timeout).await;
550 vulns.extend(scanned);
551 }
552 Some(vulns)
553 } else {
554 None
555 }
556 };
557
558 // Issue #1517: unconditional on `scope` (unlike the OSV vulnerability scan above) —
559 // `update`'s default mode never reads `ManifestAnalysis::vulnerabilities`, but it always
560 // needs to know whether the `latest` it's about to write is itself safe. Still gated on
561 // the same `vulnerabilities_enabled`/`!offline` policy every other OSV call respects.
562 let run_latest_check = ctx.policy.diagnostics.vulnerabilities_enabled && network.is_online();
563
564 // Spec 075 FR-010: the fallback-candidate OSV round only fires when
565 // `scope.cooldown_fallback` is set AND at least one dependency's `cooldown_disposition`
566 // actually found `Blocked { fallback: Some(_) }` — NFR-004: zero extra network calls
567 // otherwise (a view identical to `cached_versions` has nothing new to verify).
568 let cooldown_fallback_view_map = scope
569 .cooldown_fallback
570 .then(|| {
571 cooldown_fallback_view(
572 &cached_versions,
573 Some(&gossip_findings),
574 ctx.policy.freshness.to_freshness(),
575 now,
576 )
577 })
578 .filter(|view| {
579 view.iter().any(|(name, v)| {
580 cached_versions
581 .get(name)
582 .is_none_or(|c| c.latest != v.latest)
583 })
584 });
585 let run_fallback_check = run_latest_check && cooldown_fallback_view_map.is_some();
586
587 // FR-010: computed once and shared between the latest-status and fallback-status futures
588 // below, instead of each independently re-deriving the same map.
589 let vuln_keys = (run_latest_check || run_fallback_check).then(|| {
590 deps_core::osv::vulnerability_keys(
591 parse_result.as_ref(),
592 &resolved_versions,
593 Some(&resolved_version_candidates),
594 formatter,
595 ecosystem_id,
596 )
597 });
598
599 let latest_check = async {
600 let (true, Some(vuln_keys)) = (run_latest_check, vuln_keys.as_ref()) else {
601 return None;
602 };
603 let (targets, mut latest_status) = build_latest_check_targets(
604 parse_result.as_ref(),
605 &cached_versions,
606 vuln_keys,
607 formatter,
608 );
609 if !targets.is_empty() {
610 let timeout = Duration::from_secs(
611 ctx.policy
612 .cache
613 .fetch_timeout_secs
614 .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
615 );
616 let checked = ctx
617 .osv
618 .check_candidates(ecosystem_id, &targets, timeout)
619 .await;
620 latest_status.extend(checked);
621 }
622 Some(latest_status)
623 };
624
625 // Spec 075 FR-010: reuses `build_latest_check_targets` (never a new OSV-request builder)
626 // over the fallback-substituted view, so the fallback candidate is verified exactly the
627 // way `latest` itself is. Result is a wholly separate map — never merged into
628 // `latest_status` (see `ManifestAnalysis::fallback_status`'s doc for why).
629 let fallback_check = async {
630 let (true, Some(vuln_keys), Some(view)) = (
631 run_fallback_check,
632 vuln_keys.as_ref(),
633 cooldown_fallback_view_map.as_ref(),
634 ) else {
635 return None;
636 };
637 let (targets, mut fallback_status) =
638 build_latest_check_targets(parse_result.as_ref(), view, vuln_keys, formatter);
639 if !targets.is_empty() {
640 let timeout = Duration::from_secs(
641 ctx.policy
642 .cache
643 .fetch_timeout_secs
644 .min(OSV_SCAN_TIMEOUT_CEILING_SECS),
645 );
646 let checked = ctx
647 .osv
648 .check_candidates(ecosystem_id, &targets, timeout)
649 .await;
650 fallback_status.extend(checked);
651 }
652 Some(fallback_status)
653 };
654
655 let (tier3_result, vulnerabilities, latest_status, fallback_status): (
656 _,
657 Option<VulnerabilityMap>,
658 _,
659 _,
660 ) = tokio::join!(tier3_license_fetch, osv_scan, latest_check, fallback_check);
661
662 // Merged (not replaced) alongside the tier-1 backfill above via `entry().or_insert()`,
663 // not `extend` (critic nit): the two sources are disjoint today (only Composer
664 // populates `FetchResult::licenses`, and it's `RegistryDeclaredSpdx`, never a tier-3
665 // ecosystem), but `or_insert` makes the intended precedence explicit — an
666 // author-declared tier-1 license must win over a heuristic tier-3 one if that ever
667 // stops holding, rather than whichever call happened to run last.
668 for (name, license) in tier3_result.licenses {
669 licenses.entry(name).or_insert(license);
670 }
671 // A confirmed tier-3 timeout feeds the same exit-2 "incomplete report" signal a
672 // registry-unreachable manifest does, but through its own field (issue #1133 code-review
673 // finding #1) — not `registry_unreachable` itself: a Maven Central outage while the
674 // version registry is fully reachable is a different failure than an unreachable
675 // registry, and a caller inspecting `registry_unreachable` must not be misled into
676 // diagnosing the wrong system.
677 let license_fetch_incomplete = tier3_result.timed_out > 0;
678
679 Ok(ManifestAnalysis {
680 parse_result,
681 uri,
682 now,
683 ecosystem_id,
684 cached_versions,
685 resolved_versions,
686 resolved_version_candidates,
687 outcomes,
688 vulnerabilities,
689 latest_status,
690 fallback_status,
691 cooldown_fallback_view: cooldown_fallback_view_map,
692 gossip_findings,
693 licenses,
694 license_policy,
695 license_source: ecosystem.license_source(),
696 network,
697 fetch_failed,
698 registry_unreachable,
699 license_fetch_incomplete,
700 })
701}
702
703#[cfg(test)]
704mod has_unverified_latest_check_tests {
705 use super::ManifestAnalysis;
706 use crate::update::ignore::IgnoreRules;
707 use deps_core::licenses::LicensePolicy;
708 use deps_core::lsp_helpers::{DependencyOutcomes, PackageVersions};
709 use deps_core::osv::LatestStatusMap;
710 use deps_core::parser::DependencySource;
711 use deps_core::position::{Position, Range};
712 use deps_core::test_util::StubFormatter;
713 use deps_core::{Dependency, EcosystemId, PackageName, ParseResult, VersionReq};
714 use std::any::Any;
715 use std::collections::{HashMap, HashSet};
716
717 struct MockDep {
718 name: PackageName,
719 version_req: VersionReq,
720 version_range: Range,
721 }
722 impl Dependency for MockDep {
723 fn name(&self) -> &PackageName {
724 &self.name
725 }
726 fn name_range(&self) -> Range {
727 Range::default()
728 }
729 fn version_requirement(&self) -> Option<&VersionReq> {
730 Some(&self.version_req)
731 }
732 fn version_range(&self) -> Option<Range> {
733 Some(self.version_range)
734 }
735 fn source(&self) -> DependencySource {
736 DependencySource::Registry
737 }
738 fn as_any(&self) -> &dyn Any {
739 self
740 }
741 }
742
743 struct MockParseResult {
744 deps: Vec<MockDep>,
745 uri: url::Url,
746 }
747 impl ParseResult for MockParseResult {
748 fn dependencies(&self) -> Vec<&dyn Dependency> {
749 self.deps.iter().map(|d| d as &dyn Dependency).collect()
750 }
751 fn workspace_root(&self) -> Option<&std::path::Path> {
752 None
753 }
754 fn uri(&self) -> &url::Url {
755 &self.uri
756 }
757 fn as_any(&self) -> &dyn Any {
758 self
759 }
760 }
761
762 /// One outdated `serde` dependency ("1.0.0" -> cached latest "1.2.0"), with `latest_status`
763 /// set to `Some(&empty map)` — the exact pre-phase-B/never-checked state `latest_verdict`
764 /// treats as `Unverified` (fail closed).
765 fn analysis_with_one_outdated_unverified_dep() -> ManifestAnalysis {
766 let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
767 let mut cached_versions = HashMap::new();
768 cached_versions.insert(
769 PackageName::new("serde"),
770 PackageVersions::latest_only("1.2.0"),
771 );
772
773 ManifestAnalysis {
774 parse_result: Box::new(MockParseResult {
775 deps: vec![MockDep {
776 name: PackageName::new("serde"),
777 version_req: VersionReq::new("1.0.0"),
778 version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
779 }],
780 uri: uri.clone(),
781 }),
782 uri,
783 now: deps_core::PublishTime::now(),
784 ecosystem_id: EcosystemId::Cargo,
785 cached_versions,
786 resolved_versions: HashMap::new(),
787 resolved_version_candidates: HashMap::new(),
788 outcomes: DependencyOutcomes::new(),
789 vulnerabilities: None,
790 latest_status: Some(LatestStatusMap::new()),
791 fallback_status: None,
792 cooldown_fallback_view: None,
793 gossip_findings: HashMap::new(),
794 licenses: HashMap::new(),
795 license_policy: LicensePolicy::default(),
796 license_source: deps_core::LicenseSource::default(),
797 network: deps_core::NetworkMode::Online,
798 fetch_failed: HashSet::new(),
799 registry_unreachable: false,
800 license_fetch_incomplete: false,
801 }
802 }
803
804 /// Baseline: an unfiltered, unignored outdated dependency with no OSV verdict for its
805 /// cached latest is reported as unverified.
806 #[test]
807 fn true_for_outdated_unverified_dependency_in_scope() {
808 let analysis = analysis_with_one_outdated_unverified_dep();
809 assert!(analysis.has_unverified_latest_check(
810 &StubFormatter::DEFAULT,
811 &[],
812 &IgnoreRules::empty(),
813 ));
814 }
815
816 /// Issue #1517 critique S4: a `--package` filter that does not name the unverified
817 /// dependency must exclude it from this abort-check, the same scoping
818 /// `deps_cli::update::is_requested` applies to the actual plan.
819 #[test]
820 fn false_when_package_filter_excludes_the_dependency() {
821 let analysis = analysis_with_one_outdated_unverified_dep();
822 assert!(!analysis.has_unverified_latest_check(
823 &StubFormatter::DEFAULT,
824 &["some-other-package".to_string()],
825 &IgnoreRules::empty(),
826 ));
827 }
828
829 /// A `--package` filter that does name the dependency still reports it.
830 #[test]
831 fn true_when_package_filter_names_the_dependency() {
832 let analysis = analysis_with_one_outdated_unverified_dep();
833 assert!(analysis.has_unverified_latest_check(
834 &StubFormatter::DEFAULT,
835 &["serde".to_string()],
836 &IgnoreRules::empty(),
837 ));
838 }
839
840 /// Issue #1517 critique S4: an `[update].ignore` rule naming the dependency must exclude
841 /// it from this abort-check too, regardless of the rule's `update_types` scope (a name-only
842 /// match — see `has_unverified_latest_check`'s own doc for why it does not attempt
843 /// `IgnoreRules::skip_reason`'s kind-scoped match here).
844 #[test]
845 fn false_when_an_ignore_rule_names_the_dependency() {
846 let analysis = analysis_with_one_outdated_unverified_dep();
847 let rules = IgnoreRules::new(
848 vec![crate::config::IgnoreRule {
849 name: "serde".to_string(),
850 update_types: None,
851 }],
852 &StubFormatter::DEFAULT,
853 );
854 assert!(!analysis.has_unverified_latest_check(&StubFormatter::DEFAULT, &[], &rules));
855 }
856}