deps_core/edit.rs
1//! Protocol-agnostic manifest-edit planning (issue #1329, spec 068).
2//!
3//! Ungated (unlike `lsp_helpers::code_lenses`/`lsp_helpers::code_actions`, which require the
4//! `lsp-responses` feature): `deps-cli update` links this module directly,
5//! and CI forbids `tower-lsp-server` from reaching `deps-cli`'s non-dev dependency tree (spec
6//! 064, #1083). [`collect_update_edits`] and [`plan_vulnerability_fix`] are the domain-level
7//! planners `deps-lsp`'s `lsp_helpers::code_lenses::collect_update_all_edits` and
8//! `lsp_helpers::code_actions::build_vulnerability_fix_action` now delegate to as thin
9//! adapters — the extraction shape spec 064 already applied to [`crate::diagnostic::Diagnostic`]
10//! and spec 063 applied to [`crate::Dependency`]/[`crate::lockfile::LockFileCache`].
11
12use crate::lsp_helpers::{
13 EcosystemFormatter, LineOffsetTable, RequirementGate, RequirementStatus, VersionData,
14 is_safe_version_string, literal_span_matches, requirement_is_oversized,
15 requirement_len_exceeds_cap, resolve_in_use_version, slice_for_range, strip_whitespace,
16 warn_rejected_value,
17};
18use crate::{ConcreteVersion, Dependency, ParseResult, VersionReq};
19
20/// Whether `current` — or `dep`'s own literal version span — is an unexpanded placeholder.
21///
22/// Checks both `current` (the requirement text a caller is about to consider rewriting) and
23/// [`Dependency::version_literal`] (when present) rather than `current` alone, folding
24/// `deps-swift`'s literal-vs-synthesized-comparator distinction (a `from: "\(v)"` declaration's
25/// `current` is a synthesized `">=\(v), <1.0.0"` comparator, while `version_literal` is the raw
26/// `\(v)` interpolation actually embedded in the manifest) into one shared check instead of a
27/// per-ecosystem `format_version_replacing_for` override having to re-derive it.
28///
29/// This is the gate every central edit-planning call site
30/// ([`collect_update_candidates`], [`plan_verified_fix`],
31/// `crate::lsp_helpers::code_actions`'s unsatisfiable-requirement fix builder and REFACTOR
32/// "Update to X" loop) checks before ever calling [`replacement_text`] — see that function's
33/// doc for why the two are split into a boolean gate and a text-producing step rather than one
34/// combined call.
35///
36/// # Examples
37///
38/// ```
39/// use deps_core::edit::requirement_is_placeholder_for;
40/// use deps_core::lsp_helpers::{DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering, RequirementResolution, SourcePolicy};
41/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
42///
43/// struct PlainFormatter;
44/// impl PackageNaming for PlainFormatter {}
45/// impl PackageRendering for PlainFormatter {
46/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
47/// version.to_string()
48/// }
49/// fn package_url(&self, name: &PackageName) -> String {
50/// name.as_str().to_string()
51/// }
52/// }
53/// impl RequirementResolution for PlainFormatter {}
54/// impl DiagnosticMessages for PlainFormatter {}
55/// impl DiagnosticPolicy for PlainFormatter {}
56/// impl SourcePolicy for PlainFormatter {}
57/// impl OsvNaming for PlainFormatter {}
58///
59/// struct PlainDependency(PackageName);
60/// impl Dependency for PlainDependency {
61/// fn name(&self) -> &PackageName {
62/// &self.0
63/// }
64/// fn name_range(&self) -> deps_core::position::Range {
65/// deps_core::position::Range::default()
66/// }
67/// fn version_requirement(&self) -> Option<&VersionReq> {
68/// None
69/// }
70/// fn version_range(&self) -> Option<deps_core::position::Range> {
71/// None
72/// }
73/// fn source(&self) -> deps_core::parser::DependencySource {
74/// deps_core::parser::DependencySource::Registry
75/// }
76/// fn as_any(&self) -> &dyn std::any::Any {
77/// self
78/// }
79/// }
80///
81/// let dep = PlainDependency(PackageName::new("example"));
82/// assert!(!requirement_is_placeholder_for(&PlainFormatter, &dep, "^1.2"));
83/// assert!(requirement_is_placeholder_for(&PlainFormatter, &dep, "{{ version }}"));
84/// ```
85#[must_use]
86pub fn requirement_is_placeholder_for(
87 formatter: &dyn EcosystemFormatter,
88 dep: &dyn Dependency,
89 current: &str,
90) -> bool {
91 formatter.requirement_is_placeholder(&VersionReq::new(current))
92 || dep
93 .version_literal()
94 .is_some_and(|literal| formatter.requirement_is_placeholder(&VersionReq::new(literal)))
95}
96
97/// The only production path that rewrites a manifest's version-requirement text.
98///
99/// `None` when [`requirement_is_placeholder_for`] says `current`/`dep`'s literal is an
100/// unexpanded placeholder, else `Some(formatter.format_version_replacing_for(dep, version,
101/// current))`.
102///
103/// Centralizing the placeholder gate here — rather than leaving it to each ecosystem's
104/// [`PackageRendering::format_version_replacing`](crate::lsp_helpers::PackageRendering::format_version_replacing)/
105/// [`format_version_replacing_for`](crate::lsp_helpers::PackageRendering::format_version_replacing_for)
106/// override to re-check — means a formatter implementation can never destructively rewrite a
107/// placeholder no matter how it is reached: the 14 previously hand-rolled per-crate guards
108/// inside those methods are gone (#1391), and this free function is the sole call site that
109/// still invokes them in production. Every central edit-planning call site
110/// ([`collect_update_candidates`], [`plan_verified_fix`],
111/// `crate::lsp_helpers::code_actions`'s unsatisfiable-requirement fix builder and REFACTOR
112/// "Update to X" loop) already checks [`requirement_is_placeholder_for`] as an early gate for
113/// its own control-flow reasons (skip vs. `Err`/`None`/`continue` differ per caller) before
114/// ever reaching this call — that earlier check makes this function's own `None` branch
115/// unreachable in practice, and is kept anyway as a fail-closed defense-in-depth backstop
116/// rather than relied upon as the only gate.
117///
118/// # Examples
119///
120/// ```
121/// use deps_core::edit::replacement_text;
122/// use deps_core::lsp_helpers::{DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering, RequirementResolution, SourcePolicy};
123/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
124///
125/// struct PlainFormatter;
126/// impl PackageNaming for PlainFormatter {}
127/// impl PackageRendering for PlainFormatter {
128/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
129/// version.to_string()
130/// }
131/// fn package_url(&self, name: &PackageName) -> String {
132/// name.as_str().to_string()
133/// }
134/// }
135/// impl RequirementResolution for PlainFormatter {}
136/// impl DiagnosticMessages for PlainFormatter {}
137/// impl DiagnosticPolicy for PlainFormatter {}
138/// impl SourcePolicy for PlainFormatter {}
139/// impl OsvNaming for PlainFormatter {}
140///
141/// struct PlainDependency(PackageName);
142/// impl Dependency for PlainDependency {
143/// fn name(&self) -> &PackageName {
144/// &self.0
145/// }
146/// fn name_range(&self) -> deps_core::position::Range {
147/// deps_core::position::Range::default()
148/// }
149/// fn version_requirement(&self) -> Option<&VersionReq> {
150/// None
151/// }
152/// fn version_range(&self) -> Option<deps_core::position::Range> {
153/// None
154/// }
155/// fn source(&self) -> deps_core::parser::DependencySource {
156/// deps_core::parser::DependencySource::Registry
157/// }
158/// fn as_any(&self) -> &dyn std::any::Any {
159/// self
160/// }
161/// }
162///
163/// let dep = PlainDependency(PackageName::new("example"));
164/// assert_eq!(
165/// replacement_text(&PlainFormatter, &dep, &ConcreteVersion::new("1.2.3"), "^1.0"),
166/// Some("1.2.3".to_string())
167/// );
168/// assert_eq!(
169/// replacement_text(&PlainFormatter, &dep, &ConcreteVersion::new("1.2.3"), "{{ version }}"),
170/// None
171/// );
172/// ```
173#[must_use]
174pub fn replacement_text(
175 formatter: &dyn EcosystemFormatter,
176 dep: &dyn Dependency,
177 version: &ConcreteVersion,
178 current: &str,
179) -> Option<String> {
180 if requirement_is_placeholder_for(formatter, dep, current) {
181 return None;
182 }
183 Some(formatter.format_version_replacing_for(dep, version, current))
184}
185
186/// Protocol-agnostic replacement for `ls_types::TextEdit` — a single manifest-text
187/// replacement.
188///
189/// # Examples
190///
191/// ```
192/// use deps_core::edit::ManifestEdit;
193/// use deps_core::position::{Position, Range};
194///
195/// let edit = ManifestEdit {
196/// range: Range::new(Position::new(0, 9), Position::new(0, 14)),
197/// new_text: "1.2.0".to_string(),
198/// };
199/// assert_eq!(edit.new_text, "1.2.0");
200/// ```
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ManifestEdit {
203 /// The span in the manifest source this edit replaces.
204 pub range: crate::position::Range,
205 /// The replacement text.
206 pub new_text: String,
207}
208
209/// A manifest-edit plan with attribution — what makes ignore-rule/`--package` filtering
210/// possible. The `ls_types::TextEdit` this generalizes discards everything but the edit
211/// itself.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct PlannedUpdate {
214 /// The dependency's declared (raw) name.
215 pub name: String,
216 /// [`crate::lsp_helpers::PackageNaming::normalize_package_name`]'s output for `name`.
217 pub normalized_name: String,
218 /// The dependency name's span in the manifest source.
219 pub name_range: crate::position::Range,
220 /// The version this dependency is currently pinned to, when it could be resolved (see
221 /// [`collect_update_edits`]'s doc for exactly how). `None` covers two distinct cases: a
222 /// genuinely unresolvable in-use version (`collect_update_candidates`) — a caller that
223 /// classifies it via [`classify_update`] gets [`UpdateKind::Unknown`] for this case, the
224 /// same as for any other string [`classify_update`] can't parse as a leading dotted-numeric
225 /// version — and [`plan_verified_fix`], which never attempts to resolve this field at all
226 /// and always leaves it `None` regardless of whether the in-use version is knowable.
227 pub current: Option<ConcreteVersion>,
228 /// The version this edit would move the dependency to.
229 pub target: ConcreteVersion,
230 /// The edit itself.
231 pub edit: ManifestEdit,
232}
233
234/// A type with a `[start, end)` span, generalizing over [`ManifestEdit`] (this crate) and
235/// `ls_types::TextEdit` (the `lsp-responses`-gated impl in [`crate::lsp_helpers`]).
236///
237/// Implemented locally for a foreign type (`ls_types::TextEdit`) in the gated module —
238/// orphan-rule-clean since this trait is local to this crate.
239pub trait EditSpan {
240 /// The span's inclusive start, as `(line, character)`.
241 fn start(&self) -> (u32, u32);
242 /// The span's exclusive end, as `(line, character)`.
243 fn end(&self) -> (u32, u32);
244}
245
246impl EditSpan for ManifestEdit {
247 fn start(&self) -> (u32, u32) {
248 (self.range.start.line, self.range.start.character)
249 }
250
251 fn end(&self) -> (u32, u32) {
252 (self.range.end.line, self.range.end.character)
253 }
254}
255
256impl EditSpan for PlannedUpdate {
257 fn start(&self) -> (u32, u32) {
258 self.edit.start()
259 }
260
261 fn end(&self) -> (u32, u32) {
262 self.edit.end()
263 }
264}
265
266/// Sorts `edits` by start position and drops any edit that overlaps the previous one.
267///
268/// A dropped edit's start falls before the previous (surviving) edit's end — an overlap no
269/// LSP `WorkspaceEdit`/manifest rewrite can apply safely. Generic replacement for the
270/// type-specific `TextEdit` version this crate carried before #1329 — see [`EditSpan`]'s doc.
271///
272/// **Breaking**: this is a breaking `pub` API change on `deps-core` (the old signature only
273/// accepted `Vec<ls_types::TextEdit>`) — see `CHANGELOG.md`'s `[Unreleased]` section.
274///
275/// `caller` names the collector in the `tracing::warn!` emitted for each dropped edit.
276///
277/// # Examples
278///
279/// ```
280/// use deps_core::edit::{ManifestEdit, dedup_overlapping_edits};
281/// use deps_core::position::{Position, Range};
282///
283/// let edits = vec![
284/// ManifestEdit {
285/// range: Range::new(Position::new(0, 0), Position::new(0, 5)),
286/// new_text: "a".to_string(),
287/// },
288/// ManifestEdit {
289/// range: Range::new(Position::new(0, 2), Position::new(0, 7)),
290/// new_text: "b".to_string(),
291/// },
292/// ];
293/// let kept = dedup_overlapping_edits(edits, "test");
294/// assert_eq!(kept.len(), 1);
295/// ```
296pub fn dedup_overlapping_edits<E: EditSpan>(mut edits: Vec<E>, caller: &str) -> Vec<E> {
297 edits.sort_by_key(EditSpan::start);
298
299 let mut non_overlapping: Vec<E> = Vec::with_capacity(edits.len());
300 for edit in edits {
301 let overlaps_prev = non_overlapping
302 .last()
303 .is_some_and(|prev: &E| edit.start() < prev.end());
304 if overlaps_prev {
305 tracing::warn!(
306 start = ?edit.start(),
307 end = ?edit.end(),
308 caller,
309 "dropping overlapping edit"
310 );
311 continue;
312 }
313 non_overlapping.push(edit);
314 }
315
316 non_overlapping
317}
318
319/// Applies `edits` to `content`, returning the rewritten manifest text.
320///
321/// `edits` must be non-overlapping (see [`dedup_overlapping_edits`]) — this function does not
322/// itself check for overlap, it splices every edit's range in reverse start-position order so
323/// an earlier edit's byte offsets are never invalidated by a later one's length change. Each
324/// edit's `range.start` must not come after its own `range.end` (an inverted range); an edit
325/// violating this precondition is skipped rather than applied — every current parser
326/// produces structurally ordered ranges, so this is a defense-in-depth guard against a
327/// future parser bug, not an expected code path (this function is `pub` `deps-core` API
328/// consumed in-process by `deps-cli`, unlike its pre-#1329 callers, which only ever handed
329/// equivalent ranges back to an LSP client as `TextEdit`s for the editor to apply).
330///
331/// # Examples
332///
333/// ```
334/// use deps_core::edit::{ManifestEdit, apply_edits};
335/// use deps_core::position::{Position, Range};
336///
337/// let content = "serde = \"1.0.0\"\n";
338/// let edits = vec![ManifestEdit {
339/// range: Range::new(Position::new(0, 9), Position::new(0, 14)),
340/// new_text: "1.2.0".to_string(),
341/// }];
342/// assert_eq!(apply_edits(content, &edits), "serde = \"1.2.0\"\n");
343/// ```
344#[must_use]
345pub fn apply_edits(content: &str, edits: &[ManifestEdit]) -> String {
346 let table = LineOffsetTable::new(content);
347 let mut byte_edits: Vec<(usize, usize, &str)> = edits
348 .iter()
349 .filter_map(|edit| {
350 let start = table.position_to_byte_offset(content, edit.range.start);
351 let end = table.position_to_byte_offset(content, edit.range.end);
352 if start > end {
353 tracing::warn!(range = ?edit.range, "dropping edit with an inverted range");
354 return None;
355 }
356 Some((start, end, edit.new_text.as_str()))
357 })
358 .collect();
359 byte_edits.sort_by_key(|&(start, ..)| start);
360
361 let mut result = content.to_string();
362 for &(start, end, new_text) in byte_edits.iter().rev() {
363 result.replace_range(start..end, new_text);
364 }
365 result
366}
367
368/// Re-parses manifest text, abstracting over the production path.
369///
370/// [`EcosystemReparse`]/[`crate::ecosystem::parse_manifest_now`] is the production impl, so
371/// [`crate::lsp_helpers::fallback_edit_excludes_newer`] (spec 076 FR-024) can be unit-tested
372/// with a stub instead of a real ecosystem parser.
373///
374/// FR-024's guard validates the manifest's EFFECTIVE post-edit requirement, not the replacement
375/// span's own text in isolation — some grammars (Swift's `from:`/`.exact`/`.upToNextMinor`
376/// labels, Bundler's multi-constraint literals) build the effective requirement from context
377/// outside the edited span, so only re-parsing the whole edited document proves what the edit
378/// actually expresses.
379///
380/// # Examples
381///
382/// ```
383/// use deps_core::edit::ManifestReparse;
384///
385/// let reparse = |_content: &str| None;
386/// assert!(reparse.reparse("anything").is_none());
387/// ```
388pub trait ManifestReparse {
389 /// Re-parses `content` (an edited scratch copy of a manifest), or `None` if it could not be
390 /// parsed (fail closed).
391 fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>>;
392}
393
394/// Blanket impl so a test can pass a plain closure as a [`ManifestReparse`] instead of defining
395/// a stub type.
396impl<F: Fn(&str) -> Option<Box<dyn ParseResult>>> ManifestReparse for F {
397 fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>> {
398 self(content)
399 }
400}
401
402/// Production [`ManifestReparse`]: re-parses via [`crate::ecosystem::parse_manifest_now`].
403///
404/// That is the synchronous, dependency-count-capped sibling of
405/// [`crate::ecosystem::parse_manifest_blocking`] (spec 076 FR-024).
406///
407/// # Examples
408///
409/// ```
410/// use deps_core::edit::{EcosystemReparse, ManifestReparse};
411/// use deps_core::Ecosystem;
412/// use url::Url;
413///
414/// # fn example(ecosystem: &dyn Ecosystem, uri: Url) {
415/// let reparse = EcosystemReparse {
416/// ecosystem,
417/// uri: &uri,
418/// };
419/// let _ = reparse.reparse("content");
420/// # }
421/// ```
422pub struct EcosystemReparse<'a> {
423 /// The ecosystem to re-parse through.
424 pub ecosystem: &'a dyn crate::Ecosystem,
425 /// The manifest document's URI, for position tracking.
426 pub uri: &'a url::Url,
427}
428
429impl ManifestReparse for EcosystemReparse<'_> {
430 fn reparse(&self, content: &str) -> Option<Box<dyn ParseResult>> {
431 crate::ecosystem::parse_manifest_now(self.ecosystem, content, self.uri)
432 }
433}
434
435/// The magnitude of a version bump, per semver-shaped leading-numeric-segment comparison.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum UpdateKind {
438 /// The leading numeric segment (major) differs.
439 Major,
440 /// Major is equal, minor differs.
441 Minor,
442 /// Major and minor are equal (only patch, or nothing, differs).
443 Patch,
444 /// `from`/`to` could not both be classified as a version with an unambiguous leading
445 /// dotted-numeric segment (a SHA pin, a Go pseudo-version, a range/comparator string,
446 /// `*`/`latest`/`workspace:*`, Gradle's `{strictly}!!{preferred}`, ...).
447 Unknown,
448}
449
450/// Classifies the version bump from `from` to `to`.
451///
452/// Returns [`UpdateKind::Unknown`] unless **both** strings have an unambiguous leading
453/// dotted-numeric segment (an optional `v`/`V` prefix, then one or more all-digit
454/// dot-separated components, then either nothing or a `-`/`+` suffix) — deliberately not
455/// built on [`crate::lsp_helpers::is_same_major_minor`], whose permissive `_ => true`
456/// fallback arm is wrong for this purpose. A Go pseudo-version
457/// (`v0.0.0-20210101000000-abcdef123456`) would otherwise parse as a plain leading version
458/// (`0.0.0`) by accident, so it is rejected by an explicit shape check before the general
459/// parse runs.
460///
461/// # Examples
462///
463/// ```
464/// use deps_core::edit::{UpdateKind, classify_update};
465///
466/// assert_eq!(classify_update("1.2.3", "2.0.0"), UpdateKind::Major);
467/// assert_eq!(classify_update("1.2.3", "1.3.0"), UpdateKind::Minor);
468/// assert_eq!(classify_update("1.2.3", "1.2.4"), UpdateKind::Patch);
469/// assert_eq!(classify_update("*", "1.0.0"), UpdateKind::Unknown);
470/// assert_eq!(
471/// classify_update("v0.0.0-20210101000000-abcdef123456", "v1.0.0"),
472/// UpdateKind::Unknown
473/// );
474/// ```
475#[must_use]
476pub fn classify_update(from: &str, to: &str) -> UpdateKind {
477 match (
478 parse_leading_dotted_numeric(from),
479 parse_leading_dotted_numeric(to),
480 ) {
481 (Some(f), Some(t)) => {
482 if f.0 != t.0 {
483 UpdateKind::Major
484 } else if f.1 != t.1 {
485 UpdateKind::Minor
486 } else {
487 UpdateKind::Patch
488 }
489 }
490 _ => UpdateKind::Unknown,
491 }
492}
493
494/// Whether `s` has the shape of a Go pseudo-version: an optional `v`/`V` prefix, then a
495/// component ending in a hyphen-separated 14-digit timestamp and a 12-hex-digit commit hash
496/// (`vX.Y.Z-yyyymmddhhmmss-abcdefabcdef`, or the pre-release-shaped
497/// `vX.Y.Z-0.yyyymmddhhmmss-abcdefabcdef`, whose timestamp segment carries a `0.` prefix).
498///
499/// Checked before [`parse_leading_dotted_numeric`] runs its general parse, since a pseudo-
500/// version's leading `vX.Y.Z` would otherwise parse as an ordinary version by accident.
501fn is_go_pseudo_version(s: &str) -> bool {
502 let body = s.strip_prefix(['v', 'V']).unwrap_or(s);
503 let mut parts = body.rsplitn(3, '-');
504 let Some(hash) = parts.next() else {
505 return false;
506 };
507 let Some(timestamp) = parts.next() else {
508 return false;
509 };
510 if parts.next().is_none() {
511 return false;
512 }
513 let timestamp = timestamp.strip_prefix("0.").unwrap_or(timestamp);
514 hash.len() == 12
515 && hash.bytes().all(|b| b.is_ascii_hexdigit())
516 && timestamp.len() == 14
517 && timestamp.bytes().all(|b| b.is_ascii_digit())
518}
519
520/// Parses `s`'s leading `(major, minor, patch)` dotted-numeric segment, or `None` when `s` is
521/// not unambiguously one (see [`classify_update`]'s doc). A component beyond the third is
522/// still required to be all-digit (so a 4-component version like NuGet's assembly-version
523/// style is accepted), but only the first three are returned.
524fn parse_leading_dotted_numeric(s: &str) -> Option<(u64, u64, u64)> {
525 if is_go_pseudo_version(s) {
526 return None;
527 }
528 let stripped = s.strip_prefix(['v', 'V']).unwrap_or(s);
529 let core = match stripped.find(['-', '+']) {
530 Some(idx) => stripped.get(..idx)?,
531 None => stripped,
532 };
533 if core.is_empty() {
534 return None;
535 }
536
537 let mut major = 0u64;
538 let mut minor = 0u64;
539 let mut patch = 0u64;
540 let mut count = 0usize;
541 for part in core.split('.') {
542 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
543 return None;
544 }
545 let n: u64 = part.parse().ok()?;
546 match count {
547 0 => major = n,
548 1 => minor = n,
549 2 => patch = n,
550 _ => {}
551 }
552 count += 1;
553 }
554 if count == 0 {
555 return None;
556 }
557 Some((major, minor, patch))
558}
559
560/// Default-mode planner: every dependency [`RequirementStatus::Outdated`] would report, as a
561/// [`PlannedUpdate`].
562///
563/// Verbatim move of the logic `lsp_helpers::code_lenses::collect_update_all_edits` used to
564/// implement directly (moved here for #1329/spec 068's `deps_core::edit` extraction; that
565/// function is now a thin adapter over this one — see this crate's `lib.rs` "LSP type
566/// stability" doc section) — the literal-span guard, the `is_safe_version_string` check, the
567/// `requirement_status_for == Outdated` filter, the no-op guard, and overlap dedup are
568/// unchanged. See that function's historical doc (preserved in git history) for the full
569/// per-guard rationale.
570///
571/// [`PlannedUpdate::current`] is the dependency's resolved in-use version
572/// ([`resolve_in_use_version`], **per occurrence**, never the collapsed per-name map — spec
573/// 050's fix), when `versions` carries an [`crate::EcosystemId`]
574/// ([`VersionData::with_ecosystem`]) and resolution succeeds; empty otherwise, which
575/// [`classify_update`] always reports as [`UpdateKind::Unknown`] against any target.
576///
577/// # Examples
578///
579/// ```
580/// use deps_core::edit::collect_update_edits;
581/// use deps_core::lsp_helpers::{
582/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
583/// PackageVersions, RequirementResolution, SourcePolicy, VersionData,
584/// };
585/// use deps_core::{ConcreteVersion, Dependency, ParseResult, PackageName, VersionReq};
586/// use std::any::Any;
587/// use std::collections::HashMap;
588/// use deps_core::position::{Position, Range};
589///
590/// struct MockFormatter;
591/// impl PackageNaming for MockFormatter {}
592/// impl PackageRendering for MockFormatter {
593/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
594/// version.to_string()
595/// }
596/// fn package_url(&self, name: &PackageName) -> String {
597/// format!("https://example.com/{}", name.as_str())
598/// }
599/// }
600/// impl RequirementResolution for MockFormatter {}
601/// impl DiagnosticMessages for MockFormatter {}
602/// impl DiagnosticPolicy for MockFormatter {}
603/// impl SourcePolicy for MockFormatter {}
604/// impl OsvNaming for MockFormatter {}
605///
606/// struct MockDep {
607/// name: PackageName,
608/// version_req: VersionReq,
609/// version_range: Range,
610/// name_range: Range,
611/// }
612/// impl Dependency for MockDep {
613/// fn name(&self) -> &PackageName { &self.name }
614/// fn name_range(&self) -> Range { self.name_range }
615/// fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
616/// fn version_range(&self) -> Option<Range> { Some(self.version_range) }
617/// fn source(&self) -> deps_core::parser::DependencySource {
618/// deps_core::parser::DependencySource::Registry
619/// }
620/// fn as_any(&self) -> &dyn Any { self }
621/// }
622///
623/// struct MockParseResult { deps: Vec<MockDep>, uri: url::Url }
624/// impl ParseResult for MockParseResult {
625/// fn dependencies(&self) -> Vec<&dyn Dependency> {
626/// self.deps.iter().map(|d| d as &dyn Dependency).collect()
627/// }
628/// fn workspace_root(&self) -> Option<&std::path::Path> { None }
629/// fn uri(&self) -> &url::Url { &self.uri }
630/// fn as_any(&self) -> &dyn Any { self }
631/// }
632///
633/// let content = r#"serde = "1.0.0""#;
634/// let parse_result = MockParseResult {
635/// deps: vec![MockDep {
636/// name: PackageName::new("serde"),
637/// version_req: VersionReq::new("1.0.0"),
638/// version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
639/// name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
640/// }],
641/// uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
642/// };
643///
644/// let mut cached = HashMap::new();
645/// cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
646/// let resolved = HashMap::new();
647///
648/// let planned = collect_update_edits(
649/// &parse_result,
650/// content,
651/// VersionData::new(&cached, &resolved),
652/// &MockFormatter,
653/// );
654///
655/// assert_eq!(planned.len(), 1);
656/// assert_eq!(planned[0].edit.new_text, "1.2.0");
657/// assert_eq!(planned[0].name, "serde");
658/// ```
659#[must_use]
660pub fn collect_update_edits(
661 parse_result: &dyn ParseResult,
662 content: &str,
663 versions: VersionData<'_>,
664 formatter: &dyn EcosystemFormatter,
665) -> Vec<PlannedUpdate> {
666 let planned: Vec<PlannedUpdate> =
667 collect_update_candidates(parse_result, content, versions, formatter)
668 .into_iter()
669 .filter_map(|candidate| match candidate {
670 UpdateCandidate::Planned(planned) => Some(planned),
671 UpdateCandidate::Unplannable { .. } => None,
672 })
673 .collect();
674
675 dedup_overlapping_edits(planned, "collect_update_edits")
676}
677
678/// Why a dependency [`crate::lsp_helpers::RequirementStatus::Outdated`] could not become a
679/// writable [`PlannedUpdate`].
680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681pub enum UnplannableReason {
682 /// The registry's cached `latest` value failed the [`is_safe_version_string`] gate.
683 UnsafeLatestVersion,
684 /// The declared span does not hold the literal requirement text — a Maven
685 /// `${property}` reference, a Gradle DSL variable/version-catalog alias, or similar.
686 NonLiteralSpan,
687 /// The formatter's rewrite would be a no-op (no single unambiguous rewrite exists, e.g.
688 /// `deps-gradle`'s `{strictly}!!{preferred}` shorthand).
689 NoOpRewrite,
690 /// Issue #1517: OSV checked this exact `latest` version and found it affected by a
691 /// non-informational advisory (or a confirmed-malicious record) —
692 /// [`crate::lsp_helpers::LatestVerdict::Flagged`]. Writing this version into the manifest
693 /// would be the exact supply-chain exposure this variant exists to prevent; the shared
694 /// planner this variant lives in ([`collect_update_candidates`]) is used by both the LSP
695 /// code lens and `deps-cli update`'s default mode, so both refuse it identically.
696 LatestFlaggedByOsv,
697 /// Issue #1517: `latest`'s OSV status could not be verified —
698 /// [`crate::lsp_helpers::LatestVerdict::Unverified`] (never checked, a transient
699 /// failure/timeout, or the checked version has since diverged from what's now cached as
700 /// `latest`). Fails closed the same way [`Self::LatestFlaggedByOsv`] does: an unverified
701 /// version is never distinguishable from a flagged one at write time, so neither is ever
702 /// planned.
703 LatestUnverified,
704}
705
706/// One [`crate::lsp_helpers::RequirementStatus::Outdated`] dependency's planning outcome.
707#[derive(Debug, Clone)]
708pub enum UpdateCandidate {
709 /// A safe edit was planned.
710 Planned(PlannedUpdate),
711 /// The dependency is `Outdated` but no edit could be safely produced — callers that
712 /// report per-dependency outcomes (`deps-cli update`) must surface this rather than
713 /// silently dropping the dependency; [`collect_update_edits`] (the LSP-facing planner,
714 /// which only needs the writable subset) drops it instead.
715 Unplannable {
716 /// The dependency's declared (raw) name.
717 name: String,
718 /// [`crate::lsp_helpers::PackageNaming::normalize_package_name`]'s output for `name`.
719 normalized_name: String,
720 /// The dependency name's span in the manifest source.
721 name_range: crate::position::Range,
722 /// Why no edit could be planned.
723 reason: UnplannableReason,
724 },
725}
726
727/// Every [`crate::lsp_helpers::RequirementStatus::Outdated`] dependency, classified into
728/// [`UpdateCandidate::Planned`] or [`UpdateCandidate::Unplannable`].
729///
730/// [`collect_update_edits`] is a thin filter over this function (only the `Planned` subset,
731/// deduped) — this is the richer form `deps-cli update`'s default-mode planner consumes so an
732/// `Unplannable` dependency can be reported to the operator instead of vanishing from the
733/// plan with no signal (spec 068 S4).
734///
735/// A dependency that never reaches `Outdated` status at all (no `version_range`, unknown to
736/// the registry, no declared requirement, an empty requirement, or genuinely up to date) is
737/// not a candidate and produces no entry here — reporting those would be noise, not signal
738/// (most manifest dependencies are exactly this case on any given run). An unexpanded
739/// placeholder ([`crate::lsp_helpers::RequirementResolution::bounded_requirement_is_placeholder`],
740/// #1370) is checked and skipped the same way, independent of whatever status an ecosystem's
741/// own classification logic reports for it — this is one of the four central edit-planning
742/// gates that predicate consults.
743#[must_use]
744pub fn collect_update_candidates(
745 parse_result: &dyn ParseResult,
746 content: &str,
747 versions: VersionData<'_>,
748 formatter: &dyn EcosystemFormatter,
749) -> Vec<UpdateCandidate> {
750 let deps = parse_result.dependencies();
751 let mut candidates: Vec<UpdateCandidate> = Vec::with_capacity(deps.len());
752 // Built once and reused for every dependency below (matches the pre-extraction
753 // `collect_update_all_edits`'s own rationale for doing so).
754 let line_offsets = LineOffsetTable::new(content);
755 // Issue #1517: shared by both the LSP code lens (`collect_update_edits`) and `deps-cli
756 // update`'s default mode planner — the single gate neither can bypass.
757 let vuln_keys = versions.ecosystem.map(|ecosystem| {
758 crate::osv::vulnerability_keys(
759 parse_result,
760 versions.resolved,
761 versions.resolved_version_candidates,
762 formatter,
763 ecosystem,
764 )
765 });
766
767 for dep in deps {
768 let Some(version_range) = dep.version_range() else {
769 continue;
770 };
771
772 let normalized_name = formatter.normalize_package_name(dep.name());
773 let Some(latest) = versions
774 .cached
775 .get(normalized_name.as_str())
776 .or_else(|| versions.cached.get(dep.name()))
777 .map(|v| &v.latest)
778 else {
779 continue;
780 };
781
782 let Some(version_req) = dep.version_requirement() else {
783 continue;
784 };
785 if version_req.as_str().is_empty() {
786 // Defense-in-depth: an empty requirement would trivially satisfy the literal-span
787 // guard below (both sides normalize to "") and could anchor an edit on a
788 // non-literal span.
789 continue;
790 }
791 // #1370: central placeholder gate, checked independently of `requirement_status_for`'s
792 // own classification — a placeholder is never a rewrite candidate regardless of what
793 // status an ecosystem's own (possibly still-buggy) classification logic reports for it,
794 // the same defense-in-depth reasoning as the other three central edit-planning gates.
795 if requirement_is_placeholder_for(formatter, dep, version_req.as_str()) {
796 continue;
797 }
798 if formatter.requirement_status_for(dep, version_req, latest) != RequirementStatus::Outdated
799 {
800 continue;
801 }
802
803 // From here, `dep` is a confirmed `Outdated` candidate — every remaining guard below
804 // must surface as `UpdateCandidate::Unplannable`, not a silent `continue`.
805 let name = dep.name().as_str().to_string();
806 let name_range = dep.name_range();
807
808 if !is_safe_version_string(latest.as_str()) {
809 warn_rejected_value(
810 "is_safe_version_string",
811 "update edit plan",
812 latest.as_str(),
813 );
814 candidates.push(UpdateCandidate::Unplannable {
815 name,
816 normalized_name,
817 name_range,
818 reason: UnplannableReason::UnsafeLatestVersion,
819 });
820 continue;
821 }
822
823 // Issue #1517: never plan an edit that writes a flagged/unverified `latest` into the
824 // manifest — the exact live-exploitable gap this issue closes (`deps-cli update`'s
825 // default mode previously wrote a known-malicious version straight into the manifest).
826 // Checked before the literal-span/no-op guards below: those are about *whether* an
827 // edit can be produced at all, this is about whether the edit's *target* is safe to
828 // write, and the latter must never be skipped just because the former also failed.
829 match crate::lsp_helpers::latest_verdict(
830 versions.latest_status,
831 dep,
832 vuln_keys.as_ref(),
833 &normalized_name,
834 latest.as_str(),
835 formatter,
836 ) {
837 crate::lsp_helpers::LatestVerdict::Verified
838 | crate::lsp_helpers::LatestVerdict::NotApplicable => {}
839 crate::lsp_helpers::LatestVerdict::Flagged { .. } => {
840 candidates.push(UpdateCandidate::Unplannable {
841 name,
842 normalized_name,
843 name_range,
844 reason: UnplannableReason::LatestFlaggedByOsv,
845 });
846 continue;
847 }
848 crate::lsp_helpers::LatestVerdict::Unverified => {
849 candidates.push(UpdateCandidate::Unplannable {
850 name,
851 normalized_name,
852 name_range,
853 reason: UnplannableReason::LatestUnverified,
854 });
855 continue;
856 }
857 }
858
859 // Intentionally not calling `dependency_version_range_is_literal` (#919) —
860 // empty-requirement semantics differ (edit: nothing to update; completion: everything
861 // to offer).
862 let slice = slice_for_range(content, &line_offsets, version_range);
863 let literal_target = dep
864 .version_literal()
865 .unwrap_or_else(|| version_req.as_str());
866 if !literal_span_matches(slice, literal_target) {
867 candidates.push(UpdateCandidate::Unplannable {
868 name,
869 normalized_name,
870 name_range,
871 reason: UnplannableReason::NonLiteralSpan,
872 });
873 continue;
874 }
875
876 // Unreachable after the gate above; fails closed rather than unwrapping.
877 let Some(new_text) = replacement_text(formatter, dep, latest, version_req.as_str()) else {
878 continue;
879 };
880 if strip_whitespace(&new_text) == strip_whitespace(literal_target) {
881 candidates.push(UpdateCandidate::Unplannable {
882 name,
883 normalized_name,
884 name_range,
885 reason: UnplannableReason::NoOpRewrite,
886 });
887 continue;
888 }
889
890 // FR-003: per occurrence, never the collapsed per-name map — a renamed/aliased
891 // dependency (spec 050) must classify against its own resolved pin. `None` here means
892 // genuinely unresolvable (not, as for `plan_verified_fix`, simply never attempted) — a
893 // caller that classifies this field via `classify_update` gets `UpdateKind::Unknown`
894 // for it, since there is no version string to pass in.
895 let current = versions.ecosystem.and_then(|ecosystem| {
896 resolve_in_use_version(
897 dep,
898 &normalized_name,
899 versions.resolved,
900 versions.resolved_version_candidates,
901 formatter,
902 ecosystem,
903 )
904 });
905
906 candidates.push(UpdateCandidate::Planned(PlannedUpdate {
907 name,
908 normalized_name,
909 name_range,
910 current,
911 target: latest.clone(),
912 edit: ManifestEdit {
913 range: version_range,
914 new_text,
915 },
916 }));
917 }
918
919 candidates
920}
921
922/// Why [`plan_vulnerability_fix`] could not produce a [`PlannedUpdate`] for a dependency
923/// already known to be [`crate::osv::ScanOutcome::Vulnerable`].
924///
925/// Mirrors [`UnplannableReason`]'s role for the default-mode planner (spec 068's typed-skip
926/// convention) — before this type existed, every one of these six causes collapsed into a
927/// single `None`, forcing `deps-cli update --security-only`'s
928/// `classify_vulnerable_dependency` to re-run [`resolve_recommended_fix`]'s and
929/// `fix_target_is_verified`'s own chain itself just to tell `NoVerifiedFix` apart from
930/// `RequiresLockfileUpdate` (#1350).
931#[derive(Debug, Clone, Copy, PartialEq, Eq)]
932pub enum VulnFixSkip {
933 /// No advisory on `dv` has a claimable fix — [`crate::osv::DependencyVulnerabilities::recommended_fix`]
934 /// returned `None`.
935 NoRecommendedFix,
936 /// The recommended fix's version, converted to this ecosystem's native namespace, failed
937 /// [`is_safe_version_string`].
938 UnsafeVersion,
939 /// The internal fix-target verification gate could not confirm the fix target F against
940 /// OSV — F was never live-checked, or the live check found F still vulnerable to an
941 /// advisory this recommendation claims to resolve.
942 UnverifiedTarget,
943 /// The dependency's declared requirement, left unedited, already resolves forward to the
944 /// fix target under this ecosystem's own resolution rules — nothing to rewrite (#1344).
945 RequirementAlreadyResolves,
946 /// The formatter's rewrite would be textually identical to the declared literal — no edit
947 /// to make.
948 NoOpRewrite,
949 /// `current` is an unexpanded placeholder/interpolation
950 /// ([`crate::lsp_helpers::RequirementResolution::bounded_requirement_is_placeholder`]) — there is
951 /// no concrete version text to replace, so the requirement is never rewritten regardless
952 /// of what any other resolution predicate or the formatter's own rewrite logic would do
953 /// with it (#1370).
954 UnresolvedPlaceholder,
955 /// `current`, the dependency's requirement, or its version literal exceeds
956 /// [`crate::lsp_helpers::MAX_REQUIREMENT_LEN`] — unmodellable text is never rewritten,
957 /// fail-closed and distinct from a placeholder so a caller can report the size cause.
958 OversizedRequirement,
959}
960
961/// Resolves and validates the OSV-recommended fix for `dv`.
962///
963/// The common prefix every vulnerability-fix caller needs before it can decide what to do
964/// next: is there a fix, and is its version string safe to act on. Shared by
965/// [`plan_vulnerability_fix`] and `deps-engine`'s phase-B fix-target verification
966/// (`classify::osv::resolve_fix_target`), which independently ran the same two steps before
967/// this was extracted (#1350).
968///
969/// # Errors
970///
971/// Returns [`VulnFixSkip::NoRecommendedFix`] when `dv.recommended_fix(latest)` is `None`, or
972/// [`VulnFixSkip::UnsafeVersion`] when the fix's version fails [`is_safe_version_string`].
973///
974/// # Arguments
975///
976/// * `latest` - This dependency's entry in the shared [`crate::osv::LatestStatusMap`] (issue
977/// #1517), forwarded verbatim to [`crate::osv::DependencyVulnerabilities::recommended_fix`] —
978/// see that method's doc for its effect.
979///
980/// # Examples
981///
982/// ```
983/// use deps_core::edit::{VulnFixSkip, resolve_recommended_fix};
984/// use deps_core::lsp_helpers::{
985/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
986/// RequirementResolution, SourcePolicy,
987/// };
988/// use deps_core::osv::{Advisory, Capped, DependencyVulnerabilities, VulnSeverity};
989/// use deps_core::{ConcreteVersion, PackageName};
990/// use std::sync::Arc;
991///
992/// struct MockFormatter;
993/// impl PackageNaming for MockFormatter {}
994/// impl PackageRendering for MockFormatter {
995/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
996/// version.to_string()
997/// }
998/// fn package_url(&self, name: &PackageName) -> String {
999/// format!("https://example.com/{}", name.as_str())
1000/// }
1001/// }
1002/// impl RequirementResolution for MockFormatter {}
1003/// impl DiagnosticMessages for MockFormatter {}
1004/// impl DiagnosticPolicy for MockFormatter {}
1005/// impl SourcePolicy for MockFormatter {}
1006/// impl OsvNaming for MockFormatter {}
1007///
1008/// let dv = DependencyVulnerabilities::new(Capped::new(Vec::<Arc<Advisory>>::new(), 0));
1009/// assert_eq!(
1010/// resolve_recommended_fix(&dv, None, &MockFormatter),
1011/// Err(VulnFixSkip::NoRecommendedFix)
1012/// );
1013/// ```
1014pub fn resolve_recommended_fix(
1015 dv: &crate::osv::DependencyVulnerabilities,
1016 latest: Option<&crate::osv::UpgradeStatus>,
1017 formatter: &dyn EcosystemFormatter,
1018) -> Result<(crate::osv::FixRecommendation, String), VulnFixSkip> {
1019 let fix = dv
1020 .recommended_fix(latest)
1021 .ok_or(VulnFixSkip::NoRecommendedFix)?;
1022 let version_native = formatter.osv_version_to_native(&fix.version).into_string();
1023 if !is_safe_version_string(&version_native) {
1024 warn_rejected_value(
1025 "is_safe_version_string",
1026 "vulnerability fix plan",
1027 &version_native,
1028 );
1029 return Err(VulnFixSkip::UnsafeVersion);
1030 }
1031 Ok((fix, version_native))
1032}
1033
1034/// Whether `dv.fix_target_status` clears `fix`'s target version as an honest, presentable fix
1035/// (#462 FR-003) — verbatim move of `lsp_helpers::code_actions`'s private helper of the same
1036/// name and doc.
1037///
1038/// `CandidateClean { version: F }` always clears it. A `CandidateVulnerable { version: F,
1039/// advisory_ids }` result can also clear it — F may legitimately still be affected by
1040/// advisories `recommended_fix()` never claimed to resolve in the first place — but is
1041/// suppressed the moment `advisory_ids` names either a *claimed* advisory or one this
1042/// dependency's own `advisories` never recorded at all. Any other state means F was never
1043/// actually verified, so it is rejected too. `known_ids` is built from `dv.advisories`, the
1044/// fix-computation set capped at [`crate::osv::MAX_ADVISORY_RECORDS`] — not the smaller,
1045/// render-only [`crate::osv::DependencyVulnerabilities::advisories_for_display`] — so this gate
1046/// sees the same advisory set `recommended_fix()` itself claimed against (#1422).
1047///
1048/// `pub(crate)`: `deps-cli update --security-only` (#1329) used to call this directly to
1049/// distinguish "no verified fix target" from [`plan_vulnerability_fix`]'s other `None` cause,
1050/// but now matches on [`VulnFixSkip`] directly (#1350), so this no longer needs to be `pub`.
1051#[must_use]
1052pub(crate) fn fix_target_is_verified(
1053 dv: &crate::osv::DependencyVulnerabilities,
1054 fix: &crate::osv::FixRecommendation,
1055 version_native: &str,
1056) -> bool {
1057 use crate::osv::UpgradeStatus;
1058 use std::collections::HashSet;
1059
1060 match &dv.fix_target_status {
1061 UpgradeStatus::CandidateClean { version } => version == version_native,
1062 UpgradeStatus::CandidateVulnerable {
1063 version,
1064 advisory_ids,
1065 ..
1066 } => {
1067 if version != version_native || !advisory_ids.is_complete() {
1068 return false;
1069 }
1070 let known_ids: HashSet<&str> = dv
1071 .advisories
1072 .items()
1073 .iter()
1074 .map(|a| a.id.as_str())
1075 .collect();
1076 advisory_ids
1077 .items()
1078 .iter()
1079 .all(|id| known_ids.contains(id.as_str()) && !fix.advisory_ids.contains(id))
1080 }
1081 UpgradeStatus::NotChecked
1082 | UpgradeStatus::CandidateUnverified { .. }
1083 | UpgradeStatus::StructurallyUnchecked(_) => false,
1084 }
1085}
1086
1087/// [`resolve_recommended_fix`] plus the internal `fix_target_is_verified` gate — resolves the
1088/// OSV-recommended fix for `dv` AND confirms its target version's own OSV status.
1089///
1090/// Callers that need to know "is there a *usable* fix" (not just "is there *a* fix") call
1091/// this instead of [`resolve_recommended_fix`] alone — see [`plan_vulnerability_fix`] and
1092/// `deps-cli`'s `classify_vulnerable_dependency` (#1350 S1 regression fix: that caller must
1093/// run this check *before* any other requirement/range/yanked-status decision, exactly as it
1094/// did when it held its own copy of the `fix_target_is_verified` call, or an unverified fix
1095/// target gets misreported as `RequiresLockfileUpdate`/`Unfixable(Yanked)` instead of
1096/// `Unfixable(NoVerifiedFix)`).
1097///
1098/// `deps-engine`'s phase-B fix-target verification (`classify::osv::resolve_fix_target`) is
1099/// the producer of `dv.fix_target_status` in the first place, so it calls
1100/// [`resolve_recommended_fix`] directly instead of this function — gating on a status it has
1101/// not computed yet would be circular.
1102///
1103/// # Errors
1104///
1105/// Returns anything [`resolve_recommended_fix`] can return, plus
1106/// [`VulnFixSkip::UnverifiedTarget`] when the internal `fix_target_is_verified` gate rejects
1107/// the fix target.
1108///
1109/// # Examples
1110///
1111/// ```
1112/// use deps_core::edit::{VulnFixSkip, resolve_verified_fix};
1113/// use deps_core::lsp_helpers::{
1114/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1115/// RequirementResolution, SourcePolicy,
1116/// };
1117/// use deps_core::osv::{
1118/// Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
1119/// };
1120/// use deps_core::{ConcreteVersion, PackageName};
1121/// use std::sync::Arc;
1122///
1123/// struct MockFormatter;
1124/// impl PackageNaming for MockFormatter {}
1125/// impl PackageRendering for MockFormatter {
1126/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1127/// version.to_string()
1128/// }
1129/// fn package_url(&self, name: &PackageName) -> String {
1130/// format!("https://example.com/{}", name.as_str())
1131/// }
1132/// }
1133/// impl RequirementResolution for MockFormatter {}
1134/// impl DiagnosticMessages for MockFormatter {}
1135/// impl DiagnosticPolicy for MockFormatter {}
1136/// impl SourcePolicy for MockFormatter {}
1137/// impl OsvNaming for MockFormatter {}
1138///
1139/// let advisory = Arc::new(
1140/// Advisory::new(
1141/// "RUSTSEC-2024-0001".to_string(),
1142/// "2024-01-01T00:00:00Z".to_string(),
1143/// VulnSeverity::High,
1144/// )
1145/// .expect("valid osv id")
1146/// .with_fixed_versions(vec![OsvVersion::new("1.2.0")]),
1147/// );
1148/// // `fix_target_status` left at its `NotChecked` default — never live-checked yet.
1149/// let unverified = DependencyVulnerabilities::new(Capped::new(vec![advisory.clone()], 1));
1150/// assert_eq!(
1151/// resolve_verified_fix(&unverified, None, &MockFormatter),
1152/// Err(VulnFixSkip::UnverifiedTarget)
1153/// );
1154///
1155/// let verified = DependencyVulnerabilities::new(Capped::new(vec![advisory], 1))
1156/// .with_fix_target_status(UpgradeStatus::CandidateClean { version: ConcreteVersion::new("1.2.0") });
1157/// assert!(resolve_verified_fix(&verified, None, &MockFormatter).is_ok());
1158/// ```
1159pub fn resolve_verified_fix(
1160 dv: &crate::osv::DependencyVulnerabilities,
1161 latest: Option<&crate::osv::UpgradeStatus>,
1162 formatter: &dyn EcosystemFormatter,
1163) -> Result<(crate::osv::FixRecommendation, String), VulnFixSkip> {
1164 let (fix, version_native) = resolve_recommended_fix(dv, latest, formatter)?;
1165 if !fix_target_is_verified(dv, &fix, &version_native) {
1166 return Err(VulnFixSkip::UnverifiedTarget);
1167 }
1168 Ok((fix, version_native))
1169}
1170
1171/// Security-mode planner: plans the vulnerability-fix edit for one dependency.
1172///
1173/// The dependency must already be known to be [`crate::osv::ScanOutcome::Vulnerable`]; this
1174/// targets [`crate::osv::DependencyVulnerabilities::recommended_fix`] rather than `latest`.
1175///
1176/// Originally a verbatim move of `lsp_helpers::code_actions::build_vulnerability_fix_action`'s
1177/// planning core (the no-op guard, `is_safe_version_string`, `osv_version_to_native`,
1178/// `format_version_replacing_for`, and the `fix_target_is_verified` gate) — yank/timeout
1179/// filtering is deliberately **not** moved: both `deps-lsp` and `deps-cli` apply their own
1180/// source and policy for that (see `lsp_helpers::code_actions::generate_code_actions`'s
1181/// yank-filtering block and `deps-cli`'s `update::security` module).
1182///
1183/// #1344: also gated on whether `dep`'s own declared requirement, left unedited, already
1184/// *resolves forward* to the fix target under this ecosystem's own resolution rules
1185/// (`formatter.requirement_already_resolves_to(..)`) — not just the textual no-op guard below
1186/// (which only catches the declared literal already *spelling* the fix version verbatim).
1187/// `serde = "1"` with fix `1.0.2` must not be rewritten to `serde = "1.0.2"`: the declared
1188/// range already accepts `1.0.2`, so re-resolving already gets there without a manifest edit
1189/// (a `cargo update`/lock-file refresh, for an ecosystem that has one — not every ecosystem
1190/// does: Maven and Gradle have none, and a NuGet project's is opt-in. For those, suppression
1191/// is instead because the requirement already structurally expresses the fix — e.g. a Maven
1192/// dynamic range/`LATEST`/`SNAPSHOT` resolves it at build time — not because of any lock
1193/// file). `bounded_requirement_already_resolves_to`'s default asks only "is the fix a member of the
1194/// requirement's accepted set", correct for a requirement a resolver picks its *newest*
1195/// admissible member from, but wrong for one a resolver instead pins to its *lowest*
1196/// admissible member (NuGet's bare `Version="1.0.0"` floor, #1344) — ecosystems with that
1197/// resolution shape override the method rather than relying on the default; see
1198/// `deps-nuget`'s override. Ecosystems with no `compile_bounded_requirement` override (GitHub Actions,
1199/// GitLab CI — SHA/tag pins, not version ranges) fall through to the textual no-op guard as
1200/// the only available signal. Originally only checked by `deps-cli update --security-only`'s
1201/// `classify_vulnerable_dependency` (#1329); moved here so `deps-lsp`'s vulnerability-fix code
1202/// action shares the same decision instead of reimplementing it.
1203///
1204/// `dv` and `current` are supplied by the caller rather than resolved internally — the
1205/// pre-extraction function rebuilt `crate::osv::vulnerability_keys` per call, tolerable for
1206/// one position-driven LSP request but O(n²) for a planner iterating every dependency in a
1207/// manifest; both call sites now build that map once and look up each dependency's key
1208/// themselves.
1209///
1210/// # Examples
1211///
1212/// ```
1213/// use deps_core::edit::plan_vulnerability_fix;
1214/// use deps_core::lsp_helpers::{
1215/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1216/// RequirementResolution, SourcePolicy,
1217/// };
1218/// use deps_core::osv::{
1219/// Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
1220/// };
1221/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
1222/// use deps_core::position::{Position, Range};
1223/// use std::any::Any;
1224/// use std::sync::Arc;
1225///
1226/// struct MockFormatter;
1227/// impl PackageNaming for MockFormatter {}
1228/// impl PackageRendering for MockFormatter {
1229/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1230/// version.to_string()
1231/// }
1232/// fn package_url(&self, name: &PackageName) -> String {
1233/// format!("https://example.com/{}", name.as_str())
1234/// }
1235/// }
1236/// impl RequirementResolution for MockFormatter {}
1237/// impl DiagnosticMessages for MockFormatter {}
1238/// impl DiagnosticPolicy for MockFormatter {}
1239/// impl SourcePolicy for MockFormatter {}
1240/// impl OsvNaming for MockFormatter {}
1241///
1242/// struct MockDep {
1243/// name: PackageName,
1244/// version_req: VersionReq,
1245/// version_range: Range,
1246/// name_range: Range,
1247/// }
1248/// impl Dependency for MockDep {
1249/// fn name(&self) -> &PackageName { &self.name }
1250/// fn name_range(&self) -> Range { self.name_range }
1251/// fn version_requirement(&self) -> Option<&VersionReq> { Some(&self.version_req) }
1252/// fn version_range(&self) -> Option<Range> { Some(self.version_range) }
1253/// fn source(&self) -> deps_core::parser::DependencySource {
1254/// deps_core::parser::DependencySource::Registry
1255/// }
1256/// fn as_any(&self) -> &dyn Any { self }
1257/// }
1258///
1259/// let dep = MockDep {
1260/// name: PackageName::new("serde"),
1261/// version_req: VersionReq::new("1.0.0"),
1262/// version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
1263/// name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
1264/// };
1265///
1266/// let advisory = Arc::new(
1267/// Advisory::new(
1268/// "RUSTSEC-2024-0001".to_string(),
1269/// "2024-01-01T00:00:00Z".to_string(),
1270/// VulnSeverity::High,
1271/// )
1272/// .expect("valid osv id")
1273/// .with_fixed_versions(vec![OsvVersion::new("1.2.0")]),
1274/// );
1275/// let dv = DependencyVulnerabilities::new(Capped::new(vec![advisory], 1))
1276/// .with_fix_target_status(UpgradeStatus::CandidateClean { version: ConcreteVersion::new("1.2.0") });
1277///
1278/// let planned =
1279/// plan_vulnerability_fix(&dep, dep.version_range, "1.0.0", &dv, None, &MockFormatter);
1280/// assert_eq!(planned.unwrap().edit.new_text, "1.2.0");
1281/// ```
1282///
1283/// # Errors
1284///
1285/// Returns [`VulnFixSkip`] for any of the six causes that make an edit impossible or
1286/// unnecessary — see that type's variants.
1287pub fn plan_vulnerability_fix(
1288 dep: &dyn Dependency,
1289 version_range: crate::position::Range,
1290 current: &str,
1291 dv: &crate::osv::DependencyVulnerabilities,
1292 latest: Option<&crate::osv::UpgradeStatus>,
1293 formatter: &dyn EcosystemFormatter,
1294) -> Result<PlannedUpdate, VulnFixSkip> {
1295 let (_fix, version_native) = resolve_verified_fix(dv, latest, formatter)?;
1296 plan_verified_fix(dep, version_range, current, &version_native, formatter)
1297}
1298
1299/// The remainder of [`plan_vulnerability_fix`]'s decision, for a fix target already resolved
1300/// and verified via [`resolve_verified_fix`].
1301///
1302/// Split out for a caller that already holds `version_native` because it needed it for its
1303/// own decision (e.g. a yank check) before ever reaching this point —
1304/// [`plan_vulnerability_fix`] itself calls [`resolve_verified_fix`] internally, so calling
1305/// that first and then [`plan_vulnerability_fix`] would run the same resolve-and-verify chain
1306/// twice per dependency (code review finding, #1350). `deps-cli`'s
1307/// `classify_vulnerable_dependency` is this function's only caller outside
1308/// [`plan_vulnerability_fix`] itself.
1309///
1310/// # Errors
1311///
1312/// Returns [`VulnFixSkip::OversizedRequirement`], [`VulnFixSkip::UnresolvedPlaceholder`],
1313/// [`VulnFixSkip::RequirementAlreadyResolves`], or [`VulnFixSkip::NoOpRewrite`] — the causes
1314/// that can still make an edit unnecessary once the fix is already known resolved and verified.
1315pub fn plan_verified_fix(
1316 dep: &dyn Dependency,
1317 version_range: crate::position::Range,
1318 current: &str,
1319 version_native: &str,
1320 formatter: &dyn EcosystemFormatter,
1321) -> Result<PlannedUpdate, VulnFixSkip> {
1322 // Checked before the placeholder gate: that gate reports oversized text as a placeholder,
1323 // which would misattribute a size-based skip to `UnresolvedPlaceholder`.
1324 if requirement_len_exceeds_cap(current)
1325 || dep
1326 .version_requirement()
1327 .is_some_and(requirement_is_oversized)
1328 || dep
1329 .version_literal()
1330 .is_some_and(requirement_len_exceeds_cap)
1331 {
1332 return Err(VulnFixSkip::OversizedRequirement);
1333 }
1334
1335 // #1370: central placeholder gate, checked first — an unexpanded placeholder has no
1336 // concrete version text to replace, independent of whether `bounded_requirement_already_resolves_to`
1337 // or the formatter's own rewrite logic would coincidentally treat it as safe. Checked
1338 // against `current` (the exact text a caller is about to consider rewriting), not
1339 // `dep.version_requirement()`, since a caller may reach this with `current` derived from
1340 // somewhere other than the dependency's own preserved requirement field.
1341 if requirement_is_placeholder_for(formatter, dep, current) {
1342 return Err(VulnFixSkip::UnresolvedPlaceholder);
1343 }
1344
1345 let fix_concrete = ConcreteVersion::new(version_native);
1346 let requirement_already_resolves_to_fix =
1347 dep.version_requirement().is_some_and(|version_req| {
1348 formatter.requirement_already_resolves_to(version_req, &fix_concrete)
1349 });
1350 if requirement_already_resolves_to_fix {
1351 return Err(VulnFixSkip::RequirementAlreadyResolves);
1352 }
1353
1354 // Unreachable after the gate above; fails closed rather than unwrapping.
1355 let Some(new_text) = replacement_text(formatter, dep, &fix_concrete, current) else {
1356 return Err(VulnFixSkip::UnresolvedPlaceholder);
1357 };
1358 let literal_target = dep.version_literal().unwrap_or(current);
1359 if strip_whitespace(literal_target) == strip_whitespace(&new_text) {
1360 return Err(VulnFixSkip::NoOpRewrite);
1361 }
1362
1363 let normalized_name = formatter.normalize_package_name(dep.name());
1364 Ok(PlannedUpdate {
1365 name: dep.name().as_str().to_string(),
1366 normalized_name,
1367 name_range: dep.name_range(),
1368 // `current` here is the requirement text passed in for placeholder/no-op checks
1369 // above, not a resolved pinned version — `PlannedUpdate::current` documents only the
1370 // latter, so this caller has none to report.
1371 current: None,
1372 target: ConcreteVersion::new(version_native),
1373 edit: ManifestEdit {
1374 range: version_range,
1375 new_text,
1376 },
1377 })
1378}
1379
1380#[cfg(test)]
1381mod tests {
1382 use super::*;
1383 use crate::position::Position;
1384
1385 fn range(sl: u32, sc: u32, el: u32, ec: u32) -> crate::position::Range {
1386 crate::position::Range::new(Position::new(sl, sc), Position::new(el, ec))
1387 }
1388
1389 // --- classify_update / UpdateKind ---
1390
1391 #[test]
1392 fn test_classify_update_major() {
1393 assert_eq!(classify_update("1.2.3", "2.0.0"), UpdateKind::Major);
1394 }
1395
1396 #[test]
1397 fn test_classify_update_minor() {
1398 assert_eq!(classify_update("1.2.3", "1.3.0"), UpdateKind::Minor);
1399 }
1400
1401 #[test]
1402 fn test_classify_update_patch() {
1403 assert_eq!(classify_update("1.2.3", "1.2.4"), UpdateKind::Patch);
1404 }
1405
1406 #[test]
1407 fn test_classify_update_v_prefix() {
1408 assert_eq!(classify_update("v1.2.3", "v2.0.0"), UpdateKind::Major);
1409 }
1410
1411 #[test]
1412 fn test_classify_update_prerelease_and_build_suffix_ignored() {
1413 assert_eq!(
1414 classify_update("1.2.3-alpha.1", "1.2.3+build.5"),
1415 UpdateKind::Patch
1416 );
1417 }
1418
1419 #[test]
1420 fn test_classify_update_github_actions_sha_pin_is_unknown() {
1421 assert_eq!(
1422 classify_update(
1423 "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
1424 "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3"
1425 ),
1426 UpdateKind::Unknown
1427 );
1428 }
1429
1430 #[test]
1431 fn test_classify_update_go_pseudo_version_is_unknown() {
1432 assert_eq!(
1433 classify_update("v0.0.0-20210101000000-abcdef123456", "v1.0.0"),
1434 UpdateKind::Unknown
1435 );
1436 assert_eq!(
1437 classify_update(
1438 "v1.2.3-0.20210101000000-abcdef123456",
1439 "v1.2.3-0.20210102000000-abcdef654321"
1440 ),
1441 UpdateKind::Unknown
1442 );
1443 }
1444
1445 #[test]
1446 fn test_classify_update_maven_nuget_range_syntax_is_unknown() {
1447 assert_eq!(classify_update("[1.0,2.0)", "2.0.0"), UpdateKind::Unknown);
1448 assert_eq!(classify_update("1.0.0", "[1.0,2.0)"), UpdateKind::Unknown);
1449 }
1450
1451 #[test]
1452 fn test_classify_update_wildcard_latest_workspace_star_is_unknown() {
1453 assert_eq!(classify_update("*", "1.0.0"), UpdateKind::Unknown);
1454 assert_eq!(classify_update("latest", "1.0.0"), UpdateKind::Unknown);
1455 assert_eq!(classify_update("workspace:*", "1.0.0"), UpdateKind::Unknown);
1456 }
1457
1458 #[test]
1459 fn test_classify_update_gradle_strictly_preferred_syntax_is_unknown() {
1460 assert_eq!(
1461 classify_update("{strictly 1.0}!!1.2", "1.3.0"),
1462 UpdateKind::Unknown
1463 );
1464 }
1465
1466 #[test]
1467 fn test_classify_update_equal_versions_is_patch() {
1468 assert_eq!(classify_update("1.2.3", "1.2.3"), UpdateKind::Patch);
1469 }
1470
1471 #[test]
1472 fn test_classify_update_four_component_version() {
1473 assert_eq!(classify_update("1.0.0.0", "1.0.1.0"), UpdateKind::Patch);
1474 }
1475
1476 // --- dedup_overlapping_edits<ManifestEdit> ---
1477
1478 #[test]
1479 fn test_dedup_overlapping_manifest_edits_drops_the_later_overlap() {
1480 let edits = vec![
1481 ManifestEdit {
1482 range: range(0, 0, 0, 5),
1483 new_text: "a".to_string(),
1484 },
1485 ManifestEdit {
1486 range: range(0, 2, 0, 7),
1487 new_text: "b".to_string(),
1488 },
1489 ];
1490 let kept = dedup_overlapping_edits(edits, "test");
1491 assert_eq!(kept.len(), 1);
1492 assert_eq!(kept[0].new_text, "a");
1493 }
1494
1495 #[test]
1496 fn test_dedup_non_overlapping_manifest_edits_keeps_both() {
1497 let edits = vec![
1498 ManifestEdit {
1499 range: range(0, 0, 0, 5),
1500 new_text: "a".to_string(),
1501 },
1502 ManifestEdit {
1503 range: range(1, 0, 1, 5),
1504 new_text: "b".to_string(),
1505 },
1506 ];
1507 let kept = dedup_overlapping_edits(edits, "test");
1508 assert_eq!(kept.len(), 2);
1509 }
1510
1511 // --- apply_edits ---
1512
1513 #[test]
1514 fn test_apply_edits_single_line() {
1515 let content = "serde = \"1.0.0\"\n";
1516 let edits = vec![ManifestEdit {
1517 range: range(0, 9, 0, 14),
1518 new_text: "1.2.0".to_string(),
1519 }];
1520 assert_eq!(apply_edits(content, &edits), "serde = \"1.2.0\"\n");
1521 }
1522
1523 #[test]
1524 fn test_apply_edits_multiple_lines_reverse_splice() {
1525 let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
1526 let edits = vec![
1527 ManifestEdit {
1528 range: range(0, 9, 0, 14),
1529 new_text: "1.2.0".to_string(),
1530 },
1531 ManifestEdit {
1532 range: range(1, 9, 1, 14),
1533 new_text: "1.3.0".to_string(),
1534 },
1535 ];
1536 assert_eq!(
1537 apply_edits(content, &edits),
1538 "serde = \"1.2.0\"\ntokio = \"1.3.0\"\n"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_apply_edits_empty_slice_is_a_no_op() {
1544 let content = "serde = \"1.0.0\"\n";
1545 assert_eq!(apply_edits(content, &[]), content);
1546 }
1547
1548 /// Security-S2 regression: an inverted range (`start > end`) must be dropped, not panic
1549 /// `replace_range`. `apply_edits` splices ranges in-process now (deps-cli), unlike its
1550 /// pre-#1329 callers, which only ever handed ranges back to an LSP client.
1551 #[test]
1552 fn test_apply_edits_drops_inverted_range_instead_of_panicking() {
1553 let content = "serde = \"1.0.0\"\n";
1554 let edits = vec![ManifestEdit {
1555 range: range(0, 14, 0, 9),
1556 new_text: "evil".to_string(),
1557 }];
1558 assert_eq!(apply_edits(content, &edits), content);
1559 }
1560
1561 #[test]
1562 fn test_apply_edits_valid_edit_still_applies_alongside_a_dropped_inverted_one() {
1563 let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
1564 let edits = vec![
1565 ManifestEdit {
1566 range: range(0, 14, 0, 9),
1567 new_text: "evil".to_string(),
1568 },
1569 ManifestEdit {
1570 range: range(1, 9, 1, 14),
1571 new_text: "1.3.0".to_string(),
1572 },
1573 ];
1574 assert_eq!(
1575 apply_edits(content, &edits),
1576 "serde = \"1.0.0\"\ntokio = \"1.3.0\"\n"
1577 );
1578 }
1579
1580 #[test]
1581 fn test_apply_edits_non_ascii_line_with_several_edits() {
1582 let content = "h\u{e9}llo \u{1f600} a = \"1.0.0\", b = \"2.0.0\"\n";
1583 let edit = |sc, ec, text: &str| ManifestEdit {
1584 range: range(0, sc, 0, ec),
1585 new_text: text.to_string(),
1586 };
1587 let edits = vec![
1588 edit(27, 32, "2.2.2"),
1589 edit(6, 8, "X"),
1590 edit(14, 19, "1.1.0"),
1591 ];
1592 assert_eq!(
1593 apply_edits(content, &edits),
1594 "h\u{e9}llo X a = \"1.1.0\", b = \"2.2.2\"\n"
1595 );
1596 }
1597
1598 #[test]
1599 fn test_apply_edits_many_edits_on_one_long_non_ascii_line() {
1600 const UNITS: usize = 5000;
1601 const UNIT_UTF16_LEN: u32 = 18;
1602 const VERSION_COL: u32 = 10;
1603 let unit = |version: &str| format!("\u{e9}\u{1f600}dep = \"{version}\", ");
1604 let content = "\u{e9}\u{1f600}dep = \"1.0.0\", ".repeat(UNITS);
1605 let edits: Vec<ManifestEdit> = (0..UNITS)
1606 .filter(|i| i % 30 == 0)
1607 .map(|i| {
1608 let start = VERSION_COL + UNIT_UTF16_LEN * u32::try_from(i).unwrap();
1609 ManifestEdit {
1610 range: range(0, start, 0, start + 5),
1611 new_text: "9.9.9".to_string(),
1612 }
1613 })
1614 .collect();
1615 assert!(edits.len() >= 100);
1616 let expected: String = (0..UNITS)
1617 .map(|i| unit(if i % 30 == 0 { "9.9.9" } else { "1.0.0" }))
1618 .collect();
1619 assert_eq!(apply_edits(&content, &edits), expected);
1620 }
1621
1622 // --- collect_update_edits / collect_update_candidates ---
1623
1624 mod collect_update_tests {
1625 use super::*;
1626 use crate::lsp_helpers::PackageVersions;
1627 use crate::lsp_helpers::test_support::{MOCK_FORMATTER, MockDep, MockParseResult, pkg};
1628 use crate::{EcosystemId, PackageName};
1629 use std::collections::HashMap;
1630
1631 fn dep(name: &str, req: &str, version_range: crate::position::Range) -> MockDep {
1632 MockDep {
1633 name: pkg(name),
1634 version_req: crate::VersionReq::new(req),
1635 version_range,
1636 name_range: crate::position::Range::default(),
1637 }
1638 }
1639
1640 fn parse_result(deps: Vec<MockDep>) -> MockParseResult {
1641 MockParseResult {
1642 deps,
1643 uri: crate::test_util::test_uri("/test/Cargo.toml"),
1644 }
1645 }
1646
1647 /// spec 050 (T002): a renamed/aliased dependency's two occurrences must each
1648 /// classify `current` against their own resolved pin, never the collapsed per-name
1649 /// map.
1650 #[test]
1651 fn test_collect_update_edits_renamed_occurrence_resolves_its_own_pin() {
1652 let content = "serde = \"0.9\"\nserde = \"1.0\"\n";
1653 let pr = parse_result(vec![
1654 dep("serde", "0.9", range(0, 9, 0, 12)),
1655 dep("serde", "1.0", range(1, 9, 1, 12)),
1656 ]);
1657 let mut cached = HashMap::new();
1658 cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
1659 let resolved_versions: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1660 let mut candidates = HashMap::new();
1661 candidates.insert(
1662 pkg("serde"),
1663 vec![
1664 ConcreteVersion::from("0.9.15"),
1665 ConcreteVersion::from("1.0.219"),
1666 ],
1667 );
1668 let versions = VersionData::new(&cached, &resolved_versions)
1669 .with_resolved_version_candidates(&candidates)
1670 .with_ecosystem(EcosystemId::Cargo);
1671
1672 let mut planned = collect_update_edits(&pr, content, versions, &MOCK_FORMATTER);
1673 planned.sort_by_key(|p| p.edit.range.start.line);
1674
1675 assert_eq!(planned.len(), 2);
1676 assert_eq!(planned[0].current, Some(ConcreteVersion::from("0.9.15")));
1677 assert_eq!(planned[1].current, Some(ConcreteVersion::from("1.0.219")));
1678 }
1679
1680 #[test]
1681 fn test_collect_update_candidates_non_literal_span_is_unplannable() {
1682 // Mirrors the Maven `${property}` class: `version_range` spans a reference, not
1683 // the literal requirement text.
1684 let content = "<version>${slf4j.version}</version>";
1685 let pr = parse_result(vec![dep("slf4j-api", "2.0.16", range(0, 9, 0, 25))]);
1686 let mut cached = HashMap::new();
1687 cached.insert("slf4j-api".into(), PackageVersions::latest_only("2.1.0"));
1688 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1689 let versions = VersionData::new(&cached, &resolved);
1690
1691 let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1692 assert_eq!(candidates.len(), 1);
1693 assert!(matches!(
1694 candidates[0],
1695 UpdateCandidate::Unplannable {
1696 reason: UnplannableReason::NonLiteralSpan,
1697 ..
1698 }
1699 ));
1700 assert!(
1701 collect_update_edits(&pr, content, versions, &MOCK_FORMATTER).is_empty(),
1702 "an unplannable candidate must never reach collect_update_edits's writable subset"
1703 );
1704 }
1705
1706 /// Issue #1517 (the P0 this fix addresses): OSV flagged the registry's "latest" as
1707 /// malicious — `collect_update_candidates` (shared by the LSP code lens and
1708 /// `deps-cli update`'s default mode) must never plan writing it into the manifest.
1709 #[test]
1710 fn test_collect_update_candidates_osv_flagged_latest_is_unplannable() {
1711 let content = "feed-widget-helper = \"1.0.4\"\n";
1712 let pr = parse_result(vec![dep(
1713 "feed-widget-helper",
1714 "1.0.4",
1715 range(0, 22, 0, 29),
1716 )]);
1717 let mut cached = HashMap::new();
1718 cached.insert(
1719 "feed-widget-helper".into(),
1720 PackageVersions::latest_only("1.0.8"),
1721 );
1722 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1723 let mut latest_status = crate::osv::LatestStatusMap::new();
1724 latest_status.insert(
1725 crate::test_util::vuln_key("feed-widget-helper"),
1726 crate::osv::UpgradeStatus::CandidateVulnerable {
1727 version: ConcreteVersion::new("1.0.8"),
1728 advisory_ids: crate::osv::Capped::new(vec!["MAL-2026-16332".to_string()], 1),
1729 worst_severity: Some(crate::osv::VulnSeverity::Malicious),
1730 },
1731 );
1732 let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
1733
1734 let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1735 assert_eq!(candidates.len(), 1, "{candidates:?}");
1736 assert!(matches!(
1737 candidates[0],
1738 UpdateCandidate::Unplannable {
1739 reason: UnplannableReason::LatestFlaggedByOsv,
1740 ..
1741 }
1742 ));
1743 assert!(
1744 collect_update_edits(&pr, content, versions, &MOCK_FORMATTER).is_empty(),
1745 "a flagged latest must never reach collect_update_edits's writable subset"
1746 );
1747 }
1748
1749 /// Issue #1517 AC4: a `latest_status` map with no entry for this dependency (the
1750 /// pre-phase-B window, or a transient skip) must fail closed, never be planned as if
1751 /// verified safe.
1752 #[test]
1753 fn test_collect_update_candidates_unverified_latest_is_unplannable() {
1754 let content = "pkg = \"1.0.0\"\n";
1755 let pr = parse_result(vec![dep("pkg", "1.0.0", range(0, 6, 0, 13))]);
1756 let mut cached = HashMap::new();
1757 cached.insert("pkg".into(), PackageVersions::latest_only("2.0.0"));
1758 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1759 // Empty map, not `None`: the pre-phase-B window attaches `Some(&empty_map)`.
1760 let latest_status = crate::osv::LatestStatusMap::new();
1761 let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
1762
1763 let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1764 assert_eq!(candidates.len(), 1, "{candidates:?}");
1765 assert!(matches!(
1766 candidates[0],
1767 UpdateCandidate::Unplannable {
1768 reason: UnplannableReason::LatestUnverified,
1769 ..
1770 }
1771 ));
1772 }
1773
1774 #[test]
1775 fn test_collect_update_candidates_no_op_rewrite_is_unplannable() {
1776 struct NoOpFormatter;
1777 impl crate::lsp_helpers::PackageNaming for NoOpFormatter {}
1778 impl crate::lsp_helpers::PackageRendering for NoOpFormatter {
1779 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1780 version.to_string()
1781 }
1782 fn package_url(&self, name: &PackageName) -> String {
1783 name.as_str().to_string()
1784 }
1785 fn format_version_replacing(
1786 &self,
1787 _version: &ConcreteVersion,
1788 current: &str,
1789 ) -> String {
1790 current.to_string()
1791 }
1792 }
1793 impl crate::lsp_helpers::RequirementResolution for NoOpFormatter {
1794 fn is_bounded_requirement_up_to_date(
1795 &self,
1796 _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
1797 _latest: &ConcreteVersion,
1798 ) -> bool {
1799 false
1800 }
1801 }
1802 impl crate::lsp_helpers::DiagnosticMessages for NoOpFormatter {}
1803 impl crate::lsp_helpers::DiagnosticPolicy for NoOpFormatter {}
1804 impl crate::lsp_helpers::SourcePolicy for NoOpFormatter {}
1805 impl crate::lsp_helpers::OsvNaming for NoOpFormatter {}
1806
1807 let content = "pkg = \"1.0.0\"\n";
1808 let pr = parse_result(vec![dep("pkg", "1.0.0", range(0, 7, 0, 12))]);
1809 let mut cached = HashMap::new();
1810 cached.insert("pkg".into(), PackageVersions::latest_only("1.2.0"));
1811 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1812 let versions = VersionData::new(&cached, &resolved);
1813
1814 let candidates = collect_update_candidates(&pr, content, versions, &NoOpFormatter);
1815 assert_eq!(candidates.len(), 1);
1816 assert!(matches!(
1817 candidates[0],
1818 UpdateCandidate::Unplannable {
1819 reason: UnplannableReason::NoOpRewrite,
1820 ..
1821 }
1822 ));
1823 }
1824
1825 #[test]
1826 fn test_collect_update_candidates_unsafe_latest_is_unplannable() {
1827 let content = "serde = \"1.0.0\"\n";
1828 let pr = parse_result(vec![dep("serde", "1.0.0", range(0, 8, 0, 15))]);
1829 let mut cached = HashMap::new();
1830 cached.insert(
1831 "serde".into(),
1832 PackageVersions::latest_only("1.2.0\", \"evil\": \"true"),
1833 );
1834 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1835 let versions = VersionData::new(&cached, &resolved);
1836
1837 let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1838 assert_eq!(candidates.len(), 1);
1839 assert!(matches!(
1840 candidates[0],
1841 UpdateCandidate::Unplannable {
1842 reason: UnplannableReason::UnsafeLatestVersion,
1843 ..
1844 }
1845 ));
1846 }
1847
1848 #[test]
1849 fn test_collect_update_candidates_up_to_date_dependency_produces_no_candidate() {
1850 // A dependency that is not `Outdated` at all must produce no entry — reporting
1851 // every up-to-date dependency as a "candidate" would be noise, not signal.
1852 let content = "serde = \"^1.0\"\n";
1853 let pr = parse_result(vec![dep("serde", "^1.0", range(0, 8, 0, 14))]);
1854 let mut cached = HashMap::new();
1855 cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
1856 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1857 let versions = VersionData::new(&cached, &resolved);
1858
1859 let candidates = collect_update_candidates(&pr, content, versions, &MOCK_FORMATTER);
1860 assert!(candidates.is_empty());
1861 }
1862
1863 /// #1556 N3 (review): `current` in `collect_update_candidates` — the value
1864 /// `deps-cli update --update-types` feeds into `classify_update` — now flows through
1865 /// `resolve_in_use_version`, so a GitHub Actions SHA pin whose `resolved_pin_version`
1866 /// hook resolves to a full-semver tag now classifies as a real `UpdateKind` instead of
1867 /// `Unknown`. This is a genuine, user-visible CLI behavior change introduced by this
1868 /// PR's core fix that had zero coverage before: `test_classify_update_github_actions_
1869 /// sha_pin_is_unknown` only calls `classify_update` directly on two raw SHA strings and
1870 /// never goes through `resolve_in_use_version`/`resolved_pin_version` at all — that test
1871 /// still covers the unresolved case (the hook returning nothing) and must keep passing
1872 /// unchanged.
1873 #[test]
1874 fn test_collect_update_candidates_github_actions_sha_pin_current_resolves_via_hook() {
1875 struct ShaPinResolvedFormatter;
1876 impl crate::lsp_helpers::PackageNaming for ShaPinResolvedFormatter {}
1877 impl crate::lsp_helpers::PackageRendering for ShaPinResolvedFormatter {
1878 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1879 version.to_string()
1880 }
1881 fn package_url(&self, name: &PackageName) -> String {
1882 name.as_str().to_string()
1883 }
1884 }
1885 impl crate::lsp_helpers::RequirementResolution for ShaPinResolvedFormatter {
1886 fn is_bounded_requirement_up_to_date(
1887 &self,
1888 _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
1889 _latest: &ConcreteVersion,
1890 ) -> bool {
1891 false
1892 }
1893 fn resolved_pin_version(
1894 &self,
1895 _dep: &dyn Dependency,
1896 ) -> Option<crate::lsp_helpers::ResolvedPin> {
1897 Some(crate::lsp_helpers::ResolvedPin::most_specific(
1898 ConcreteVersion::new("v1.3.0"),
1899 ))
1900 }
1901 }
1902 impl crate::lsp_helpers::DiagnosticMessages for ShaPinResolvedFormatter {}
1903 impl crate::lsp_helpers::DiagnosticPolicy for ShaPinResolvedFormatter {}
1904 impl crate::lsp_helpers::SourcePolicy for ShaPinResolvedFormatter {}
1905 impl crate::lsp_helpers::OsvNaming for ShaPinResolvedFormatter {}
1906
1907 let sha = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
1908 let content = format!("actions/checkout = \"{sha}\"\n");
1909 let sha_start = u32::try_from(content.find(sha).unwrap()).unwrap();
1910 let sha_end = sha_start + u32::try_from(sha.len()).unwrap();
1911 let pr = parse_result(vec![dep(
1912 "actions/checkout",
1913 sha,
1914 range(0, sha_start, 0, sha_end),
1915 )]);
1916 let mut cached = HashMap::new();
1917 cached.insert(
1918 "actions/checkout".into(),
1919 PackageVersions::latest_only("v1.4.0"),
1920 );
1921 let resolved: HashMap<PackageName, ConcreteVersion> = HashMap::new();
1922 let versions =
1923 VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::GithubActions);
1924
1925 let candidates =
1926 collect_update_candidates(&pr, &content, versions, &ShaPinResolvedFormatter);
1927
1928 assert_eq!(candidates.len(), 1, "{candidates:?}");
1929 let UpdateCandidate::Planned(planned) = &candidates[0] else {
1930 panic!("expected a planned update, got {:?}", candidates[0]);
1931 };
1932 assert_eq!(planned.current, Some(ConcreteVersion::from("v1.3.0")));
1933 assert_eq!(
1934 classify_update(
1935 planned.current.as_ref().unwrap().as_str(),
1936 planned.target.as_str()
1937 ),
1938 UpdateKind::Minor
1939 );
1940 }
1941 }
1942
1943 // --- plan_vulnerability_fix: #1344 requirement-already-admits-fix gate, #1347
1944 // requirement_is_unresolved hardening ---
1945
1946 mod plan_vulnerability_fix_tests {
1947 use super::*;
1948 use crate::PackageName;
1949 use crate::VersionReq;
1950 use crate::lsp_helpers::test_support::{
1951 MOCK_FORMATTER, MockDep, StrictSemverFormatter, pkg,
1952 };
1953 use crate::lsp_helpers::{
1954 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
1955 RequirementMatcher, RequirementResolution, SourcePolicy,
1956 };
1957 use crate::osv::{
1958 Advisory, Capped, DependencyVulnerabilities, OsvVersion, UpgradeStatus, VulnSeverity,
1959 };
1960
1961 fn dep(name: &str, req: &str, version_range: crate::position::Range) -> MockDep {
1962 MockDep {
1963 name: PackageName::new(name),
1964 version_req: crate::VersionReq::new(req),
1965 version_range,
1966 name_range: crate::position::Range::default(),
1967 }
1968 }
1969
1970 fn verified_dv(fixed_version: &str) -> DependencyVulnerabilities {
1971 let advisory = std::sync::Arc::new(
1972 Advisory::new(
1973 "RUSTSEC-2024-0001".to_string(),
1974 "2024-01-01T00:00:00Z".to_string(),
1975 VulnSeverity::High,
1976 )
1977 .expect("valid osv id")
1978 .with_fixed_versions(vec![OsvVersion::new(fixed_version)]),
1979 );
1980 DependencyVulnerabilities::new(Capped::new(vec![advisory], 1)).with_fix_target_status(
1981 UpgradeStatus::CandidateClean {
1982 version: ConcreteVersion::new(fixed_version),
1983 },
1984 )
1985 }
1986
1987 /// Reproduces `GithubActionsFormatter`'s real semantics: `requirement_is_unresolved`
1988 /// is `true` for a full-SHA pin (it means "not decidable from text alone", not
1989 /// "unexpanded placeholder"), yet `format_version_replacing_for` still produces a
1990 /// legitimate SHA-preserving rewrite for it. Guards against reintroducing #1347's C1
1991 /// regression (gating `plan_vulnerability_fix` on `requirement_is_unresolved`, which
1992 /// would silently drop this ecosystem's working vulnerability remediation) — a real
1993 /// cross-crate `GithubActionsFormatter` can't be used here (`deps-core` cannot depend
1994 /// on `deps-github-actions`), so this mock reproduces its documented override shape
1995 /// instead.
1996 struct ShaPinFormatter;
1997 impl PackageNaming for ShaPinFormatter {}
1998 impl PackageRendering for ShaPinFormatter {
1999 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2000 version.to_string()
2001 }
2002 fn package_url(&self, name: &crate::PackageName) -> String {
2003 name.as_str().to_string()
2004 }
2005 fn format_version_replacing(
2006 &self,
2007 version: &ConcreteVersion,
2008 _current: &str,
2009 ) -> String {
2010 format!("{version} # v-resolved")
2011 }
2012 }
2013 impl RequirementResolution for ShaPinFormatter {
2014 fn bounded_requirement_is_unresolved(
2015 &self,
2016 _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
2017 ) -> bool {
2018 true
2019 }
2020 }
2021 impl DiagnosticMessages for ShaPinFormatter {}
2022 impl DiagnosticPolicy for ShaPinFormatter {}
2023 impl SourcePolicy for ShaPinFormatter {}
2024 impl OsvNaming for ShaPinFormatter {}
2025
2026 /// A caret range ("^1" is what Cargo/npm-shaped bare "1" compiles to) that already
2027 /// admits the fix target must suppress the edit — the declared requirement needs no
2028 /// manifest change, only a lock-file update.
2029 #[test]
2030 fn test_requirement_already_admitting_fix_returns_none() {
2031 let d = dep("serde", "1", range(0, 8, 0, 9));
2032 let dv = verified_dv("1.0.2");
2033
2034 let planned =
2035 plan_vulnerability_fix(&d, d.version_range, "1", &dv, None, &StrictSemverFormatter);
2036 assert_eq!(planned, Err(VulnFixSkip::RequirementAlreadyResolves));
2037 }
2038
2039 /// #1472 defense-in-depth: an oversized requirement must not trip
2040 /// `RequirementAlreadyResolves`, even when `bounded_requirement_already_resolves_to` would
2041 /// otherwise report `true` — proven via `ExactMatchFormatter`'s exact string-equality
2042 /// matcher, so an oversized requirement identical to the fix target trivially "already
2043 /// resolves" without the gate. With the gate, planning stops earlier with the distinct
2044 /// `OversizedRequirement` skip (#1665) — not `UnresolvedPlaceholder`, which the
2045 /// placeholder gate's fail-closed `true` for oversized text would otherwise report; the
2046 /// at-cap control proves the size check is a strict `>`, not `>=`.
2047 #[test]
2048 fn test_oversized_requirement_does_not_report_already_resolves() {
2049 use crate::lsp_helpers::MAX_REQUIREMENT_LEN;
2050 use crate::lsp_helpers::test_support::ExactMatchFormatter;
2051
2052 let at_cap = "1".repeat(MAX_REQUIREMENT_LEN);
2053 let d = dep(
2054 "serde",
2055 &at_cap,
2056 range(0, 8, 0, 8 + u32::try_from(at_cap.len()).unwrap()),
2057 );
2058 assert_eq!(
2059 plan_verified_fix(&d, d.version_range, &at_cap, &at_cap, &ExactMatchFormatter),
2060 Err(VulnFixSkip::RequirementAlreadyResolves)
2061 );
2062
2063 let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
2064 let d = dep(
2065 "serde",
2066 &oversized,
2067 range(0, 8, 0, 8 + u32::try_from(oversized.len()).unwrap()),
2068 );
2069
2070 let planned = plan_verified_fix(
2071 &d,
2072 d.version_range,
2073 &oversized,
2074 &oversized,
2075 &ExactMatchFormatter,
2076 );
2077
2078 assert_eq!(planned, Err(VulnFixSkip::OversizedRequirement));
2079 }
2080
2081 /// #1665: the oversized check also covers the dependency's own requirement and version
2082 /// literal, not only the `current` text a caller passes in — each arm alone must yield
2083 /// `OversizedRequirement` rather than a placeholder or rewrite verdict.
2084 #[test]
2085 fn test_oversized_dependency_requirement_or_literal_is_oversized_skip() {
2086 use crate::lsp_helpers::MAX_REQUIREMENT_LEN;
2087 use crate::lsp_helpers::test_support::ExactMatchFormatter;
2088
2089 struct LiteralDep {
2090 inner: MockDep,
2091 literal: String,
2092 }
2093 impl Dependency for LiteralDep {
2094 fn name(&self) -> &PackageName {
2095 self.inner.name()
2096 }
2097 fn name_range(&self) -> crate::position::Range {
2098 self.inner.name_range()
2099 }
2100 fn version_requirement(&self) -> Option<&VersionReq> {
2101 self.inner.version_requirement()
2102 }
2103 fn version_range(&self) -> Option<crate::position::Range> {
2104 self.inner.version_range()
2105 }
2106 fn version_literal(&self) -> Option<&str> {
2107 Some(&self.literal)
2108 }
2109 fn source(&self) -> crate::parser::DependencySource {
2110 self.inner.source()
2111 }
2112 fn as_any(&self) -> &dyn std::any::Any {
2113 self
2114 }
2115 }
2116
2117 let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
2118
2119 let oversized_requirement = dep("serde", &oversized, range(0, 8, 0, 9));
2120 assert_eq!(
2121 plan_verified_fix(
2122 &oversized_requirement,
2123 oversized_requirement.version_range,
2124 "1",
2125 "2",
2126 &ExactMatchFormatter,
2127 ),
2128 Err(VulnFixSkip::OversizedRequirement)
2129 );
2130
2131 let oversized_literal = LiteralDep {
2132 inner: dep("serde", "1", range(0, 8, 0, 9)),
2133 literal: oversized,
2134 };
2135 assert_eq!(
2136 plan_verified_fix(
2137 &oversized_literal,
2138 range(0, 8, 0, 9),
2139 "1",
2140 "2",
2141 &ExactMatchFormatter,
2142 ),
2143 Err(VulnFixSkip::OversizedRequirement)
2144 );
2145 }
2146
2147 /// A requirement the comparator confirms does NOT admit the fix target must still
2148 /// produce a rewrite — the #1344 gate must not suppress legitimate fixes.
2149 #[test]
2150 fn test_requirement_not_admitting_fix_returns_planned_edit() {
2151 let d = dep("serde", "0.9", range(0, 8, 0, 11));
2152 let dv = verified_dv("1.0.2");
2153
2154 let planned = plan_vulnerability_fix(
2155 &d,
2156 d.version_range,
2157 "0.9",
2158 &dv,
2159 None,
2160 &StrictSemverFormatter,
2161 )
2162 .expect("0.9 does not admit 1.0.2, so an edit must be planned");
2163 assert_eq!(planned.edit.new_text, "1.0.2");
2164 assert_eq!(planned.target.as_str(), "1.0.2");
2165 }
2166
2167 /// An ecosystem with no `compile_bounded_requirement` override (e.g. GitHub Actions/GitLab CI)
2168 /// has no comparator to consult, so the gate is inert and the textual no-op guard is
2169 /// the only available signal — a genuinely different target must still be planned.
2170 #[test]
2171 fn test_no_compile_requirement_override_falls_back_to_no_op_guard() {
2172 let d = dep("serde", "1", range(0, 8, 0, 9));
2173 let dv = verified_dv("1.0.2");
2174
2175 let planned =
2176 plan_vulnerability_fix(&d, d.version_range, "1", &dv, None, &MOCK_FORMATTER)
2177 .unwrap();
2178 assert_eq!(planned.edit.new_text, "\"1.0.2\"");
2179 }
2180
2181 /// A malformed `fixed_versions` entry (as if it somehow reached this dependency's
2182 /// `advisories` despite OSV's own wire-boundary validation) must be rejected before
2183 /// any other gate runs — `VulnFixSkip::UnsafeVersion`, not a planned edit.
2184 #[test]
2185 fn test_unsafe_fix_version_is_rejected() {
2186 let d = dep("serde", "0.9", range(0, 8, 0, 11));
2187 // A space is not in `is_safe_version_string`'s allowlist.
2188 let dv = verified_dv("1.2.0 evil");
2189
2190 let planned =
2191 plan_vulnerability_fix(&d, d.version_range, "0.9", &dv, None, &MOCK_FORMATTER);
2192 assert_eq!(planned, Err(VulnFixSkip::UnsafeVersion));
2193 }
2194
2195 /// A genuine textual no-op — the formatter's rewrite is byte-identical to the literal
2196 /// fallback `current` text — must be `VulnFixSkip::NoOpRewrite`, distinct from
2197 /// `RequirementAlreadyResolves` above (no `compile_bounded_requirement` override here, so that
2198 /// gate never fires; this is the plain textual guard alone).
2199 #[test]
2200 fn test_true_no_op_rewrite_is_rejected() {
2201 let d = dep("serde", "1", range(0, 8, 0, 9));
2202 let dv = verified_dv("1.0.2");
2203
2204 // `MOCK_FORMATTER.format_version_for_text_edit` quotes its input, so the
2205 // already-quoted literal fallback below is byte-identical to the planned rewrite.
2206 let planned = plan_vulnerability_fix(
2207 &d,
2208 d.version_range,
2209 "\"1.0.2\"",
2210 &dv,
2211 None,
2212 &MOCK_FORMATTER,
2213 );
2214 assert_eq!(planned, Err(VulnFixSkip::NoOpRewrite));
2215 }
2216
2217 /// #1422 regression: a dependency with more than `ADVISORY_DISPLAY_CAP` advisories must
2218 /// still resolve and verify a fix computed from an advisory beyond that display cap —
2219 /// `resolve_recommended_fix`/`fix_target_is_verified` must read the full
2220 /// `MAX_ADVISORY_RECORDS`-capped `advisories` set, not the render-only
2221 /// `advisories_for_display` truncation.
2222 #[test]
2223 fn test_plan_vulnerability_fix_uses_advisory_beyond_display_cap() {
2224 use crate::osv::ADVISORY_DISPLAY_CAP;
2225
2226 let mut advisories: Vec<std::sync::Arc<Advisory>> = (0..ADVISORY_DISPLAY_CAP + 3)
2227 .map(|i| {
2228 std::sync::Arc::new(
2229 Advisory::new(
2230 format!("ADV-{i}"),
2231 "2024-01-01T00:00:00Z".to_string(),
2232 VulnSeverity::High,
2233 )
2234 .expect("valid osv id")
2235 .with_fixed_versions(vec![OsvVersion::new("1.0.0")]),
2236 )
2237 })
2238 .collect();
2239 // The true highest fix sits past ADVISORY_DISPLAY_CAP; every other advisory fixes
2240 // at a lower version.
2241 let beyond_cap_index = ADVISORY_DISPLAY_CAP + 1;
2242 advisories[beyond_cap_index] = std::sync::Arc::new(
2243 Advisory::new(
2244 format!("ADV-{beyond_cap_index}"),
2245 "2024-01-01T00:00:00Z".to_string(),
2246 VulnSeverity::High,
2247 )
2248 .expect("valid osv id")
2249 .with_fixed_versions(vec![OsvVersion::new("2.0.0")]),
2250 );
2251 let total = advisories.len();
2252 let dv = DependencyVulnerabilities::new(Capped::new(advisories, total))
2253 .with_fix_target_status(UpgradeStatus::CandidateClean {
2254 version: ConcreteVersion::new("2.0.0"),
2255 });
2256
2257 let d = dep("serde", "0.9", range(0, 8, 0, 11));
2258 let planned =
2259 plan_vulnerability_fix(&d, d.version_range, "0.9", &dv, None, &MOCK_FORMATTER)
2260 .expect("fix beyond the display cap must still be recommended and verified");
2261 assert_eq!(planned.edit.new_text, "\"2.0.0\"");
2262 assert_eq!(planned.target.as_str(), "2.0.0");
2263 }
2264
2265 /// A synthetic formatter mimicking a resolver that pins a bare requirement to its
2266 /// *lowest* admissible member (NuGet's bare `Version="1.0.0"` floor, #1344 C1) rather
2267 /// than following forward to the newest one. Its raw `compile_bounded_requirement` matcher is
2268 /// deliberately permissive (matches any version at or above the floor, exactly like
2269 /// NuGet's lower-bound-only shape) so this test proves `plan_vulnerability_fix` consults
2270 /// `bounded_requirement_already_resolves_to`'s override — which correctly refuses to
2271 /// suppress — rather than the raw matcher alone.
2272 struct FloorFormatter;
2273
2274 struct FloorMatcher(String);
2275 impl RequirementMatcher for FloorMatcher {
2276 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
2277 Some(version.as_str() >= self.0.as_str())
2278 }
2279
2280 fn strict_prerelease_exclusion(&self) -> bool {
2281 false
2282 }
2283 }
2284
2285 impl PackageNaming for FloorFormatter {}
2286 impl PackageRendering for FloorFormatter {
2287 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
2288 version.to_string()
2289 }
2290 fn package_url(&self, name: &PackageName) -> String {
2291 name.as_str().to_string()
2292 }
2293 }
2294 impl RequirementResolution for FloorFormatter {
2295 fn compile_bounded_requirement(
2296 &self,
2297 requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
2298 ) -> Option<Box<dyn RequirementMatcher>> {
2299 Some(Box::new(FloorMatcher(requirement.as_str().to_string())))
2300 }
2301
2302 fn bounded_requirement_already_resolves_to(
2303 &self,
2304 _requirement: crate::lsp_helpers::BoundedVersionReq<'_>,
2305 _target: &ConcreteVersion,
2306 ) -> bool {
2307 // A floor never auto-follows forward — leaving it unedited always keeps
2308 // resolving to the floor itself, never to a higher target.
2309 false
2310 }
2311 }
2312 impl DiagnosticMessages for FloorFormatter {}
2313 impl DiagnosticPolicy for FloorFormatter {}
2314 impl SourcePolicy for FloorFormatter {}
2315 impl OsvNaming for FloorFormatter {}
2316
2317 /// #1344 C1 regression: a floor-shaped requirement must NOT be suppressed just
2318 /// because `compile_bounded_requirement`'s raw matcher admits the fix target — the resolver
2319 /// keeps pinning to the floor, so an edit is still the only way to actually apply the
2320 /// fix. See `deps-nuget`'s real `bounded_requirement_already_resolves_to` override for the
2321 /// concrete regression this mirrors.
2322 #[test]
2323 fn test_floor_shaped_requirement_still_returns_planned_edit() {
2324 let d = dep("nuget.pkg", "1.0.0", range(0, 8, 0, 15));
2325 let dv = verified_dv("1.0.2");
2326
2327 // Sanity: the raw matcher alone would wrongly admit the fix, if the gate used it
2328 // directly instead of `bounded_requirement_already_resolves_to`.
2329 assert_eq!(
2330 FloorFormatter
2331 .compile_requirement(&crate::VersionReq::new("1.0.0"))
2332 .unwrap()
2333 .matches(&ConcreteVersion::new("1.0.2")),
2334 Some(true)
2335 );
2336
2337 let planned =
2338 plan_vulnerability_fix(&d, d.version_range, "1.0.0", &dv, None, &FloorFormatter)
2339 .expect("a floor-shaped requirement must not suppress the fix");
2340 assert_eq!(planned.edit.new_text, "1.0.2");
2341 }
2342
2343 #[test]
2344 fn test_plan_vulnerability_fix_ignores_requirement_is_unresolved() {
2345 let action_dep = MockDep {
2346 name: pkg("some-action"),
2347 version_req: VersionReq::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
2348 version_range: range(0, 9, 0, 49),
2349 name_range: crate::position::Range::default(),
2350 };
2351 let dv = verified_dv("1.2.0");
2352
2353 let planned = plan_vulnerability_fix(
2354 &action_dep,
2355 action_dep.version_range,
2356 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2357 &dv,
2358 None,
2359 &ShaPinFormatter,
2360 );
2361
2362 assert_eq!(
2363 planned.expect("fix must still be planned").edit.new_text,
2364 "1.2.0 # v-resolved"
2365 );
2366 }
2367 }
2368}