deps_core/lsp_helpers/mod.rs
1//! Shared LSP response builders.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use std::time::Duration;
6
7use crate::error::DepsError;
8use crate::licenses::LicensePolicy;
9use crate::osv::{
10 CandidateStatusMap, CandidateStatuses, LatestStatusMap, ScanOutcome, StructuralSkipReason,
11 UpgradeStatus, VulnKey, VulnSeverity, VulnerabilityMap,
12};
13use crate::position::{Position, Range};
14use crate::{
15 ConcreteVersion, CooldownWindow, Dependency, Deprecation, DepsDevClient, EcosystemId,
16 FetchFailure, GossipFindings, LicenseSource, PackageName, PublishTime, RemovalStatus,
17 TyposquatSignal, VersionReq,
18};
19
20#[cfg(feature = "lsp-responses")]
21mod code_actions;
22#[cfg(feature = "lsp-responses")]
23mod code_lenses;
24mod diagnostics;
25mod formatter;
26mod git_ref;
27#[cfg(feature = "lsp-responses")]
28mod hover;
29#[cfg(feature = "lsp-responses")]
30mod hover_markdown;
31mod in_use_version;
32#[cfg(feature = "lsp-responses")]
33mod inlay_hints;
34#[cfg(test)]
35pub(crate) mod test_support;
36#[cfg(test)]
37#[cfg(feature = "lsp-responses")]
38pub(crate) mod test_support_lsp;
39
40/// Generic replacement for this module's former `TextEdit`-only `dedup_overlapping_edits`
41/// (#1329) — re-exported here so every existing `lsp_helpers::dedup_overlapping_edits(edits,
42/// caller)` call site (e.g. `deps_github_actions::collect_pin_all_to_sha_edits`) keeps
43/// compiling unchanged, with `E = ls_types::TextEdit` inferred from the call's own argument
44/// type.
45#[cfg(feature = "lsp-responses")]
46pub use crate::edit::dedup_overlapping_edits;
47#[cfg(feature = "lsp-responses")]
48pub use code_actions::generate_code_actions;
49#[cfg(feature = "lsp-responses")]
50pub use code_lenses::{
51 PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
52 generate_code_lenses,
53};
54pub use diagnostics::{
55 BoundedVersionReq, DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities,
56 LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE, MAX_DIAGNOSTIC_VALUE_CHARS, MAX_REQUIREMENT_LEN,
57 TYPOSQUAT_DIAGNOSTIC_CODE, TyposquatFetchOutcome, UNSATISFIABLE_DIAGNOSTIC_CODE,
58 compile_requirement_unless, fetch_gossip_findings_batch, fetch_typosquat_signals,
59 force_refresh_gossip_findings, generate_diagnostics_from_cache, redact_name_for_diagnostic,
60 redact_requirement_for_diagnostic, requirement_is_unsatisfiable, requirement_len_exceeds_cap,
61 sanitize_advisory_text_for_diagnostic, sanitize_and_truncate_for_diagnostic,
62 truncate_for_diagnostic,
63};
64// `pub` (#1578): `edit.rs`/`in_use_version.rs` share this in-crate, and it is also reused by
65// callers outside `deps-core` that need this same size gate before compiling a requirement
66// (mirroring `requirement_len_exceeds_cap`'s own `pub` rationale) rather than duplicating the
67// length check at their own `compile_bounded_requirement` call site (#1472).
68pub use diagnostics::requirement_is_oversized;
69// `pub(crate)` (not `pub`, matching the constant's own visibility) so `completion.rs` can
70// share this bound with `inlay_hints`/`hover` rather than declaring a duplicate cap.
71// `completion` is itself `#[cfg(feature = "lsp-responses")]` (see `lib.rs`) and is this
72// re-export's only consumer outside `lsp_helpers`, so without this gate a default-features
73// build (e.g. `fuzz`'s workspace check) sees it as unused.
74#[cfg(feature = "lsp-responses")]
75pub(crate) use diagnostics::MAX_VERSION_DIAGNOSTIC_CHARS;
76pub use formatter::{
77 BareMeaning, BuildMetadataPolicy, DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter,
78 OsvNameAvailability, OsvNaming, PackageNaming, PackageRendering, RequirementGate,
79 RequirementResolution, RequirementRewriteShape, SourcePolicy, bare_meaning,
80 classify_requirement_rewrite_shape, format_version_replacing_by_shape, requirement_is_compound,
81 up_to_date_for_comparators_via_compiled_matcher, up_to_date_via_compiled_matcher,
82};
83pub use git_ref::{
84 CommitSha, MAX_FALLBACK_SCAN_BYTES, MarkedScalar, ResolvedPin, ShaPinLookup, SiblingTags,
85 TagIndex, byte_span_to_range, extends_tag, is_full_sha, is_null_tag, is_partial_semver_shaped,
86 is_plain_null, is_tag_shaped, locate_value_span, marker_byte_offset, match_v_prefix_style,
87 short_sha,
88};
89#[cfg(feature = "lsp-responses")]
90pub use git_ref::{
91 ResolvedShaPin, ShaPinning, build_sha_pin_action, sha_pin_text_edit, splice_hover_line,
92 splice_resolved_line,
93};
94#[cfg(feature = "lsp-responses")]
95pub use hover::{CMD_DOT_FOOTER, generate_hover};
96#[cfg(feature = "lsp-responses")]
97pub use hover_markdown::{FieldKind, HoverMarkdown, SafeNumber};
98pub use in_use_version::{
99 InUseVersions, concrete_pin_version, has_unqueryable_resolved_pin, is_full_semver_shape,
100 resolve_in_use_version, resolve_in_use_versions,
101};
102#[cfg(feature = "lsp-responses")]
103pub use inlay_hints::generate_inlay_hints;
104
105/// Maximum number of recent versions hover's "Recent versions" section renders.
106///
107/// Also the walk target for registries (NuGet, npm) that must fetch publish times for
108/// only the versions actually rendered, rather than the entire version history.
109pub const HOVER_RECENT_VERSIONS: usize = 8;
110
111/// A cooldown-cleared, ecosystem-safe, floor-protected fallback candidate (spec 075
112/// `deps-cli update` fallback, FR-001/FR-002).
113///
114/// Computed once, at fetch time, by `deps-engine::classify::fetch::fetch_and_classify_package`
115/// — never at read time (see [`cooldown_disposition`] for the read-time side). [`Self::published_at`]
116/// is never `None`: a candidate without a known publish time is never stored (OQ2's fail-closed
117/// rule for a non-`latest` candidate).
118#[non_exhaustive]
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CooldownFallback {
121 /// The fallback candidate's version.
122 pub version: ConcreteVersion,
123 /// When the fallback candidate was published. Never `None` — see the type's own doc.
124 pub published_at: PublishTime,
125}
126
127impl CooldownFallback {
128 /// Constructs a `CooldownFallback`.
129 ///
130 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
131 /// this crate, so every other crate must go through this constructor instead.
132 ///
133 /// # Examples
134 ///
135 /// ```
136 /// use deps_core::PublishTime;
137 /// use deps_core::lsp_helpers::CooldownFallback;
138 ///
139 /// let fallback = CooldownFallback::new("1.1.0".into(), PublishTime::from_unix_secs(1_000));
140 /// assert_eq!(fallback.version, "1.1.0");
141 /// ```
142 #[must_use]
143 pub const fn new(version: ConcreteVersion, published_at: PublishTime) -> Self {
144 Self {
145 version,
146 published_at,
147 }
148 }
149}
150
151/// What blocked [`PackageVersions::latest`] from being usable as-is — drives
152/// check/hover/diagnostics wording. Carried by [`CooldownDisposition::Blocked`].
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub enum CooldownBlocker {
155 /// GOSSIP reported an active cooldown for this exact version (spec 072 FR-005).
156 Gossip,
157 /// The local [`CooldownWindow`] heuristic reports this version as
158 /// recently published, with no authoritative GOSSIP answer available for it.
159 Local {
160 /// When `latest` was published, per the registry.
161 published_at: PublishTime,
162 },
163}
164
165/// Read-time-only outcome of [`cooldown_disposition`] — never stored on [`PackageVersions`].
166///
167/// Spec 075 NFR-002: a stored disposition would be a fetch-time snapshot, violating spec 072
168/// FR-011's read-time evaluation requirement.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum CooldownDisposition<'a> {
171 /// The freshness feature is disabled — cooldown has no effect at all.
172 NotEvaluated,
173 /// `latest` is not blocked by cooldown (no GOSSIP verdict, or a past one; no active local
174 /// heuristic result).
175 Cleared,
176 /// `latest` is currently blocked. `fallback`, when present, is the newest
177 /// cooldown-cleared, ecosystem-safe, floor-protected alternative a caller may offer
178 /// instead (spec 075) — `None` when no such candidate was found at fetch time.
179 Blocked {
180 /// What blocked `latest`.
181 by: CooldownBlocker,
182 /// The fallback candidate, if one was computed and stored for this dependency.
183 fallback: Option<&'a CooldownFallback>,
184 },
185}
186
187/// Outcome of [`cooldown_precedence`]: whether a single version is currently blocked by the
188/// freshness cooldown, and by what.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum CooldownPrecedence {
191 /// Not blocked (no active GOSSIP verdict, or a past one; no active local heuristic result).
192 Cleared,
193 /// Currently blocked, and by what.
194 Blocked(CooldownBlocker),
195}
196
197/// The GOSSIP-vs-local cooldown precedence rule for a single `(name, version)` pair.
198///
199/// An authoritative GOSSIP verdict wins outright, and only when GOSSIP has none does the local
200/// heuristic ([`local_cooldown_precedence`]) apply.
201///
202/// Extracted from [`cooldown_disposition`] (spec 075 FR-004) so `deps-engine`'s fetch-time
203/// fallback-candidate search (`compute_cooldown_fallback`) can share the exact same rule
204/// without first needing a full [`PackageVersions`] — that type doesn't exist yet at fetch
205/// time for any version other than the one being classified (issue #1551).
206///
207/// Takes a [`CooldownWindow`] rather than the [`FreshnessSettings`
208/// enum](crate::FreshnessSettings) so callers naturally go through
209/// [`FreshnessSettings::Enabled`](crate::FreshnessSettings::Enabled) — the only place a window
210/// value is produced — though `CooldownWindow::from_secs` being `pub` means this is a
211/// convention, not a hard type-level guarantee.
212///
213/// # Examples
214///
215/// ```
216/// use deps_core::lsp_helpers::{CooldownPrecedence, cooldown_precedence};
217/// use deps_core::{CooldownWindow, PackageName, PublishTime};
218///
219/// let cleared = cooldown_precedence(
220/// None,
221/// &PackageName::new("left-pad"),
222/// "2.0.0",
223/// Some(PublishTime::from_unix_secs(1_000)),
224/// CooldownWindow::from_secs(500),
225/// PublishTime::from_unix_secs(2_000),
226/// );
227/// assert_eq!(cleared, CooldownPrecedence::Cleared);
228///
229/// let blocked = cooldown_precedence(
230/// None,
231/// &PackageName::new("left-pad"),
232/// "2.0.0",
233/// Some(PublishTime::from_unix_secs(1_000)),
234/// CooldownWindow::from_secs(5_000),
235/// PublishTime::from_unix_secs(2_000),
236/// );
237/// assert!(matches!(blocked, CooldownPrecedence::Blocked(_)));
238/// ```
239#[must_use]
240pub fn cooldown_precedence(
241 gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
242 name: &PackageName,
243 version: &str,
244 published_at: Option<PublishTime>,
245 cooldown: CooldownWindow,
246 now: PublishTime,
247) -> CooldownPrecedence {
248 match gossip_cooldown_for(gossip_prefetch, name, version, now) {
249 GossipCooldownLookup::Active => CooldownPrecedence::Blocked(CooldownBlocker::Gossip),
250 GossipCooldownLookup::NotActive => CooldownPrecedence::Cleared,
251 GossipCooldownLookup::Unavailable => local_cooldown_precedence(published_at, cooldown, now),
252 }
253}
254
255/// The local freshness heuristic alone (`published_at` age against `cooldown`), without GOSSIP.
256///
257/// Extracted from [`cooldown_precedence`]'s `Unavailable` arm so every site that evaluates the
258/// local rule by itself — that arm, and [`cooldown_disposition`]'s read-time fallback
259/// re-evaluation — shares one implementation rather than reimplementing the age comparison.
260///
261/// # Examples
262///
263/// ```
264/// use deps_core::lsp_helpers::{CooldownPrecedence, local_cooldown_precedence};
265/// use deps_core::{CooldownWindow, PublishTime};
266///
267/// let now = PublishTime::from_unix_secs(2_000);
268/// let cooldown = CooldownWindow::from_secs(500);
269///
270/// // No known publish time: never blocked.
271/// assert_eq!(
272/// local_cooldown_precedence(None, cooldown, now),
273/// CooldownPrecedence::Cleared
274/// );
275///
276/// // Published recently: blocked.
277/// let recent = PublishTime::from_unix_secs(1_900);
278/// assert!(matches!(
279/// local_cooldown_precedence(Some(recent), cooldown, now),
280/// CooldownPrecedence::Blocked(_)
281/// ));
282///
283/// // Age exactly equal to the window: cleared (the bound is exclusive).
284/// let at_boundary = PublishTime::from_unix_secs(1_500);
285/// assert_eq!(
286/// local_cooldown_precedence(Some(at_boundary), cooldown, now),
287/// CooldownPrecedence::Cleared
288/// );
289/// ```
290#[must_use]
291pub fn local_cooldown_precedence(
292 published_at: Option<PublishTime>,
293 cooldown: CooldownWindow,
294 now: PublishTime,
295) -> CooldownPrecedence {
296 match published_at {
297 Some(published_at) if cooldown.contains(published_at.age_secs_from(now)) => {
298 CooldownPrecedence::Blocked(CooldownBlocker::Local { published_at })
299 }
300 _ => CooldownPrecedence::Cleared,
301 }
302}
303
304/// Evaluates, at read time, whether [`PackageVersions::latest`] is blocked by the freshness
305/// cooldown and whether a stored [`PackageVersions::cooldown_fallback`] candidate is available.
306///
307/// Spec 075 FR-004's single precedence function, shared by `apply_outdated_rule`
308/// (check/diagnostics wording) and `deps-cli update`'s planner so the two commands can no
309/// longer disagree (spec 075 NFR-001 steps 0-4; OSV verification is deliberately NOT this
310/// function's job — see spec 075 FR-010/FR-011). The GOSSIP-vs-local precedence itself is
311/// [`cooldown_precedence`]; this function adds the read-time fallback re-evaluation on top.
312///
313/// # Examples
314///
315/// ```
316/// use deps_core::lsp_helpers::{CooldownDisposition, PackageVersions, cooldown_disposition};
317/// use deps_core::{FreshnessSettings, PackageName, PublishTime};
318/// use std::sync::Arc;
319///
320/// let versions = PackageVersions::new(
321/// "2.0.0".into(),
322/// Arc::from(vec!["2.0.0".into(), "1.0.0".into()]),
323/// )
324/// .with_published_at(PublishTime::from_unix_secs(1_000));
325///
326/// let disposition = cooldown_disposition(
327/// &versions,
328/// &PackageName::new("left-pad"),
329/// FreshnessSettings::Enabled {
330/// cooldown: deps_core::CooldownWindow::from_secs(500),
331/// },
332/// None,
333/// PublishTime::from_unix_secs(2_000),
334/// );
335/// assert_eq!(disposition, CooldownDisposition::Cleared);
336///
337/// let blocked = cooldown_disposition(
338/// &versions,
339/// &PackageName::new("left-pad"),
340/// FreshnessSettings::Enabled {
341/// cooldown: deps_core::CooldownWindow::from_secs(5_000),
342/// },
343/// None,
344/// PublishTime::from_unix_secs(2_000),
345/// );
346/// assert!(matches!(blocked, CooldownDisposition::Blocked { .. }));
347/// ```
348#[must_use]
349pub fn cooldown_disposition<'a>(
350 versions: &'a PackageVersions,
351 name: &PackageName,
352 freshness: crate::freshness::FreshnessSettings,
353 gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
354 now: PublishTime,
355) -> CooldownDisposition<'a> {
356 let crate::freshness::FreshnessSettings::Enabled { cooldown } = freshness else {
357 return CooldownDisposition::NotEvaluated;
358 };
359
360 // NFR-002 (M3): the stored fallback candidate is itself re-evaluated at read time — a
361 // cooldown window narrowed between fetch and read (or, for a long-lived LSP cache, simply
362 // enough wall-clock time passing that the fetch-time computation has gone stale in the
363 // *other* direction the caller didn't ask about) must never let a candidate that no
364 // longer clears the window keep reading as usable. This can only make the outcome
365 // stricter, never less safe — a `None` fallback stays `None`.
366 let fallback = versions.cooldown_fallback.as_ref().filter(|fallback| {
367 matches!(
368 local_cooldown_precedence(Some(fallback.published_at), cooldown, now),
369 CooldownPrecedence::Cleared
370 )
371 });
372
373 match cooldown_precedence(
374 gossip_prefetch,
375 name,
376 versions.latest.as_str(),
377 versions.published_at,
378 cooldown,
379 now,
380 ) {
381 CooldownPrecedence::Cleared => CooldownDisposition::Cleared,
382 CooldownPrecedence::Blocked(by) => CooldownDisposition::Blocked { by, fallback },
383 }
384}
385
386/// Result of [`fallback_edit_excludes_newer`] (spec 076 FR-023): whether the ecosystem's
387/// default-rendered fallback edit is safe to write.
388#[derive(Debug, Clone, Copy, PartialEq, Eq)]
389pub enum FallbackEditVerdict {
390 /// The edit is safe: applying and re-parsing it still excludes every known `available`
391 /// version newer than the fallback candidate.
392 Writable,
393 /// The edit was rejected — see [`FallbackEditRejection`] for which check failed.
394 Rejected(FallbackEditRejection),
395}
396
397/// Which [`fallback_edit_excludes_newer`] check rejected the fallback edit, in evaluation
398/// order (first failure wins; phase 2 is only reached once every phase-1 check passes).
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub enum FallbackEditRejection {
401 /// Precondition (round-4 critic D5, fix-cycle M2): `candidate`'s span does not equal this
402 /// occurrence's own `Dependency::version_range()` — the edit was built for a different
403 /// occurrence (or a stale one), so nothing below is meaningful for THIS dependency.
404 /// Production always passes equality (the edit is always built from the same dependency's
405 /// own `version_range()`); this guards the public API against a caller passing a
406 /// mismatched edit.
407 CandidateSpanMismatch,
408 /// Fix-cycle (issue #1561/CWE-1284 class): `fallback` itself is absent from `available` —
409 /// a stale or inconsistent candidate computed against a version list that has since
410 /// changed. Checked once, shared by both d0 and d1, before either can read `fallback`'s
411 /// position: never infer "not a downgrade" from the absence of evidence.
412 FallbackUnlisted,
413 /// Phase 1, a0 pre-check (issue #1580, CWE-400 defense-in-depth mirroring #1472/#1578):
414 /// R0's raw string exceeds [`crate::lsp_helpers::MAX_REQUIREMENT_LEN`] —
415 /// `compile_bounded_requirement` is never called for it. A size-based fail-closed guard, distinct
416 /// from [`Self::OriginalUncompilable`] (a confirmed parse failure on an attempted compile).
417 OriginalOversized,
418 /// Phase 1, a0: the ORIGINAL declared requirement has no compiled matcher (e.g. GitHub
419 /// Actions/GitLab CI tag pins) — unchanged from spec 075.
420 OriginalUncompilable,
421 /// Phase 1, c0: the original requirement already reads the fallback as current (a
422 /// below-floor fallback, e.g. NuGet's bare-floor shape).
423 OriginalAlreadyUpToDate,
424 /// Phase 1, d0: the original requirement's own floor (the OLDEST `available` entry it
425 /// still matches) is newer than `fallback` — writing it would be a downgrade below what R0
426 /// already resolves to, or no listed entry evidences the floor at all (issues #1564/#1561:
427 /// fixed from an earlier "reject if anything newer also matches" reading, which rejected
428 /// nearly every permissive/auto-following requirement, and from a "no evidence means safe"
429 /// vacuous accept on an unlisted declared pin).
430 OriginalResolvesPastFallback,
431 /// Fix-cycle (#1571): the original declared requirement admits some entry strictly newer
432 /// than `fallback`, or a matcher-reported `!=`-style term explicitly bans `fallback`
433 /// (`RequirementMatcher::explicitly_excludes`), yet `fallback` itself fails
434 /// `r0_matcher.matches` — `fallback` sits inside R0's nominal span (or one of its `||`
435 /// branches, for an ecosystem with alternation) but is punched out of it (a `!=` term, or a
436 /// `||`-alternation gap), which a bare floor-position comparison (d0 above) cannot see since
437 /// neither hole shape moves the floor. Distinct from the legitimate case where `fallback`
438 /// fails `r0_matcher.matches` only because it exceeds every branch's ceiling entirely
439 /// (FR-025's out-of-range acceptance).
440 OriginalExcludesFallback,
441 /// The edited manifest could not be re-parsed (FR-024) — [`crate::edit::ManifestReparse`]
442 /// returned `None`, e.g. an `Ecosystem::parse_manifest_sync` override or default that itself
443 /// fails to parse the edited scratch copy.
444 ReparseFailed,
445 /// The re-parsed manifest did not contain exactly one dependency matching this occurrence's
446 /// `(normalized name, version_range.start)`.
447 OccurrenceNotUnique,
448 /// Phase 2, a1 pre-check: same guard as [`Self::OriginalOversized`], applied to R1 (the
449 /// re-parsed EDITED requirement) before its own `compile_bounded_requirement` attempt.
450 EditedOversized,
451 /// Phase 2, a1: the re-parsed, EDITED requirement has no compiled matcher.
452 EditedUncompilable,
453 /// Phase 2, b1: the edited requirement's matcher does not accept the fallback itself — the
454 /// written edit does not actually express the fallback version.
455 EditedExcludesFallback,
456 /// Phase 2, d1: the edited (WRITTEN) requirement admits some `available` entry strictly
457 /// newer than the fallback — the edit auto-follows back into an in-cooldown version. Unlike
458 /// d0, this is NOT a floor comparison: an auto-following range (Cargo's caret, Dart's caret,
459 /// PyPI's default `>=X,<next`) has its floor AT the fallback itself by construction (the
460 /// edit was rendered FROM `fallback`), so a floor check alone would never catch a range that
461 /// also admits something newer above it — exactly the case round-1 critic S1 exists to
462 /// reject (spec 076 §1's documented, intentional fail-closed outcome for these ecosystems).
463 EditedAdmitsNewer,
464}
465
466/// Spec 076 FR-023/FR-024: THE uniform guard deciding whether a cooldown-fallback candidate's
467/// default-rendered edit is safe to write.
468///
469/// Applied identically to spec 075's lockfile-resolved path and spec 076's no-lockfile path —
470/// no per-ecosystem override or retry (FR-025).
471///
472/// Two phases, evaluated in this exact order, first failure wins:
473///
474/// - **Phase 1**, on `R0 = dep.version_requirement()` (the ORIGINAL declared requirement, no
475/// parse needed): a0-pre (issue #1580, CWE-400 defense-in-depth) R0 must not be
476/// [`BoundedVersionReq::new`] (i.e. not [`requirement_is_oversized`]) — `compile_bounded_requirement`
477/// is never called for an oversized requirement; a0 `compile_bounded_requirement(R0)` must be
478/// `Some`; c0 `is_bounded_requirement_up_to_date(R0, fallback)` must be `false` (closes a NuGet bare-floor gap,
479/// spec 076 round-1 critic S2); d0 no `available` entry STRICTLY newer than `fallback` may
480/// satisfy `bounded_requirement_already_resolves_to(R0, entry)` (anti-downgrade — writing `fallback`
481/// must not move resolution backward relative to what R0 already resolves to); fix-cycle #1571:
482/// when `fallback` itself does not match R0's compiled matcher, R0 must not directly report
483/// `fallback` as `!=`-excluded ([`RequirementMatcher::explicitly_excludes`]) and it must not
484/// be the case that some entry strictly newer than `fallback` still matches R0 — otherwise
485/// `fallback` sits inside R0's nominal span (or a `||` branch's) but not R0 itself (a `!=`
486/// term or a `||`-alternation gap), which the floor comparison alone cannot see
487/// ([`FallbackEditRejection::OriginalExcludesFallback`]).
488/// - **Re-parse** (only if phase 1 passes, FR-024): `candidate` is applied to a scratch copy of
489/// `content`, the copy is re-parsed via `reparse`, and the edited occurrence is located by
490/// `(formatter.normalize_package_name(dep.name()), version_range().start)` — NOT `name_range`,
491/// since some grammars (NuGet's `Version`-before-`Include` attribute order, Maven's XML
492/// element order, Gradle's map notation) put the version before the name, so `name_range`
493/// would shift under the edit and silently fail closed. Exactly one match is required; zero
494/// or more than one is [`FallbackEditRejection::OccurrenceNotUnique`].
495/// - **Phase 2**, on `R1` = the located occurrence's re-parsed requirement: a1-pre, the same
496/// [`BoundedVersionReq::new`] guard as a0-pre, applied to R1 before its own
497/// `compile_bounded_requirement` attempt; a1 `compile_bounded_requirement(R1)` must be `Some`; b1 R1's
498/// matcher must accept `fallback` itself
499/// (`Some(true)`) — proving the written edit actually expresses `fallback`, failing closed for
500/// an unmodellable/unsatisfiable written requirement; d1 no `available` entry STRICTLY newer
501/// than `fallback` may satisfy `bounded_requirement_already_resolves_to(R1, entry)` (the edit must not
502/// auto-follow back into a known newer, still-in-cooldown version).
503///
504/// Yanked entries in `available` are NOT excluded from the d0/d1 scans — conservative,
505/// fail-closed: a yanked newer version admitted by the requirement still rejects the fallback.
506///
507/// # Examples
508///
509/// ```
510/// use deps_core::edit::ManifestEdit;
511/// use deps_core::lsp_helpers::{
512/// BoundedVersionReq, DiagnosticMessages, DiagnosticPolicy, FallbackEditRejection, FallbackEditVerdict,
513/// OsvNaming, PackageNaming, PackageRendering, RequirementMatcher, RequirementResolution,
514/// SourcePolicy, fallback_edit_excludes_newer,
515/// };
516/// use deps_core::position::{Position, Range};
517/// use deps_core::{ConcreteVersion, Dependency, PackageName, ParseResult, VersionReq};
518///
519/// struct ExactMatcher(String);
520/// impl RequirementMatcher for ExactMatcher {
521/// fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
522/// Some(version.as_str() == self.0)
523/// }
524/// fn strict_prerelease_exclusion(&self) -> bool {
525/// false
526/// }
527/// }
528///
529/// struct ExactFormatter;
530/// impl PackageNaming for ExactFormatter {}
531/// impl PackageRendering for ExactFormatter {
532/// fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
533/// version.to_string()
534/// }
535/// fn package_url(&self, name: &PackageName) -> String {
536/// name.as_str().to_string()
537/// }
538/// }
539/// impl RequirementResolution for ExactFormatter {
540/// fn compile_bounded_requirement(
541/// &self,
542/// requirement: BoundedVersionReq<'_>,
543/// ) -> Option<Box<dyn RequirementMatcher>> {
544/// Some(Box::new(ExactMatcher(requirement.as_str().to_string())))
545/// }
546/// }
547/// impl DiagnosticMessages for ExactFormatter {}
548/// impl DiagnosticPolicy for ExactFormatter {}
549/// impl SourcePolicy for ExactFormatter {}
550/// impl OsvNaming for ExactFormatter {}
551///
552/// struct ExactDependency;
553/// impl Dependency for ExactDependency {
554/// fn name(&self) -> &PackageName {
555/// static NAME: std::sync::LazyLock<PackageName> =
556/// std::sync::LazyLock::new(|| PackageName::new("pkg"));
557/// &NAME
558/// }
559/// fn name_range(&self) -> Range {
560/// Range::default()
561/// }
562/// fn version_requirement(&self) -> Option<&VersionReq> {
563/// static REQ: std::sync::LazyLock<VersionReq> =
564/// std::sync::LazyLock::new(|| VersionReq::new("1.0.0"));
565/// Some(&REQ)
566/// }
567/// fn version_range(&self) -> Option<Range> {
568/// Some(Range::new(Position::new(0, 0), Position::new(0, 6)))
569/// }
570/// fn source(&self) -> deps_core::parser::DependencySource {
571/// deps_core::parser::DependencySource::Registry
572/// }
573/// fn as_any(&self) -> &dyn std::any::Any {
574/// self
575/// }
576/// }
577///
578/// let formatter = ExactFormatter;
579/// let dep = ExactDependency;
580/// let candidate = ManifestEdit {
581/// range: Range::new(Position::new(0, 0), Position::new(0, 6)),
582/// new_text: "1.1.0".to_string(),
583/// };
584/// let fallback = ConcreteVersion::new("1.1.0");
585/// let available = [ConcreteVersion::new("1.1.0"), ConcreteVersion::new("1.0.0")];
586/// // A stub `ManifestReparse` that always fails — demonstrates the fail-closed re-parse gate.
587/// let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
588///
589/// let verdict = fallback_edit_excludes_newer(
590/// &formatter,
591/// &reparse,
592/// "1.0.0",
593/// &dep,
594/// &candidate,
595/// &fallback,
596/// &available,
597/// );
598/// assert_eq!(
599/// verdict,
600/// FallbackEditVerdict::Rejected(FallbackEditRejection::ReparseFailed)
601/// );
602/// ```
603#[must_use]
604pub fn fallback_edit_excludes_newer(
605 formatter: &dyn crate::lsp_helpers::EcosystemFormatter,
606 reparse: &dyn crate::edit::ManifestReparse,
607 content: &str,
608 dep: &dyn Dependency,
609 candidate: &crate::edit::ManifestEdit,
610 fallback: &ConcreteVersion,
611 available: &[ConcreteVersion],
612) -> FallbackEditVerdict {
613 use FallbackEditRejection::{
614 CandidateSpanMismatch, EditedAdmitsNewer, EditedExcludesFallback, EditedOversized,
615 EditedUncompilable, FallbackUnlisted, OccurrenceNotUnique, OriginalAlreadyUpToDate,
616 OriginalExcludesFallback, OriginalOversized, OriginalResolvesPastFallback,
617 OriginalUncompilable, ReparseFailed,
618 };
619
620 // Precondition (D5, fix-cycle M2): `candidate` must target THIS occurrence's own span.
621 if Some(candidate.range) != dep.version_range() {
622 return FallbackEditVerdict::Rejected(CandidateSpanMismatch);
623 }
624
625 // Phase 1: R0, the ORIGINAL declared requirement — no parse needed.
626 let Some(r0) = dep.version_requirement() else {
627 return FallbackEditVerdict::Rejected(OriginalUncompilable);
628 };
629 // a0-pre (issue #1580, CWE-400 defense-in-depth mirroring #1472/#1578): fail closed before
630 // `compile_bounded_requirement` ever sees an oversized requirement string.
631 let Some(r0) = BoundedVersionReq::new(r0) else {
632 return FallbackEditVerdict::Rejected(OriginalOversized);
633 };
634 let Some(r0_matcher) = formatter.compile_bounded_requirement(r0) else {
635 return FallbackEditVerdict::Rejected(OriginalUncompilable);
636 };
637
638 // Fix-cycle (issues #1564/#1561, CWE-1284 class): `fallback`'s own position is looked up
639 // ONCE here and shared by d0/d1 below — fails closed instead of letting a "no evidence"
640 // absence read as "safe" the way `available.iter().take_while(|v| *v != fallback)` did when
641 // `fallback` was never found at all (it would scan to the end without ever short-circuiting,
642 // silently changing what the scan even means). Ordered after a0 so an ecosystem with no
643 // compiled requirement model (GitHub Actions, GitLab CI) logs the more informative
644 // `OriginalUncompilable` instead of `FallbackUnlisted` — same final `Rejected` outcome either
645 // way, this only affects the `tracing::debug!` reason.
646 let Some(fallback_pos) = available.iter().position(|v| v == fallback) else {
647 return FallbackEditVerdict::Rejected(FallbackUnlisted);
648 };
649
650 if formatter.is_bounded_requirement_up_to_date(r0, fallback) {
651 return FallbackEditVerdict::Rejected(OriginalAlreadyUpToDate);
652 }
653 // d0 (issues #1564/#1561 fix, matching #1565's shipped floor-comparison exactly): a FLOOR
654 // comparison over R0's raw admitted-set membership (`compile_bounded_requirement(..).matches`), not
655 // "does anything newer also match" and NOT `bounded_requirement_already_resolves_to` — that stricter
656 // predicate is deliberately always `false` for a floor-shaped requirement (NuGet's bare
657 // `Version="1.0.0"`, see its `bounded_requirement_already_resolves_to` doc), which would make this
658 // scan find no floor at all and fail closed on every floor-type ecosystem. `available` is
659 // newest-first, so the requirement's own floor — the OLDEST entry it still admits — sits at
660 // the LARGEST matching index. Accept iff that floor is at or after `fallback`'s own index
661 // (`fallback` is not older than what R0, left unedited, already resolves to); `None`
662 // (nothing evidences the floor at all, e.g. an unlisted declared pin) fails closed the same
663 // as a floor strictly newer than `fallback`.
664 let r0_floor = available
665 .iter()
666 .rposition(|v| r0_matcher.matches(v) == Some(true));
667 if r0_floor.is_none_or(|floor| floor < fallback_pos) {
668 return FallbackEditVerdict::Rejected(OriginalResolvesPastFallback);
669 }
670 // Fix-cycle (#1571): R0 admitting `fallback` inside its nominal span yet not `fallback`
671 // itself — a `!=`-style exclusion (PyPI/Composer/Bundler) or a `||`-alternation gap
672 // (Composer) punches a hole rather than shifting the floor, so the floor comparison above
673 // alone cannot see it. Two independent signals, either one rejects. `r0_floor ==
674 // Some(fallback_pos)` (code-review perf finding) means the floor scan above already proved
675 // `fallback` matches R0 — the floor is BY DEFINITION a matching index, so no need to call
676 // `matches` on `fallback` again for that common case.
677 let fallback_matches_r0 =
678 r0_floor == Some(fallback_pos) || r0_matcher.matches(fallback) == Some(true);
679 if !fallback_matches_r0 {
680 // (i) Intensional: the matcher itself names `fallback` as explicitly banned (critic
681 // fix-cycle S1 — `r0_matcher.explicitly_excludes` is the only way to tell a `!=` hole
682 // apart from a legitimate ceiling-exceeding fallback when NO listed `available` entry
683 // happens to evidence the hole either, e.g. `>=1.0,!=1.5.0,<2.0` with `fallback` ==
684 // 1.5.0 and `available` = [2.0.0, 1.5.0, 1.0.0] — 2.0.0 fails `matches` too (it's past
685 // the `<2.0` ceiling), so (ii) below is vacuous and only this direct check catches it).
686 if r0_matcher.explicitly_excludes(fallback) {
687 return FallbackEditVerdict::Rejected(OriginalExcludesFallback);
688 }
689 // (ii) Extensional, structural fallback net: some LISTED entry strictly newer than
690 // `fallback` still matches R0 (e.g. `r0_floor` (4) >= `fallback_pos` (2) passed above
691 // even though R0 explicitly excludes `fallback`, or a `||`-alternation gap like
692 // `^1.0 || ^3.0` with `fallback` = 2.5.0). A legitimate out-of-range fallback (e.g.
693 // Cargo's `^1.0` with fallback 2.5.0) has no R0-matching entry newer than it at all —
694 // FR-025's intentional ceiling-exceeding acceptance — so this never misfires on that
695 // case.
696 #[allow(clippy::indexing_slicing)]
697 let newer_than_fallback = &available[..fallback_pos];
698 if newer_than_fallback
699 .iter()
700 .any(|v| r0_matcher.matches(v) == Some(true))
701 {
702 return FallbackEditVerdict::Rejected(OriginalExcludesFallback);
703 }
704 }
705
706 // Re-parse (FR-024): apply the candidate edit to a scratch copy and re-parse it.
707 let edited_content = crate::edit::apply_edits(content, std::slice::from_ref(candidate));
708 let Some(parsed) = reparse.reparse(&edited_content) else {
709 return FallbackEditVerdict::Rejected(ReparseFailed);
710 };
711
712 // Locate the edited occurrence by (normalized name, version_range().start) — invariant
713 // under an edit that only changes text at or after it, unlike `name_range` (round-4 critic
714 // M3: NuGet/Maven/Gradle grammars can put the version before the name).
715 let key = formatter.normalize_package_name(dep.name());
716 let orig_start = dep.version_range().map(|r| r.start);
717 let mut candidates = parsed.dependencies().into_iter().filter(|edited_dep| {
718 formatter.normalize_package_name(edited_dep.name()) == key
719 && edited_dep.version_range().map(|r| r.start) == orig_start
720 });
721 let Some(edited_dep) = candidates.next() else {
722 return FallbackEditVerdict::Rejected(OccurrenceNotUnique);
723 };
724 if candidates.next().is_some() {
725 return FallbackEditVerdict::Rejected(OccurrenceNotUnique);
726 }
727
728 // Phase 2: R1, the re-parsed EDITED requirement.
729 let Some(r1) = edited_dep.version_requirement() else {
730 return FallbackEditVerdict::Rejected(EditedUncompilable);
731 };
732 // a1-pre: same guard as a0-pre, applied to R1.
733 let Some(r1) = BoundedVersionReq::new(r1) else {
734 return FallbackEditVerdict::Rejected(EditedOversized);
735 };
736 let Some(matcher) = formatter.compile_bounded_requirement(r1) else {
737 return FallbackEditVerdict::Rejected(EditedUncompilable);
738 };
739 if matcher.matches(fallback) != Some(true) {
740 return FallbackEditVerdict::Rejected(EditedExcludesFallback);
741 }
742 // d1 (round-1 critic S1) is deliberately NOT the d0 floor comparison: an auto-following
743 // range's floor sits at `fallback` itself by construction (the edit was rendered FROM
744 // `fallback`), so a floor check alone would never see that the SAME range also admits an
745 // entry strictly newer — the auto-follow case this check exists to catch. `fallback_pos` is
746 // `available.iter().position(..)`'s own result, so it is always `<= available.len()` and the
747 // slice below (every entry strictly newer than `fallback`, since `available` is newest-first)
748 // never panics — exact, not the fragile `take_while(|v| *v != fallback)` this replaced.
749 #[allow(clippy::indexing_slicing)]
750 let strictly_newer = &available[..fallback_pos];
751 if strictly_newer
752 .iter()
753 .any(|v| formatter.bounded_requirement_already_resolves_to(r1, v))
754 {
755 return FallbackEditVerdict::Rejected(EditedAdmitsNewer);
756 }
757
758 FallbackEditVerdict::Writable
759}
760
761/// Registry version data for one package, fetched together in a single round trip.
762///
763/// `latest` and `available` are deliberately asymmetric — this is load-bearing, not an
764/// oversight:
765/// - `latest` comes from this ecosystem's own `Registry::select_latest_matching(.., "*")`
766/// pick, which excludes yanked (and, for semver/node-semver `*`, prerelease) versions —
767/// the same value `get_latest_matching` returned before this type existed.
768/// - `available` is the **unfiltered** `get_versions` output: every published version,
769/// newest-first, yanked and prerelease entries included.
770///
771/// The unsatisfiable-requirement check (see `crate::lsp_helpers::requirement_is_unsatisfiable`)
772/// scans `available` and deliberately does not filter it: a requirement that only matches a
773/// yanked or prerelease version is still satisfied, so filtering `available` the same way
774/// `latest` is filtered would produce false "no published version satisfies" warnings.
775///
776/// `yanked` is the subset of `available` (same version-string encoding) that the registry
777/// reported as yanked/deprecated, paired with each entry's [`RemovalStatus`]. It exists
778/// because `Registry::get_latest_matching` — the call that used to populate this cache —
779/// filters yanked entries out by contract on every current registry implementation, so a
780/// per-version yanked flag threaded through *that* call would always read `false` (see
781/// #233). `available` now comes from the unfiltered `get_versions` instead, which does
782/// observe yanked entries, so `yanked` is derived from that same fetch rather than
783/// discarded.
784///
785/// The status rides alongside each version (rather than a bare membership list) so
786/// [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s #247 "requirement satisfiable
787/// only by a yanked version" check can gate its own package-level-deprecation suppression
788/// on `AdvisoryDeprecated` specifically, never on a genuine `Yanked` finding — mirroring
789/// [`VersionData::outcomes`]'s D5 gate for the #263 in-use-version check (see #437).
790#[non_exhaustive]
791#[derive(Debug, Clone, PartialEq, Eq)]
792pub struct PackageVersions {
793 /// Latest usable version for this package.
794 pub latest: ConcreteVersion,
795 /// Every published version, newest-first, unfiltered.
796 pub available: Arc<[ConcreteVersion]>,
797 /// Subset of `available` reported as yanked/deprecated by the registry, each paired
798 /// with its [`RemovalStatus`].
799 pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
800 /// When `latest` was published, if the registry exposes it. `None` when
801 /// the ecosystem doesn't wire [`crate::Version::published_at`] or the fetch
802 /// never ran.
803 ///
804 /// Deliberately a field on this single per-package struct rather than a
805 /// second parallel map keyed alongside `latest` — the earlier two-map
806 /// design (issue #227 critique C3) let `latest` and its age drift apart
807 /// silently whenever one map was updated (e.g. lockfile-resolved
808 /// overwrite) without the other. Bundling them here makes that
809 /// desync impossible: whoever sets `latest` sets `published_at` too.
810 pub published_at: Option<crate::freshness::PublishTime>,
811 /// Set when a version was excluded from being [`Self::latest`] solely because of an
812 /// active GOSSIP cooldown finding whose version was strictly newer than the dependency's
813 /// already-in-use version (`deps-cli` spec 074, FR-003/FR-005's floor-protected filter —
814 /// round 1 correction: there is no local [`CooldownWindow`]-based
815 /// exclusion this unions with, the freshness cooldown window only ever rewords a
816 /// downstream diagnostic message).
817 /// `None` in the common case (GOSSIP disabled, no matching finding, or no active
818 /// cooldown), and in four further no-op cases where nothing was actually held back: the
819 /// protect floor fully neutralized the exclusion because the flagged version was itself
820 /// already in use and still the best available (C1a); no in-use version could be
821 /// resolved at all, so no floor could be constructed and GOSSIP deliberately excludes
822 /// nothing this fetch (C1b — the common case for a range requirement with no lockfile);
823 /// a floor exists but the ecosystem's own selection rules reject every remaining
824 /// candidate above it (S1); or a floor exists and a filtered pick is found, but that
825 /// pick's original (pre-filter) position is older than the floor itself — the floor
826 /// version survived filtering yet was rejected by the ecosystem's own selection rules
827 /// (e.g. an in-use prerelease/yanked version), leaving only an even-older release to be
828 /// picked, which would be a downgrade below the floor (S4). In all four cases the
829 /// unfiltered pick is reused instead. Purely an in-process outcome-reporting field: no
830 /// wire/schema format carries it.
831 pub gossip_excluded_version: Option<ConcreteVersion>,
832 /// The newest cooldown-cleared, ecosystem-safe, floor-protected candidate found for this
833 /// dependency when `latest` may be cooldown-blocked (`deps-cli update`'s fallback, spec
834 /// 075 FR-001/FR-002). Computed unconditionally whenever freshness is enabled, regardless
835 /// of whether `latest` itself is currently blocked — see [`cooldown_disposition`] for the
836 /// read-time decision of whether it is actually needed (spec 075 NFR-002). `None` when
837 /// freshness is disabled, no lockfile-resolved in-use version exists to floor the search
838 /// (spec 075 OQ1), or no candidate passed every guard.
839 pub cooldown_fallback: Option<CooldownFallback>,
840}
841
842impl PackageVersions {
843 /// Constructs a `PackageVersions` from its two required fields, with [`Self::yanked`] and
844 /// [`Self::published_at`] left empty/`None` — chain [`Self::with_yanked`] and/or
845 /// [`Self::with_published_at`] to attach them.
846 ///
847 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
848 /// this crate, so every other crate (including test code) must go through this
849 /// constructor instead.
850 ///
851 /// # Examples
852 ///
853 /// ```
854 /// use deps_core::{ConcreteVersion, PackageVersions};
855 /// use std::sync::Arc;
856 ///
857 /// let versions = PackageVersions::new(
858 /// ConcreteVersion::new("2.0.0"),
859 /// Arc::from(vec![ConcreteVersion::new("2.0.0"), ConcreteVersion::new("1.0.0")]),
860 /// );
861 /// assert_eq!(versions.latest, "2.0.0");
862 /// assert_eq!(versions.available.len(), 2);
863 /// assert!(versions.yanked.is_empty());
864 /// ```
865 #[must_use]
866 pub fn new(latest: ConcreteVersion, available: Arc<[ConcreteVersion]>) -> Self {
867 Self {
868 latest,
869 available,
870 yanked: Arc::from(Vec::new()),
871 published_at: None,
872 gossip_excluded_version: None,
873 cooldown_fallback: None,
874 }
875 }
876
877 /// Attaches the registry's yanked/deprecated-version findings. See [`Self::yanked`].
878 #[must_use]
879 pub fn with_yanked(mut self, yanked: Arc<[(ConcreteVersion, RemovalStatus)]>) -> Self {
880 self.yanked = yanked;
881 self
882 }
883
884 /// Attaches when `latest` was published. See [`Self::published_at`].
885 #[must_use]
886 pub const fn with_published_at(mut self, published_at: crate::freshness::PublishTime) -> Self {
887 self.published_at = Some(published_at);
888 self
889 }
890
891 /// Attaches the version a GOSSIP cooldown finding excluded from being [`Self::latest`].
892 /// See [`Self::gossip_excluded_version`].
893 #[must_use]
894 pub fn with_gossip_excluded_version(mut self, excluded: ConcreteVersion) -> Self {
895 self.gossip_excluded_version = Some(excluded);
896 self
897 }
898
899 /// Attaches the computed cooldown-fallback candidate. See [`Self::cooldown_fallback`].
900 #[must_use]
901 pub fn with_cooldown_fallback(mut self, fallback: CooldownFallback) -> Self {
902 self.cooldown_fallback = Some(fallback);
903 self
904 }
905
906 /// Builds a `PackageVersions` from only the "latest" version string, with `available`
907 /// populated as the single-element list `[latest]`.
908 ///
909 /// **Test-only in intent.** The one-element `available` this produces is a real, if
910 /// small, version list — it is not "empty/unknown", so `requirement_is_unsatisfiable`
911 /// will evaluate a requirement against it. Do **not** use this for a lock-file-only
912 /// population path that has no real version list to offer (use
913 /// [`latest_without_list`](Self::latest_without_list) there instead, which leaves
914 /// `available` genuinely empty) — that exact substitution is the false-positive N5 was
915 /// written to prevent: it would let the unsatisfiable-requirement check produce a
916 /// verdict against a fabricated one-entry list before any registry fetch has run. Real
917 /// registry fetches always populate `available` from the full `get_versions` result
918 /// instead of using either constructor.
919 ///
920 /// # Examples
921 ///
922 /// ```
923 /// use deps_core::{ConcreteVersion, PackageVersions};
924 ///
925 /// let versions = PackageVersions::latest_only("1.0.214");
926 /// assert_eq!(versions.latest, "1.0.214");
927 /// assert_eq!(&*versions.available, &[ConcreteVersion::new("1.0.214")]);
928 /// ```
929 pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
930 let latest = latest.into();
931 let available = Arc::from(vec![latest.clone()]);
932 Self {
933 latest,
934 available,
935 yanked: Arc::from(Vec::new()),
936 published_at: None,
937 gossip_excluded_version: None,
938 cooldown_fallback: None,
939 }
940 }
941
942 /// Builds a `PackageVersions` with no version list — used where only the "latest" value
943 /// is known and probing further would be misleading, notably the lock-file population
944 /// path (`crates/deps-lsp/src/document/lifecycle.rs`), which must not populate a
945 /// plausible-looking one-element `available` list before any registry fetch has run: the
946 /// unsatisfiable-requirement check treats an empty `available` as "still loading, skip".
947 ///
948 /// # Examples
949 ///
950 /// ```
951 /// use deps_core::PackageVersions;
952 ///
953 /// let versions = PackageVersions::latest_without_list("1.0.195");
954 /// assert_eq!(versions.latest, "1.0.195");
955 /// assert!(versions.available.is_empty());
956 /// ```
957 pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
958 Self {
959 latest: latest.into(),
960 available: Arc::from(Vec::new()),
961 yanked: Arc::from(Vec::new()),
962 published_at: None,
963 gossip_excluded_version: None,
964 cooldown_fallback: None,
965 }
966 }
967}
968
969/// Everything the lifecycle fetch learned about one package, keyed by its normalized name.
970///
971/// All three channels can hold simultaneously for the same package — this is load-bearing,
972/// not an incidental shape. The D5 status gate in
973/// [`generate_diagnostics_from_cache`] reads the [`Self::deprecation`] and [`Self::yanked`]
974/// entries for the same normalized name together and compares their [`RemovalStatus`], and on
975/// the didChange path a surviving deprecation can coexist with a later fetch failure. A single
976/// enum variant per package could not express that overlap.
977#[non_exhaustive]
978#[derive(Debug, Clone, Default, PartialEq)]
979pub struct DependencyOutcome {
980 /// The version found yanked (the in-use version, or `latest` when only `latest` itself is
981 /// yanked), paired with its [`RemovalStatus`]. See [`VersionData::outcomes`]'s prior
982 /// semantics (#233/#263).
983 pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
984 /// Package-level deprecation finding (#205). See [`Deprecation`].
985 pub deprecation: Option<Deprecation>,
986 /// Registry fetch errored, timed out, or was never attempted (#267). See [`FetchFailure`].
987 pub fetch_failure: Option<FetchFailure>,
988 /// The registry fetch succeeded (no [`Self::fetch_failure`]) but produced zero
989 /// comparable versions — e.g. a real GitHub repository whose only tags don't parse
990 /// as full `major.minor.patch` semver (`dtolnay/rust-toolchain`'s sole tag `v1`,
991 /// issue #550). Distinct from both an absent [`DependencyOutcome`] entry ("never
992 /// fetched") and [`Self::fetch_failure`] ("couldn't be asked"): this package
993 /// demonstrably exists, so [R5](crate::lsp_helpers::generate_diagnostics_from_cache)
994 /// must not claim it is unknown.
995 pub no_comparable_versions: bool,
996}
997
998impl DependencyOutcome {
999 /// True when none of the four channels are set.
1000 #[must_use]
1001 pub const fn is_empty(&self) -> bool {
1002 self.yanked.is_none()
1003 && self.deprecation.is_none()
1004 && self.fetch_failure.is_none()
1005 && !self.no_comparable_versions
1006 }
1007}
1008
1009/// Normalized-package-name -> [`DependencyOutcome`] map.
1010///
1011/// A newtype rather than a bare `HashMap` so the empty-entry pruning invariant (an entry is
1012/// removed once all three of its channels are cleared) lives in one place, and so test
1013/// fixtures get chainable `with_*` constructors instead of building three ad-hoc `HashMap`s.
1014/// Keyed by plain normalized name — unlike [`crate::osv::VulnerabilityMap`], which is keyed by
1015/// [`crate::osv::VulnKey`] to disambiguate multiple occurrences of one name.
1016#[derive(Debug, Clone, Default, PartialEq)]
1017pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
1018
1019impl DependencyOutcomes {
1020 /// Creates an empty map.
1021 #[must_use]
1022 pub fn new() -> Self {
1023 Self::default()
1024 }
1025
1026 /// Looks up the full outcome recorded for `name`.
1027 #[must_use]
1028 pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
1029 self.0.get(name)
1030 }
1031
1032 /// Looks up the yanked-version finding for `name`.
1033 #[must_use]
1034 pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
1035 self.0.get(name)?.yanked.as_ref()
1036 }
1037
1038 /// Looks up the package-level deprecation finding for `name`.
1039 #[must_use]
1040 pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
1041 self.0.get(name)?.deprecation.as_ref()
1042 }
1043
1044 /// Looks up the fetch-failure finding for `name`.
1045 #[must_use]
1046 pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
1047 self.0.get(name)?.fetch_failure.as_ref()
1048 }
1049
1050 /// True when the registry fetch for `name` succeeded but produced zero comparable
1051 /// versions (#550). See [`DependencyOutcome::no_comparable_versions`].
1052 #[must_use]
1053 pub fn no_comparable_versions(&self, name: &str) -> bool {
1054 self.0.get(name).is_some_and(|o| o.no_comparable_versions)
1055 }
1056
1057 /// Records a yanked-version finding for `name`, creating the entry if absent.
1058 pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
1059 self.0.entry(name).or_default().yanked = Some(yanked);
1060 }
1061
1062 /// Records a package-level deprecation finding for `name`, creating the entry if absent.
1063 pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
1064 self.0.entry(name).or_default().deprecation = Some(deprecation);
1065 }
1066
1067 /// Records a fetch-failure finding for `name`, creating the entry if absent.
1068 pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
1069 self.0.entry(name).or_default().fetch_failure = Some(failure);
1070 }
1071
1072 /// Records a fetch-failure finding for `name` only if one is not already recorded,
1073 /// creating the entry if absent.
1074 pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
1075 self.0
1076 .entry(name)
1077 .or_default()
1078 .fetch_failure
1079 .get_or_insert(failure);
1080 }
1081
1082 /// Records that `name`'s registry fetch succeeded but produced zero comparable
1083 /// versions (#550), creating the entry if absent.
1084 pub fn set_no_comparable_versions(&mut self, name: String) {
1085 self.0.entry(name).or_default().no_comparable_versions = true;
1086 }
1087
1088 /// Clears the no-comparable-versions channel for `name`, pruning the entry if it
1089 /// becomes empty.
1090 pub fn clear_no_comparable_versions(&mut self, name: &str) {
1091 if let Some(entry) = self.0.get_mut(name) {
1092 entry.no_comparable_versions = false;
1093 self.prune(name);
1094 }
1095 }
1096
1097 /// Clears the yanked-version channel for `name`, pruning the entry if it becomes empty.
1098 pub fn clear_yanked(&mut self, name: &str) {
1099 if let Some(entry) = self.0.get_mut(name) {
1100 entry.yanked = None;
1101 self.prune(name);
1102 }
1103 }
1104
1105 /// Clears the deprecation channel for `name`, pruning the entry if it becomes empty.
1106 pub fn clear_deprecation(&mut self, name: &str) {
1107 if let Some(entry) = self.0.get_mut(name) {
1108 entry.deprecation = None;
1109 self.prune(name);
1110 }
1111 }
1112
1113 /// Clears the fetch-failure channel for `name`, pruning the entry if it becomes empty.
1114 pub fn clear_fetch_failure(&mut self, name: &str) {
1115 if let Some(entry) = self.0.get_mut(name) {
1116 entry.fetch_failure = None;
1117 self.prune(name);
1118 }
1119 }
1120
1121 /// Removes the whole entry for `name`, regardless of which channels are set.
1122 pub fn remove(&mut self, name: &str) {
1123 self.0.remove(name);
1124 }
1125
1126 /// Clears the fetch-failure channel for every entry, pruning any that become empty.
1127 ///
1128 /// Used when a forced re-fetch (e.g. a live-reloaded registry-routing setting, deps-lsp
1129 /// issue #592) makes every previously recorded fetch-failure finding untrustworthy: the
1130 /// routing itself changed, so a failure recorded under the old routing must not survive
1131 /// to be merged with results fetched under the new one.
1132 pub fn clear_all_fetch_failures(&mut self) {
1133 let names: Vec<String> = self.0.keys().cloned().collect();
1134 for name in names {
1135 self.clear_fetch_failure(&name);
1136 }
1137 }
1138
1139 fn prune(&mut self, name: &str) {
1140 if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
1141 self.0.remove(name);
1142 }
1143 }
1144
1145 /// Number of entries currently stored.
1146 #[must_use]
1147 pub fn len(&self) -> usize {
1148 self.0.len()
1149 }
1150
1151 /// True when no entries are stored.
1152 #[must_use]
1153 pub fn is_empty(&self) -> bool {
1154 self.0.is_empty()
1155 }
1156
1157 /// Number of entries with a yanked-version finding, for logging/`Debug`.
1158 #[must_use]
1159 pub fn yanked_count(&self) -> usize {
1160 self.0.values().filter(|o| o.yanked.is_some()).count()
1161 }
1162
1163 /// Number of entries with a deprecation finding, for logging/`Debug`.
1164 #[must_use]
1165 pub fn deprecation_count(&self) -> usize {
1166 self.0.values().filter(|o| o.deprecation.is_some()).count()
1167 }
1168
1169 /// Number of entries with a fetch-failure finding, for logging/`Debug`.
1170 #[must_use]
1171 pub fn fetch_failure_count(&self) -> usize {
1172 self.0
1173 .values()
1174 .filter(|o| o.fetch_failure.is_some())
1175 .count()
1176 }
1177
1178 /// Chainable builder recording a yanked-version finding. Test/fixture ergonomics.
1179 #[must_use]
1180 pub fn with_yanked(
1181 mut self,
1182 name: impl Into<String>,
1183 yanked: (ConcreteVersion, RemovalStatus),
1184 ) -> Self {
1185 self.set_yanked(name.into(), yanked);
1186 self
1187 }
1188
1189 /// Chainable builder recording a package-level deprecation finding. Test/fixture
1190 /// ergonomics.
1191 #[must_use]
1192 pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
1193 self.set_deprecation(name.into(), deprecation);
1194 self
1195 }
1196
1197 /// Chainable builder recording a fetch-failure finding. Test/fixture ergonomics.
1198 #[must_use]
1199 pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
1200 self.set_fetch_failure(name.into(), failure);
1201 self
1202 }
1203
1204 /// Chainable builder recording a no-comparable-versions finding (#550). Test/fixture
1205 /// ergonomics.
1206 #[must_use]
1207 pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
1208 self.set_no_comparable_versions(name.into());
1209 self
1210 }
1211}
1212
1213/// Bundles the two per-package version maps (`cached`, `resolved`) that LSP handlers pass
1214/// together everywhere.
1215///
1216/// Grouping them prevents accidentally swapping the two map arguments at a call site, since
1217/// the compiler can no longer typecheck them positionally.
1218///
1219/// # Examples
1220///
1221/// ```
1222/// use deps_core::{ConcreteVersion, PackageName, PackageVersions, VersionData};
1223/// use std::collections::HashMap;
1224///
1225/// let mut cached = HashMap::new();
1226/// cached.insert(PackageName::new("serde"), PackageVersions::latest_only("1.0.214"));
1227///
1228/// let mut resolved = HashMap::new();
1229/// resolved.insert(PackageName::new("serde"), ConcreteVersion::new("1.0.200"));
1230///
1231/// let versions = VersionData::new(&cached, &resolved);
1232///
1233/// assert_eq!(versions.cached.get("serde").map(|v| v.latest.as_str()), Some("1.0.214"));
1234/// assert_eq!(versions.resolved.get("serde").map(ConcreteVersion::as_str), Some("1.0.200"));
1235/// ```
1236#[non_exhaustive]
1237#[derive(Debug, Clone, Copy)]
1238pub struct VersionData<'a> {
1239 /// Latest known versions and full version lists from the registry, keyed by package name.
1240 pub cached: &'a HashMap<PackageName, PackageVersions>,
1241 /// Versions actually resolved in the lock file, keyed by package name.
1242 pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
1243 /// Every lock-file-resolved version for a package name, when more than one is retained
1244 /// (issue #649) — additive alongside [`Self::resolved`], which stays the single
1245 /// collapsed value for the common case. `None` by default (most call sites have no such
1246 /// map); [`crate::lsp_helpers::resolve_in_use_version`] and [`crate::osv::vulnerability_keys`]
1247 /// consult it to disambiguate two manifest occurrences of one resolved name (e.g. a
1248 /// Cargo `package = "..."` rename pinning an older major) by each occurrence's own
1249 /// `version_requirement()`, falling back to [`Self::resolved`] when a name has at most
1250 /// one candidate.
1251 pub resolved_version_candidates: Option<&'a HashMap<PackageName, Vec<ConcreteVersion>>>,
1252 /// OSV scan results, keyed by normalized package name. `None` when no
1253 /// scan has run yet (e.g. the feature is disabled) — distinct from an
1254 /// empty map, which would mean "scanned, nothing found".
1255 pub vulnerabilities: Option<&'a VulnerabilityMap>,
1256 /// Yanked, deprecation, and fetch-failure findings from the most recent lifecycle fetch,
1257 /// keyed by normalized package name — see [`DependencyOutcome`] for what each channel
1258 /// means and why they must stay readable together off one lookup (D5 in
1259 /// [`generate_diagnostics_from_cache`], #233/#263/#205/#267). `None` when no fetch has run
1260 /// yet — distinct from an empty map, which would mean "checked, nothing found".
1261 pub outcomes: Option<&'a DependencyOutcomes>,
1262 /// This document's ecosystem, when the caller has one to give. `None` in
1263 /// most test fixtures and a handful of ecosystem-crate self-tests that
1264 /// predate this field.
1265 ///
1266 /// Enables two occurrence-aware refinements added for #394 (duplicate
1267 /// dependency names no longer collapsing into one shared finding):
1268 /// [`generate_diagnostics_from_cache`] only emits a yanked-version
1269 /// diagnostic on the occurrence whose own in-use version actually
1270 /// matches the recorded finding (S1), and the vulnerability lookups in
1271 /// `generate_diagnostics_from_cache`, [`generate_hover`], and
1272 /// `generate_code_actions` prefer a version-qualified
1273 /// [`crate::osv::VulnerabilityMap`] key over the plain name when more
1274 /// than one occurrence of a name has a distinct in-use version (S2).
1275 /// When `None`, both fall back to their pre-#394 name-only behavior.
1276 pub ecosystem: Option<EcosystemId>,
1277 /// The current [`NetworkMode`](crate::NetworkMode) (issue #483). When
1278 /// [`NetworkMode::Offline`](crate::NetworkMode::Offline), [`generate_hover`] appends a
1279 /// footer stating that version *and vulnerability* data were not checked — deliberately
1280 /// more specific than a bare "showing cached data" notice, since `hover.rs`'s
1281 /// `Some(ScanOutcome::Skipped(_)) | None` arm renders nothing for an offline OSV skip,
1282 /// which would otherwise look identical to a scanned-and-clean dependency.
1283 pub network: crate::NetworkMode,
1284 /// The deps.dev client to fetch a supply-chain trust signal through
1285 /// (spec 037), when the caller wants hover to attempt one. `None` by
1286 /// default and left `None` by every surface but `handlers/hover.rs`
1287 /// (deps-lsp) — diagnostics, code actions, inlay hints, and code lenses
1288 /// never set this, which is what makes FR-010's hover-only scope
1289 /// structural rather than convention: those surfaces cannot reach
1290 /// deps.dev because they are never handed a client. `&'a Arc<..>`, not
1291 /// `&'a DepsDevClient`, so [`generate_hover`] can clone the `Arc` into a
1292 /// detached background task.
1293 pub trust: Option<&'a Arc<DepsDevClient>>,
1294 /// Background-pre-fetched license data for ecosystems `deps_dev_system` doesn't
1295 /// cover and whose hot-path registry response carries no license field — Dart,
1296 /// Swift, Gradle, Deno (issue #660, spec 010 plan §1 tier 3), keyed by raw
1297 /// (unnormalized) package name. `None` by default. Populated from `DocumentState`'s
1298 /// per-document pre-fetch cache (`document::osv_scan::run_license_prefetch` on
1299 /// document open/change — mirrors [`Self::vulnerabilities`]'s "populated by a
1300 /// background task, read synchronously here" shape) by `handlers/hover.rs` (deps-lsp)
1301 /// for hover, and by `handlers/diagnostics.rs`'s `textDocument/diagnostic` pull path
1302 /// for [`generate_diagnostics_from_cache`]'s license-policy rule (issue #661) — see
1303 /// [`Self::license_policy`].
1304 ///
1305 /// **What version this actually reflects is per-ecosystem, not uniformly the
1306 /// resolved version** (critic S1 — corrects a previous blanket claim here that it
1307 /// "only ever covers the resolved version"): Gradle's Maven Central POM fetch and
1308 /// Deno's JSR per-version API are genuinely fetched at the dependency's resolved/
1309 /// in-use version. Dart's pub.dev `/score` endpoint is per-*package*, not
1310 /// per-version — it reflects pana's detection on whatever pub.dev most recently
1311 /// scored, unrelated to which version is resolved. Swift's GitHub
1312 /// `GET /repos/{owner}/{repo}` reflects the repository's default branch, not the
1313 /// resolved version's tag. Every source here is still a background pre-fetch gated
1314 /// on a version having been resolved at all (no in-use version means nothing to
1315 /// look up), but only Gradle/Deno are actually version-*specific* in what they
1316 /// return.
1317 pub license_prefetch: Option<&'a HashMap<PackageName, Vec<String>>>,
1318 /// How [`Self::license_prefetch`]'s (and, for a resolved-version match, the
1319 /// registry-declared) license strings are sourced (issue #688), consulted by
1320 /// [`generate_hover`] for the "(detected)" qualifier and by
1321 /// [`generate_diagnostics_from_cache`]'s license-policy rule to decide whether
1322 /// [`crate::licenses::resolve_license_entries`] must normalize free text before
1323 /// evaluation. `None` in most test fixtures and any handler that doesn't go through
1324 /// [`crate::Ecosystem::generate_hover`]/[`crate::Ecosystem::generate_diagnostics`]'s
1325 /// default implementation, which is what actually attaches this field via
1326 /// `self.license_source()` — falls back to
1327 /// [`LicenseSource::RegistryDeclaredSpdx`] (`Default`) wherever consumed.
1328 pub license_source: Option<LicenseSource>,
1329 /// SPDX allow-list/deny-list license policy (issue #661, spec 010 Phase 2), when the
1330 /// caller wants diagnostics evaluated against one. `None` by default.
1331 ///
1332 /// Unlike [`Self::trust`], this is **not** hover-only-scoped: `deps-lsp`'s
1333 /// `handlers/diagnostics.rs::generate_diagnostics_internal` attaches the currently
1334 /// configured policy (cached on `ServerState`, kept live-updated by
1335 /// `Backend::initialize`/`did_change_configuration`) unconditionally, so every
1336 /// diagnostics-generation call site — the `textDocument/diagnostic` pull path *and*
1337 /// every push-path background refresh (fetch-completion, watched-config reparse,
1338 /// lock-file change) — evaluates the same policy. An earlier revision scoped this
1339 /// field to the pull path only, following `Self::trust`'s hover-only precedent; that
1340 /// was a false analogy (issue #660/#661 critic C1) — `trust` is safe hover-only
1341 /// because hover has exactly one producer per request, but diagnostics has multiple
1342 /// producers all replacing the same client-visible `publish_diagnostics` set, so a
1343 /// caller-scoped policy meant the license diagnostic flickered in and out on every
1344 /// edit and was invisible to push-only clients.
1345 /// [`generate_diagnostics_from_cache`]'s license-policy rule reads this alongside
1346 /// [`Self::license_prefetch`] — so today it only actually fires for tier-3 ecosystems
1347 /// (Dart/Swift/Deno; Gradle is explicitly excluded, see that rule's doc comment), the
1348 /// only ones `license_prefetch` covers; this field and the rule are otherwise
1349 /// ecosystem-agnostic and need no change as `license_prefetch`'s coverage widens.
1350 pub license_policy: Option<&'a LicensePolicy>,
1351 /// Background-pre-fetched typosquat-suspect signal per declared dependency (issue
1352 /// #1437, spec 071), keyed by raw (unnormalized) package name — mirrors
1353 /// [`Self::license_prefetch`]'s exact shape and rationale (NFR-002: this must never be
1354 /// an inline `.await` on the diagnostics-generation path, so it is resolved ahead of
1355 /// time by a document-lifecycle background task,
1356 /// `deps-lsp::document::typosquat::run_typosquat_prefetch`, and merely read
1357 /// synchronously here — see that function's doc). Consumed directly inside
1358 /// [`generate_diagnostics_from_cache`] itself (not gated behind
1359 /// `Ecosystem::generate_diagnostics`'s default impl), the same way
1360 /// [`Self::license_prefetch`] is, so every ecosystem's `generate_diagnostics`
1361 /// override — not just the shared default — picks up the diagnostic automatically
1362 /// (issue #1437 security-review finding: an override that calls
1363 /// [`generate_diagnostics_from_cache`] directly, as `deps-npm`'s catalog-diagnostics
1364 /// override does, must never be able to silently bypass this). `None` when the feature
1365 /// is disabled, the ecosystem isn't deps.dev-covered, offline, or no dependency in the
1366 /// document cleared the ratio gate.
1367 pub typosquat_prefetch: Option<&'a HashMap<PackageName, TyposquatSignal>>,
1368 /// Background-pre-fetched deps.dev GOSSIP cooldown/low-usage findings per declared
1369 /// dependency (issue #1456, spec 072), keyed by raw (unnormalized) package name —
1370 /// mirrors [`Self::typosquat_prefetch`]'s exact shape and rationale: populated by
1371 /// `deps-lsp::document::gossip_prefetch`, via `DocumentState::signals.gossip_findings`, and
1372 /// merely read synchronously here — never fetched inline on the hover/diagnostics-
1373 /// generation path itself.
1374 ///
1375 /// A lookup hit is only trustworthy for the version it was resolved against
1376 /// (spec 072 FR-008) — the entry's own [`crate::GossipFindings::version`] field must
1377 /// be compared against the version actually being displayed at each call site (hover's
1378 /// `latest_line`, diagnostics' `package_versions.latest`) before use; a mismatch is
1379 /// treated as a cache miss and falls back to [`local_cooldown_precedence`]. `None`
1380 /// when the feature is disabled, offline, the ecosystem isn't deps.dev-covered, or no
1381 /// dependency in the document has a prefetch result yet.
1382 pub gossip_prefetch: Option<&'a HashMap<PackageName, GossipFindings>>,
1383 /// The deps.dev client to fetch hover's live GOSSIP low-usage signal through (issue
1384 /// #1456, spec 072 FR-005/FR-009), when the caller wants hover to attempt one. `None`
1385 /// by default, mirroring [`Self::trust`]'s exact "presence is the gate" shape (FR-010's
1386 /// structural pattern) — set only by `handlers/hover.rs` (deps-lsp) when
1387 /// `GossipConfig.enabled` is set and not offline. Deliberately a **separate** field
1388 /// from [`Self::trust`], not reused: `SupplyChainConfig.enabled` (opt-out, default
1389 /// `true`) and `GossipConfig.enabled` (opt-in, default `false`) are independently
1390 /// switchable — conflating the two fields would let disabling supply-chain trust
1391 /// signals silently disable GOSSIP's low-usage fetch too, or vice versa. `&'a Arc<..>`,
1392 /// not `&'a DepsDevClient`, for the same "clone into a detached background task"
1393 /// reason [`Self::trust`]'s doc gives.
1394 pub gossip_client: Option<&'a Arc<DepsDevClient>>,
1395 /// Phase B's per-key "latest" check result (issue #1517), keyed the same way as
1396 /// [`Self::vulnerabilities`] — see [`crate::osv::LatestStatusMap`]. `None` means OSV
1397 /// checking is disabled or offline for this scan entirely, distinct from `Some(map)` with a
1398 /// missing/`Unverified` entry for one dependency (which [`latest_verdict`] treats as
1399 /// fail-closed, not "not applicable"). Every renderer that surfaces a dependency's `latest`
1400 /// as an upgrade recommendation (hover, diagnostics, code actions, code lens, inlay hints,
1401 /// completion) must consult [`latest_verdict`] with this field before treating that `latest`
1402 /// as safe.
1403 pub latest_status: Option<&'a LatestStatusMap>,
1404 /// Phase B's per-(dependency, candidate-version) OSV check result (#1524) — see
1405 /// [`crate::osv::CandidateStatusMap`]. `None` means OSV checking is disabled or offline for
1406 /// this scan entirely, mirroring [`Self::latest_status`]'s identical distinction. A
1407 /// candidate-offering surface (code actions' "update to X" list, completion's version
1408 /// items) must consult [`candidate_verdict`] with this field for every version it is about
1409 /// to offer, not only the one [`Self::latest_status`] already covers.
1410 pub candidate_status: Option<&'a CandidateStatusMap>,
1411}
1412
1413impl<'a> VersionData<'a> {
1414 /// Creates a new `VersionData` from the cached and resolved version maps.
1415 ///
1416 /// `vulnerabilities` starts `None`; chain [`Self::with_vulnerabilities`]
1417 /// to attach a scan result.
1418 ///
1419 /// # Examples
1420 ///
1421 /// ```
1422 /// use deps_core::VersionData;
1423 /// use std::collections::HashMap;
1424 ///
1425 /// let cached = HashMap::new();
1426 /// let resolved = HashMap::new();
1427 /// let versions = VersionData::new(&cached, &resolved);
1428 /// assert!(versions.cached.is_empty());
1429 /// assert!(versions.vulnerabilities.is_none());
1430 /// ```
1431 pub fn new(
1432 cached: &'a HashMap<PackageName, PackageVersions>,
1433 resolved: &'a HashMap<PackageName, ConcreteVersion>,
1434 ) -> Self {
1435 Self {
1436 cached,
1437 resolved,
1438 resolved_version_candidates: None,
1439 vulnerabilities: None,
1440 outcomes: None,
1441 ecosystem: None,
1442 network: crate::NetworkMode::Online,
1443 trust: None,
1444 license_prefetch: None,
1445 license_source: None,
1446 license_policy: None,
1447 typosquat_prefetch: None,
1448 gossip_prefetch: None,
1449 gossip_client: None,
1450 latest_status: None,
1451 candidate_status: None,
1452 }
1453 }
1454
1455 /// Attaches the per-name lock-file candidates map, enabling the per-occurrence
1456 /// disambiguation described on [`Self::resolved_version_candidates`].
1457 ///
1458 /// # Examples
1459 ///
1460 /// ```
1461 /// use deps_core::VersionData;
1462 /// use std::collections::HashMap;
1463 ///
1464 /// let cached = HashMap::new();
1465 /// let resolved = HashMap::new();
1466 /// let candidates = HashMap::new();
1467 /// let versions =
1468 /// VersionData::new(&cached, &resolved).with_resolved_version_candidates(&candidates);
1469 /// assert!(versions.resolved_version_candidates.is_some());
1470 /// ```
1471 #[must_use]
1472 pub const fn with_resolved_version_candidates(
1473 mut self,
1474 candidates: &'a HashMap<PackageName, Vec<ConcreteVersion>>,
1475 ) -> Self {
1476 self.resolved_version_candidates = Some(candidates);
1477 self
1478 }
1479
1480 /// Attaches an OSV scan result to this `VersionData`.
1481 ///
1482 /// # Examples
1483 ///
1484 /// ```
1485 /// use deps_core::VersionData;
1486 /// use deps_core::osv::VulnerabilityMap;
1487 /// use std::collections::HashMap;
1488 ///
1489 /// let cached = HashMap::new();
1490 /// let resolved = HashMap::new();
1491 /// let vulns = VulnerabilityMap::new();
1492 /// let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns);
1493 /// assert!(versions.vulnerabilities.is_some());
1494 /// ```
1495 #[must_use]
1496 pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
1497 self.vulnerabilities = Some(vulnerabilities);
1498 self
1499 }
1500
1501 /// Attaches phase B's per-key "latest" check result to this `VersionData`. See
1502 /// [`Self::latest_status`].
1503 ///
1504 /// # Examples
1505 ///
1506 /// ```
1507 /// use deps_core::VersionData;
1508 /// use deps_core::osv::LatestStatusMap;
1509 /// use std::collections::HashMap;
1510 ///
1511 /// let cached = HashMap::new();
1512 /// let resolved = HashMap::new();
1513 /// let latest_status = LatestStatusMap::new();
1514 /// let versions = VersionData::new(&cached, &resolved).with_latest_status(&latest_status);
1515 /// assert!(versions.latest_status.is_some());
1516 /// ```
1517 #[must_use]
1518 pub fn with_latest_status(mut self, latest_status: &'a LatestStatusMap) -> Self {
1519 self.latest_status = Some(latest_status);
1520 self
1521 }
1522
1523 /// Attaches phase B's per-candidate-version check result to this `VersionData`. See
1524 /// [`Self::candidate_status`].
1525 ///
1526 /// # Examples
1527 ///
1528 /// ```
1529 /// use deps_core::VersionData;
1530 /// use deps_core::osv::CandidateStatusMap;
1531 /// use std::collections::HashMap;
1532 ///
1533 /// let cached = HashMap::new();
1534 /// let resolved = HashMap::new();
1535 /// let candidate_status = CandidateStatusMap::new();
1536 /// let versions =
1537 /// VersionData::new(&cached, &resolved).with_candidate_status(&candidate_status);
1538 /// assert!(versions.candidate_status.is_some());
1539 /// ```
1540 #[must_use]
1541 pub fn with_candidate_status(mut self, candidate_status: &'a CandidateStatusMap) -> Self {
1542 self.candidate_status = Some(candidate_status);
1543 self
1544 }
1545
1546 /// Attaches yanked, deprecation, and fetch-failure findings to this `VersionData`. See
1547 /// [`Self::outcomes`].
1548 ///
1549 /// # Examples
1550 ///
1551 /// ```
1552 /// use deps_core::VersionData;
1553 /// use deps_core::lsp_helpers::DependencyOutcomes;
1554 /// use std::collections::HashMap;
1555 ///
1556 /// let cached = HashMap::new();
1557 /// let resolved = HashMap::new();
1558 /// let outcomes = DependencyOutcomes::new();
1559 /// let versions = VersionData::new(&cached, &resolved).with_outcomes(&outcomes);
1560 /// assert!(versions.outcomes.is_some());
1561 /// ```
1562 #[must_use]
1563 pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
1564 self.outcomes = Some(outcomes);
1565 self
1566 }
1567
1568 /// Attaches this document's ecosystem, enabling the occurrence-aware
1569 /// refinements described on [`Self::ecosystem`].
1570 ///
1571 /// # Examples
1572 ///
1573 /// ```
1574 /// use deps_core::{EcosystemId, VersionData};
1575 /// use std::collections::HashMap;
1576 ///
1577 /// let cached = HashMap::new();
1578 /// let resolved = HashMap::new();
1579 /// let versions = VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::Cargo);
1580 /// assert_eq!(versions.ecosystem, Some(EcosystemId::Cargo));
1581 /// ```
1582 #[must_use]
1583 pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
1584 self.ecosystem = Some(ecosystem);
1585 self
1586 }
1587
1588 /// Marks this `VersionData` as built while `network.offline` was set, so
1589 /// [`generate_hover`] appends its offline footer.
1590 ///
1591 /// # Examples
1592 ///
1593 /// ```
1594 /// use deps_core::VersionData;
1595 /// use std::collections::HashMap;
1596 ///
1597 /// let cached = HashMap::new();
1598 /// let resolved = HashMap::new();
1599 /// let versions = VersionData::new(&cached, &resolved).with_network(deps_core::NetworkMode::Offline);
1600 /// assert_eq!(versions.network, deps_core::NetworkMode::Offline);
1601 /// ```
1602 #[must_use]
1603 pub const fn with_network(mut self, network: crate::NetworkMode) -> Self {
1604 self.network = network;
1605 self
1606 }
1607
1608 /// Attaches a deps.dev client, enabling [`generate_hover`] to attempt a
1609 /// supply-chain trust signal for the hovered dependency. See
1610 /// [`Self::trust`].
1611 ///
1612 /// # Examples
1613 ///
1614 /// ```
1615 /// use deps_core::{DepsDevClient, HttpCache, VersionData};
1616 /// use std::collections::HashMap;
1617 /// use std::sync::Arc;
1618 ///
1619 /// let cached = HashMap::new();
1620 /// let resolved = HashMap::new();
1621 /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
1622 /// let versions = VersionData::new(&cached, &resolved).with_trust(&client);
1623 /// assert!(versions.trust.is_some());
1624 /// ```
1625 #[must_use]
1626 pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
1627 self.trust = Some(client);
1628 self
1629 }
1630
1631 /// Attaches tier-3 pre-fetched license data. See [`Self::license_prefetch`].
1632 ///
1633 /// # Examples
1634 ///
1635 /// ```
1636 /// use deps_core::VersionData;
1637 /// use std::collections::HashMap;
1638 ///
1639 /// let cached = HashMap::new();
1640 /// let resolved = HashMap::new();
1641 /// let licenses = HashMap::new();
1642 /// let versions = VersionData::new(&cached, &resolved).with_license_prefetch(&licenses);
1643 /// assert!(versions.license_prefetch.is_some());
1644 /// ```
1645 #[must_use]
1646 pub const fn with_license_prefetch(
1647 mut self,
1648 licenses: &'a HashMap<PackageName, Vec<String>>,
1649 ) -> Self {
1650 self.license_prefetch = Some(licenses);
1651 self
1652 }
1653
1654 /// Attaches this ecosystem's license source. See [`Self::license_source`].
1655 ///
1656 /// # Examples
1657 ///
1658 /// ```
1659 /// use deps_core::{LicenseSource, VersionData};
1660 /// use std::collections::HashMap;
1661 ///
1662 /// let cached = HashMap::new();
1663 /// let resolved = HashMap::new();
1664 /// let versions =
1665 /// VersionData::new(&cached, &resolved).with_license_source(LicenseSource::PomFreeText);
1666 /// assert_eq!(versions.license_source, Some(LicenseSource::PomFreeText));
1667 /// ```
1668 #[must_use]
1669 pub const fn with_license_source(mut self, source: LicenseSource) -> Self {
1670 self.license_source = Some(source);
1671 self
1672 }
1673
1674 /// Attaches a license policy for diagnostics evaluation. See [`Self::license_policy`].
1675 ///
1676 /// # Examples
1677 ///
1678 /// ```
1679 /// use deps_core::{LicensePolicy, VersionData};
1680 /// use std::collections::HashMap;
1681 ///
1682 /// let cached = HashMap::new();
1683 /// let resolved = HashMap::new();
1684 /// let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
1685 /// let versions = VersionData::new(&cached, &resolved).with_license_policy(&policy);
1686 /// assert!(versions.license_policy.is_some());
1687 /// ```
1688 #[must_use]
1689 pub const fn with_license_policy(mut self, policy: &'a LicensePolicy) -> Self {
1690 self.license_policy = Some(policy);
1691 self
1692 }
1693
1694 /// Attaches background-pre-fetched typosquat signals. See [`Self::typosquat_prefetch`].
1695 ///
1696 /// # Examples
1697 ///
1698 /// ```
1699 /// use deps_core::VersionData;
1700 /// use std::collections::HashMap;
1701 ///
1702 /// let cached = HashMap::new();
1703 /// let resolved = HashMap::new();
1704 /// let typosquat = HashMap::new();
1705 /// let versions = VersionData::new(&cached, &resolved).with_typosquat_prefetch(&typosquat);
1706 /// assert!(versions.typosquat_prefetch.is_some());
1707 /// ```
1708 #[must_use]
1709 pub const fn with_typosquat_prefetch(
1710 mut self,
1711 typosquat_prefetch: &'a HashMap<PackageName, TyposquatSignal>,
1712 ) -> Self {
1713 self.typosquat_prefetch = Some(typosquat_prefetch);
1714 self
1715 }
1716
1717 /// Attaches background-pre-fetched GOSSIP findings. See [`Self::gossip_prefetch`].
1718 ///
1719 /// # Examples
1720 ///
1721 /// ```
1722 /// use deps_core::VersionData;
1723 /// use std::collections::HashMap;
1724 ///
1725 /// let cached = HashMap::new();
1726 /// let resolved = HashMap::new();
1727 /// let gossip = HashMap::new();
1728 /// let versions = VersionData::new(&cached, &resolved).with_gossip_prefetch(&gossip);
1729 /// assert!(versions.gossip_prefetch.is_some());
1730 /// ```
1731 #[must_use]
1732 pub const fn with_gossip_prefetch(
1733 mut self,
1734 gossip_prefetch: &'a HashMap<PackageName, GossipFindings>,
1735 ) -> Self {
1736 self.gossip_prefetch = Some(gossip_prefetch);
1737 self
1738 }
1739
1740 /// Attaches a deps.dev client, enabling hover to attempt a live GOSSIP low-usage fetch.
1741 /// See [`Self::gossip_client`].
1742 ///
1743 /// # Examples
1744 ///
1745 /// ```
1746 /// use deps_core::{DepsDevClient, HttpCache, VersionData};
1747 /// use std::collections::HashMap;
1748 /// use std::sync::Arc;
1749 ///
1750 /// let cached = HashMap::new();
1751 /// let resolved = HashMap::new();
1752 /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
1753 /// let versions = VersionData::new(&cached, &resolved).with_gossip_client(&client);
1754 /// assert!(versions.gossip_client.is_some());
1755 /// ```
1756 #[must_use]
1757 pub const fn with_gossip_client(mut self, client: &'a Arc<DepsDevClient>) -> Self {
1758 self.gossip_client = Some(client);
1759 self
1760 }
1761}
1762
1763/// The three states [`gossip_cooldown_for`] can resolve to — deliberately distinct from a
1764/// plain `Option<&GossipCooldown>` (issue #1456 security/impl-critic review, S2).
1765///
1766/// A bare `Option` conflates "no GOSSIP data available for this version at all" with "GOSSIP
1767/// has data and it authoritatively says this version is not in cooldown", and both used to
1768/// fall through to the unattributed local heuristic — which can then show a cooldown callout
1769/// that directly contradicts what GOSSIP already knows, violating FR-002's "authoritative when
1770/// available" requirement.
1771///
1772/// Spec 075 FR-005 (R-S3) redefined [`Self::NotActive`] to require a parsed, past `end` —
1773/// a missing COOLDOWN finding is [`Self::Unavailable`], not [`Self::NotActive`]. The earlier
1774/// definition conflated "no data" with "confirmed clear", which was fail-open for a write
1775/// path (`deps-cli update`'s cooldown-fallback candidate, spec 075).
1776#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1777pub enum GossipCooldownLookup {
1778 /// No GOSSIP data for this exact version — an absent entry, an FR-008 version mismatch,
1779 /// or a COOLDOWN finding with no cooldown at all (`findings.cooldown == None`, including
1780 /// a missing/unparseable `end`, spec 075 FR-005) — the caller falls back to
1781 /// [`local_cooldown_precedence`], unattributed.
1782 Unavailable,
1783 /// GOSSIP data is present for this exact version and confirms an active cooldown as of
1784 /// `now` (spec 072 FR-011's read-time `end > now` check) — the caller renders a
1785 /// GOSSIP-attributed callout. Carries no payload: no caller renders the cooldown's own
1786 /// `end`/`risk` today, only whether one is active — see [`gossip_cooldown_for`] for
1787 /// where that data is still available (`GossipFindings.cooldown`) if a future caller
1788 /// needs it.
1789 Active,
1790 /// GOSSIP data is present for this exact version and authoritatively reports a cooldown
1791 /// that has already ended (a parsed, past `end`) — the caller must render nothing here,
1792 /// never falling back to the local heuristic, which could contradict this answer.
1793 NotActive,
1794}
1795
1796/// Looks up the GOSSIP cooldown state for `name` in `gossip_prefetch`, applying spec 072
1797/// FR-008's version-equality gate against `comparand` and FR-011's read-time `end > now` check.
1798///
1799/// `comparand` is the version actually being displayed at this call site — hover's
1800/// `latest_line`, diagnostics' `package_versions.latest` — shared by
1801/// `hover::push_latest_hover_section`, `diagnostics::apply_outdated_rule`,
1802/// [`crate::lsp_helpers::cooldown_disposition`], and `deps-engine`'s fallback-candidate
1803/// computation (spec 075) so no call site can diverge on this gate.
1804///
1805/// See [`GossipCooldownLookup`]'s doc for why this returns a three-state enum rather than
1806/// `Option<&GossipCooldown>`.
1807///
1808/// # Examples
1809///
1810/// ```
1811/// use deps_core::PackageName;
1812/// use deps_core::deps_dev::{GossipCooldown, GossipRiskLevel};
1813/// use deps_core::lsp_helpers::{GossipCooldownLookup, gossip_cooldown_for};
1814/// use deps_core::test_util::stub_gossip_findings;
1815/// use deps_core::PublishTime;
1816/// use std::collections::HashMap;
1817///
1818/// let name = PackageName::new("left-pad");
1819/// let mut prefetch = HashMap::new();
1820/// prefetch.insert(
1821/// name.clone(),
1822/// stub_gossip_findings(
1823/// "1.3.0",
1824/// Some(GossipCooldown::new(
1825/// PublishTime::from_unix_secs(2_000),
1826/// GossipRiskLevel::High,
1827/// )),
1828/// ),
1829/// );
1830///
1831/// assert_eq!(
1832/// gossip_cooldown_for(Some(&prefetch), &name, "1.3.0", PublishTime::from_unix_secs(1_000)),
1833/// GossipCooldownLookup::Active
1834/// );
1835/// assert_eq!(
1836/// gossip_cooldown_for(Some(&prefetch), &name, "1.3.0", PublishTime::from_unix_secs(3_000)),
1837/// GossipCooldownLookup::NotActive
1838/// );
1839/// assert_eq!(
1840/// gossip_cooldown_for(None, &name, "1.3.0", PublishTime::now()),
1841/// GossipCooldownLookup::Unavailable
1842/// );
1843/// ```
1844#[must_use]
1845pub fn gossip_cooldown_for(
1846 gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
1847 name: &PackageName,
1848 comparand: &str,
1849 now: PublishTime,
1850) -> GossipCooldownLookup {
1851 let Some(findings) = gossip_prefetch.and_then(|m| m.get(name)) else {
1852 return GossipCooldownLookup::Unavailable;
1853 };
1854 if findings.version != comparand {
1855 return GossipCooldownLookup::Unavailable;
1856 }
1857 match findings.cooldown.as_ref() {
1858 Some(cooldown) if cooldown.is_active(now) => GossipCooldownLookup::Active,
1859 Some(_) => GossipCooldownLookup::NotActive,
1860 None => GossipCooldownLookup::Unavailable,
1861 }
1862}
1863
1864/// Wall-clock budget `deps-lsp`'s completion handler gives an ecosystem's
1865/// `generate_completions` before treating it as a timeout.
1866///
1867/// Past this, the handler skips the fallback search rather than treating a
1868/// fast-but-empty result as "genuinely no results"
1869/// (`crates/deps-lsp/src/handlers/completion.rs`).
1870///
1871/// A registry-backed completion path that retries internally on failure (e.g.
1872/// `deps-maven`'s `search`, #274) must size its own total retry budget to
1873/// exceed this constant: finishing sooner with an empty/error result is
1874/// indistinguishable, at the call site, from a query that legitimately has no
1875/// matches, and triggers a wasted (and, for a struggling registry, likely to also
1876/// fail) fallback search rather than the handler's existing skip-on-timeout path.
1877///
1878/// Lives here (ungated), not in [`crate::completion`] (`#[cfg(feature =
1879/// "lsp-responses")]`), and is re-exported from there for that feature's consumers:
1880/// a registry-backed completion path's own retry-budget constant (e.g. `deps-maven`'s
1881/// `RECENT_FAILURE_TTL`) is genuine `Registry::search` behavior, not LSP-response-shaped,
1882/// and must stay available under `--no-default-features` too (issue #1083 critic M1) —
1883/// one definition, not a hand-copied duplicate per feature state.
1884pub const COMPLETION_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
1885
1886/// Wall-clock budget hover and code-action generation give the primary
1887/// `Registry::get_versions_from` fetch for the dependency under the cursor.
1888///
1889/// Unlike completion, code lens, inlay hints, and the background fetch task, hover
1890/// and code actions previously awaited this call with no deadline at all — an
1891/// ecosystem whose registry client retries across several candidate URLs
1892/// sequentially (e.g. `deps-maven`'s Gradle Plugin Portal fallback) could multiply
1893/// `HttpCache`'s own per-request timeout into tens of seconds of blocked, user-facing
1894/// latency on a single hover or lightbulb request (issue #1204). This is the *only*
1895/// deadline needed to fix that: it wraps the whole `get_versions_from` future,
1896/// cancelling it — candidate-URL loop included — at the ecosystem-registry level, so
1897/// no per-ecosystem inner budget is required on top of it. On elapse, hover falls back
1898/// to its existing `.ok()`-based basic-card degradation (the same path a genuine fetch
1899/// error takes); code actions instead drop the speculative fix/unsat actions outright
1900/// (see `await_versions_fetch`'s `timed_out` flag) rather than reusing that same
1901/// fail-open path, since an unverified yank check must not be treated as "not yanked".
1902pub const REGISTRY_FETCH_BUDGET: Duration = Duration::from_secs(10);
1903
1904/// Awaits `fetch` bounded by [`REGISTRY_FETCH_BUDGET`], logging a `tracing::warn!` naming
1905/// `package` and `context` if the budget elapses first.
1906///
1907/// Shared by hover and code-action generation to avoid duplicating this
1908/// timeout/log/degrade shape at both call sites. Returns `(None, false)` for a
1909/// fetch-level error exactly like the pre-#1204 `.ok()` degrade, and `(None, true)` on
1910/// timeout — the `bool` lets a caller (code actions) tell the two apart when a fetch
1911/// failure and a fetch timeout must not be treated the same way (impl-critic S1: a
1912/// timeout is not a verified "not yanked" answer, so it must not fail open the way a
1913/// genuine registry outage does).
1914#[cfg(feature = "lsp-responses")]
1915async fn await_versions_fetch<T, E>(
1916 fetch: impl std::future::Future<Output = Result<T, E>>,
1917 package: &PackageName,
1918 context: &'static str,
1919) -> (Option<T>, bool) {
1920 match tokio::time::timeout(REGISTRY_FETCH_BUDGET, fetch).await {
1921 Ok(result) => (result.ok(), false),
1922 Err(_) => {
1923 tracing::warn!(
1924 package = %package.for_tracing(),
1925 context,
1926 timeout_secs = REGISTRY_FETCH_BUDGET.as_secs(),
1927 "primary registry version fetch timed out"
1928 );
1929 (None, true)
1930 }
1931 }
1932}
1933
1934/// Converts UTF-16 offset to byte offset in a string.
1935///
1936/// LSP uses UTF-16 code units for character positions (for compatibility with
1937/// JavaScript and other languages). This function converts from UTF-16 offset
1938/// to byte offset for Rust string indexing.
1939///
1940/// # Arguments
1941///
1942/// * `s` - The string to index into
1943/// * `utf16_offset` - UTF-16 code unit offset (from LSP Position.character)
1944///
1945/// # Returns
1946///
1947/// Byte offset if valid, `None` if the UTF-16 offset is out of bounds.
1948///
1949/// # Examples
1950///
1951/// ```
1952/// # use deps_core::lsp_helpers::utf16_to_byte_offset;
1953/// // ASCII: UTF-16 offset equals byte offset
1954/// assert_eq!(utf16_to_byte_offset("hello", 2), Some(2));
1955///
1956/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
1957/// assert_eq!(utf16_to_byte_offset("日本語", 0), Some(0));
1958/// assert_eq!(utf16_to_byte_offset("日本語", 1), Some(3));
1959/// assert_eq!(utf16_to_byte_offset("日本語", 2), Some(6));
1960///
1961/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
1962/// assert_eq!(utf16_to_byte_offset("😀test", 2), Some(4));
1963/// ```
1964pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
1965 let mut utf16_count = 0u32;
1966 for (byte_idx, ch) in s.char_indices() {
1967 if utf16_count >= utf16_offset {
1968 return Some(byte_idx);
1969 }
1970 #[expect(
1971 clippy::cast_possible_truncation,
1972 reason = "char::len_utf16 always returns 1 or 2, so this cast never truncates"
1973 )]
1974 {
1975 utf16_count += ch.len_utf16() as u32;
1976 }
1977 }
1978 if utf16_count == utf16_offset {
1979 return Some(s.len());
1980 }
1981 None
1982}
1983
1984/// Converts a byte offset within `s` to a UTF-16 code unit offset (LSP `Position.character`).
1985///
1986/// `byte_offset` may be an arbitrary caller-supplied value: it is clamped to `s.len()` and
1987/// floored down to the nearest UTF-8 char boundary before use, so this never panics.
1988///
1989/// # Examples
1990///
1991/// ```
1992/// # use deps_core::lsp_helpers::byte_to_utf16_offset;
1993/// // ASCII: byte offset equals UTF-16 offset
1994/// assert_eq!(byte_to_utf16_offset("hello", 2), 2);
1995///
1996/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
1997/// assert_eq!(byte_to_utf16_offset("日本語", 0), 0);
1998/// assert_eq!(byte_to_utf16_offset("日本語", 3), 1);
1999/// assert_eq!(byte_to_utf16_offset("日本語", 6), 2);
2000///
2001/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
2002/// assert_eq!(byte_to_utf16_offset("😀test", 4), 2);
2003///
2004/// // Never panics: an offset landing mid-character floors down to the start of that
2005/// // character, and an offset past the end saturates to the string's length.
2006/// assert_eq!(byte_to_utf16_offset("日本語", 1), 0); // inside the first character
2007/// assert_eq!(byte_to_utf16_offset("日本語", 999), 3); // past the end
2008/// ```
2009#[expect(
2010 clippy::string_slice,
2011 reason = "end is floor_char_boundary-clamped just above, mirroring the already-hardened \
2012 LineOffsetTable::byte_offset_to_position (lsp_helpers/mod.rs)"
2013)]
2014pub fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
2015 // Saturate rather than silently wrap: an LSP `Position.character` past `u32::MAX` UTF-16
2016 // units is already meaningless, but a wrapped value would be a wrong-but-plausible one
2017 // (#673 — the exact offset-math bug class #244 shipped).
2018 //
2019 // `byte_offset` is not guaranteed to be in bounds or on a char boundary (a caller-supplied
2020 // offset can be past `s.len()` or land mid-character); `floor_char_boundary` clamps both
2021 // — past-the-end saturates to `s.len()` and any other in-bounds index floors to the
2022 // nearest char boundary — mirroring `LineOffsetTable::byte_offset_to_position`
2023 // (lsp_helpers/mod.rs).
2024 let end = s.floor_char_boundary(byte_offset);
2025 u32::try_from(s[..end].encode_utf16().count()).unwrap_or(u32::MAX)
2026}
2027
2028/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
2029pub fn position_in_range(pos: Position, range: Range) -> bool {
2030 if pos.line < range.start.line || pos.line > range.end.line {
2031 return false;
2032 }
2033 if pos.line == range.start.line && pos.character < range.start.character {
2034 return false;
2035 }
2036 if pos.line == range.end.line && pos.character > range.end.character {
2037 return false;
2038 }
2039 true
2040}
2041
2042/// Whether `dep`'s `version_range()` is a degenerate, completion-only position with no
2043/// requirement behind it (e.g. Maven's `<version></version>`/self-closing `<version/>`) rather
2044/// than a real, non-empty range that happens to have no requirement (e.g. Gradle's
2045/// `version.ref` pointing at a dangling or rich-version alias, which spans the real
2046/// alias-reference text). Zero-width (`start == end`) is the discriminating signal —
2047/// `version_requirement().is_none()` alone is not, since both shapes share it.
2048pub(crate) fn version_range_is_synthetic_empty(dep: &dyn Dependency) -> bool {
2049 dep.version_requirement().is_none() && dep.version_range().is_some_and(|r| r.start == r.end)
2050}
2051
2052/// Resolves the [`ScanOutcome`] for one dependency occurrence.
2053///
2054/// Tries `keys`' version-qualified lookup key for `dep` first (from
2055/// `crate::osv::vulnerability_keys`, #394 S2 — distinguishes two occurrences of one name
2056/// pinned to different versions), then the ecosystem-normalized name, then `dep`'s declared
2057/// name.
2058///
2059/// The single shared fallback chain for every OSV-outcome consumer
2060/// (`diagnostics::apply_vulnerability_rule`, `diagnostics::skip_reason_notice`,
2061/// `hover::generate_hover`'s vulnerability section) — previously written out four times
2062/// independently (issue #1400), risking a future change to the fallback priority landing in
2063/// only some of them. `keys` is `Option` because a caller with no [`EcosystemId`] to give
2064/// `crate::osv::vulnerability_keys` (most test
2065/// fixtures) has none to pass. `normalized_name` stays a caller-supplied parameter rather than
2066/// being derived here: `diagnostics` precomputes it once per name range and shares it across
2067/// both this lookup and its own per-dependency loop, so `normalize_package_name` (an
2068/// allocating call) never runs twice for the same dependency.
2069///
2070/// # Examples
2071///
2072/// ```
2073/// use deps_core::lsp_helpers::{
2074/// DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
2075/// RequirementResolution, SourcePolicy, resolve_scan_outcome,
2076/// };
2077/// use deps_core::osv::{ScanOutcome, VulnerabilityMap};
2078/// use deps_core::position::{Position, Range};
2079/// use deps_core::{ConcreteVersion, Dependency, PackageName, VersionReq};
2080/// use std::any::Any;
2081///
2082/// struct SimpleDep {
2083/// name: PackageName,
2084/// name_range: Range,
2085/// }
2086///
2087/// impl Dependency for SimpleDep {
2088/// fn name(&self) -> &PackageName {
2089/// &self.name
2090/// }
2091/// fn name_range(&self) -> Range {
2092/// self.name_range
2093/// }
2094/// fn version_requirement(&self) -> Option<&VersionReq> {
2095/// None
2096/// }
2097/// fn version_range(&self) -> Option<Range> {
2098/// None
2099/// }
2100/// fn source(&self) -> deps_core::parser::DependencySource {
2101/// deps_core::parser::DependencySource::Registry
2102/// }
2103/// fn as_any(&self) -> &dyn Any {
2104/// self
2105/// }
2106/// }
2107///
2108/// let dep = SimpleDep {
2109/// name: PackageName::new("time"),
2110/// name_range: Range::new(Position::new(0, 0), Position::new(0, 4)).into(),
2111/// };
2112///
2113/// let mut vulnerabilities = VulnerabilityMap::new();
2114/// vulnerabilities.insert(deps_core::test_util::vuln_key("time"), ScanOutcome::Clean);
2115///
2116/// // No `VulnKeys` map: falls back straight to the normalized name.
2117/// let outcome = resolve_scan_outcome(&vulnerabilities, &dep, None, "time");
2118/// assert!(matches!(outcome, Some(ScanOutcome::Clean)));
2119/// ```
2120pub fn resolve_scan_outcome<'a>(
2121 vulnerabilities: &'a VulnerabilityMap,
2122 dep: &dyn Dependency,
2123 keys: Option<&crate::osv::VulnKeys>,
2124 normalized_name: &str,
2125) -> Option<&'a ScanOutcome> {
2126 resolve_by_vuln_key(vulnerabilities, dep, keys, normalized_name)
2127}
2128
2129/// Shared per-occurrence [`VulnKey`] lookup chain (version-qualified key, then normalized name,
2130/// then declared name) underlying both [`resolve_scan_outcome`] and [`resolve_latest_status`] —
2131/// the two maps [`VulnerabilityMap`] and [`crate::osv::LatestStatusMap`] share this exact key
2132/// space (#1517), so the fallback chain only needs writing once.
2133fn resolve_by_vuln_key<'a, V>(
2134 map: &'a HashMap<VulnKey, V>,
2135 dep: &dyn Dependency,
2136 keys: Option<&crate::osv::VulnKeys>,
2137 normalized_name: &str,
2138) -> Option<&'a V> {
2139 keys.and_then(|k| k.get(&dep.name_range()))
2140 .and_then(|key| map.get(key))
2141 .or_else(|| map.get(&VulnKey::from_name(normalized_name.to_string())))
2142 .or_else(|| map.get(&VulnKey::from_name(dep.name().as_str().to_string())))
2143}
2144
2145/// Looks up `dep`'s entry in phase B's per-key "latest" status map, using the same
2146/// version-qualified-key-then-normalized-name-then-declared-name fallback chain as
2147/// [`resolve_scan_outcome`] (#1517).
2148///
2149/// # Examples
2150///
2151/// ```
2152/// use deps_core::lsp_helpers::resolve_latest_status;
2153/// use deps_core::osv::{LatestStatusMap, UpgradeStatus};
2154/// use deps_core::position::{Position, Range};
2155/// use deps_core::{Dependency, PackageName, VersionReq};
2156/// use std::any::Any;
2157///
2158/// struct SimpleDep {
2159/// name: PackageName,
2160/// name_range: Range,
2161/// }
2162///
2163/// impl Dependency for SimpleDep {
2164/// fn name(&self) -> &PackageName {
2165/// &self.name
2166/// }
2167/// fn name_range(&self) -> Range {
2168/// self.name_range
2169/// }
2170/// fn version_requirement(&self) -> Option<&VersionReq> {
2171/// None
2172/// }
2173/// fn version_range(&self) -> Option<Range> {
2174/// None
2175/// }
2176/// fn source(&self) -> deps_core::parser::DependencySource {
2177/// deps_core::parser::DependencySource::Registry
2178/// }
2179/// fn as_any(&self) -> &dyn Any {
2180/// self
2181/// }
2182/// }
2183///
2184/// let dep = SimpleDep {
2185/// name: PackageName::new("time"),
2186/// name_range: Range::new(Position::new(0, 0), Position::new(0, 4)).into(),
2187/// };
2188///
2189/// let mut latest_status = LatestStatusMap::new();
2190/// latest_status.insert(
2191/// deps_core::test_util::vuln_key("time"),
2192/// UpgradeStatus::CandidateClean {
2193/// version: deps_core::ConcreteVersion::new("1.0.0"),
2194/// },
2195/// );
2196///
2197/// let status = resolve_latest_status(&latest_status, &dep, None, "time");
2198/// assert!(matches!(status, Some(UpgradeStatus::CandidateClean { .. })));
2199/// ```
2200pub fn resolve_latest_status<'a>(
2201 latest_status: &'a LatestStatusMap,
2202 dep: &dyn Dependency,
2203 keys: Option<&crate::osv::VulnKeys>,
2204 normalized_name: &str,
2205) -> Option<&'a UpgradeStatus> {
2206 resolve_by_vuln_key(latest_status, dep, keys, normalized_name)
2207}
2208
2209/// Whether a dependency's currently-displayed "latest" upgrade recommendation is safe to
2210/// surface as such (issue #1517).
2211///
2212/// The single gate every renderer (hover, diagnostics, code actions, code lens, inlay hints,
2213/// completion) and `deps-cli update`/`check` must consult before treating `latest` as an
2214/// endorsed upgrade target.
2215// Exhaustive: a new "verdict" is a deliberate design decision about how a fifth case should
2216// render everywhere at once, never a silent default — mirrors `EcosystemId`'s exhaustive-match
2217// convention (project rule, see #118).
2218#[derive(Debug, Clone, PartialEq, Eq)]
2219pub enum LatestVerdict {
2220 /// OSV's latest-check does not apply here: OSV is disabled/offline for this scan entirely
2221 /// (no [`crate::osv::LatestStatusMap`] was ever attached), or this dependency's skip reason
2222 /// is structural ([`crate::osv::SkipReason::is_structural`] — its source/ecosystem is never
2223 /// checked against OSV, e.g. a git dependency or an OSV-unsupported ecosystem).
2224 NotApplicable,
2225 /// The exact version currently displayed as "latest" was checked and found clean (or
2226 /// affected only by [`VulnSeverity::Informational`]-only advisories, e.g. an "unmaintained"
2227 /// notice with no genuine security content).
2228 Verified,
2229 /// The exact version currently displayed as "latest" was checked and found affected by at
2230 /// least one non-informational advisory (or a record whose severity could not be
2231 /// determined, which is treated as blocking rather than silently passed through).
2232 Flagged {
2233 /// Advisory ids affecting the checked version, for display.
2234 advisory_ids: Vec<String>,
2235 /// Whether the worst affecting advisory is a confirmed-malicious-package record
2236 /// ([`VulnSeverity::Malicious`]) — renderers use this to escalate wording/severity.
2237 malicious: bool,
2238 },
2239 /// The version was never definitively checked against OSV: no entry for this dependency
2240 /// (including the pre-phase-B window, where an empty map is attached deliberately so this
2241 /// case fires instead of [`Self::NotApplicable`]), a transient skip (timeout, query
2242 /// failure, truncation), or the checked version has since diverged from what's now
2243 /// displayed as `latest`. Renderers must treat this the same as [`Self::Flagged`] for the
2244 /// purpose of *not* recommending the upgrade — the two are kept distinct only so wording can
2245 /// differ ("not yet verified" vs. "flagged unsafe").
2246 Unverified,
2247}
2248
2249/// Computes `dep`'s [`LatestVerdict`] against `displayed_latest` (the exact version string a
2250/// renderer is about to show as "latest") from `latest_status` (typically
2251/// [`VersionData::latest_status`]).
2252///
2253/// # Examples
2254///
2255/// ```
2256/// use deps_core::lsp_helpers::{LatestVerdict, latest_verdict};
2257/// use deps_core::osv::{LatestStatusMap, UpgradeStatus};
2258/// use deps_core::position::{Position, Range};
2259/// use deps_core::{Dependency, PackageName, VersionReq};
2260/// use std::any::Any;
2261///
2262/// struct SimpleDep {
2263/// name: PackageName,
2264/// name_range: Range,
2265/// }
2266///
2267/// impl Dependency for SimpleDep {
2268/// fn name(&self) -> &PackageName {
2269/// &self.name
2270/// }
2271/// fn name_range(&self) -> Range {
2272/// self.name_range
2273/// }
2274/// fn version_requirement(&self) -> Option<&VersionReq> {
2275/// None
2276/// }
2277/// fn version_range(&self) -> Option<Range> {
2278/// None
2279/// }
2280/// fn source(&self) -> deps_core::parser::DependencySource {
2281/// deps_core::parser::DependencySource::Registry
2282/// }
2283/// fn as_any(&self) -> &dyn Any {
2284/// self
2285/// }
2286/// }
2287///
2288/// let dep = SimpleDep {
2289/// name: PackageName::new("left-pad"),
2290/// name_range: Range::new(Position::new(0, 0), Position::new(0, 8)).into(),
2291/// };
2292/// let formatter = deps_core::test_util::StubFormatter::new();
2293///
2294/// // No map attached at all (OSV disabled/offline) — not applicable, today's behavior.
2295/// assert_eq!(
2296/// latest_verdict(None, &dep, None, "left-pad", "1.0.8", &formatter),
2297/// LatestVerdict::NotApplicable
2298/// );
2299///
2300/// // A map with no entry for this dependency (e.g. before phase B first completes) must fail
2301/// // closed, never pass through as safe.
2302/// let empty = LatestStatusMap::new();
2303/// assert_eq!(
2304/// latest_verdict(Some(&empty), &dep, None, "left-pad", "1.0.8", &formatter),
2305/// LatestVerdict::Unverified
2306/// );
2307/// ```
2308#[must_use]
2309pub fn latest_verdict(
2310 latest_status: Option<&LatestStatusMap>,
2311 dep: &dyn Dependency,
2312 keys: Option<&crate::osv::VulnKeys>,
2313 normalized_name: &str,
2314 displayed_latest: &str,
2315 formatter: &dyn EcosystemFormatter,
2316) -> LatestVerdict {
2317 let Some(map) = latest_status else {
2318 return LatestVerdict::NotApplicable;
2319 };
2320 upgrade_status_to_verdict(
2321 resolve_latest_status(map, dep, keys, normalized_name),
2322 displayed_latest,
2323 dep,
2324 formatter,
2325 )
2326}
2327
2328/// Shared `UpgradeStatus -> LatestVerdict` mapping behind both [`latest_verdict`] and
2329/// [`candidate_verdict`] (#1524) — factored out so the two never drift on what "clean" vs.
2330/// "vulnerable" vs. "unverified"/"not applicable" means for a checked candidate.
2331///
2332/// A [`SkipReason::NonRegistrySource`] structural entry is cached under `dep`'s plain
2333/// [`crate::osv::VulnKey`] (name, not source), so it survives untouched if `dep`'s source later
2334/// changes from e.g. path/git to registry — until the next phase B run overwrites it. Since that
2335/// window would otherwise read as the fail-open [`LatestVerdict::NotApplicable`] for a dependency
2336/// that is now registry-resolvable and simply hasn't been checked yet, this re-checks `dep`'s
2337/// *current* source before trusting a cached `NonRegistrySource` skip (issue #1531). The check
2338/// goes through `formatter.source_is_public_registry_content` — the same predicate phase B itself
2339/// uses to classify `NonRegistrySource` in the first place (`deps-engine`'s `classify::osv`) —
2340/// rather than a bare `DependencySource::Registry` match, so an ecosystem that widens what counts
2341/// as registry content (e.g. `deps-cargo`'s `AlternateRegistry { mirrors_crates_io: true }` for a
2342/// configured crates.io mirror) is not itself misread as still-non-registry and wrongly kept
2343/// stale. A mismatch means the entry is stale, so it fails closed to
2344/// [`LatestVerdict::Unverified`] instead.
2345fn upgrade_status_to_verdict(
2346 status: Option<&UpgradeStatus>,
2347 expected_version: &str,
2348 dep: &dyn Dependency,
2349 formatter: &dyn EcosystemFormatter,
2350) -> LatestVerdict {
2351 match status {
2352 None | Some(UpgradeStatus::NotChecked) => LatestVerdict::Unverified,
2353 Some(UpgradeStatus::StructurallyUnchecked(reason)) => {
2354 let stale_non_registry_skip = *reason == StructuralSkipReason::NonRegistrySource
2355 && formatter.source_is_public_registry_content(&dep.source());
2356 if stale_non_registry_skip {
2357 LatestVerdict::Unverified
2358 } else {
2359 LatestVerdict::NotApplicable
2360 }
2361 }
2362 Some(UpgradeStatus::CandidateUnverified { reason, .. }) => {
2363 if reason.is_structural() {
2364 LatestVerdict::NotApplicable
2365 } else {
2366 LatestVerdict::Unverified
2367 }
2368 }
2369 Some(UpgradeStatus::CandidateClean { version }) => {
2370 if version == expected_version {
2371 LatestVerdict::Verified
2372 } else {
2373 LatestVerdict::Unverified
2374 }
2375 }
2376 Some(UpgradeStatus::CandidateVulnerable {
2377 version,
2378 advisory_ids,
2379 worst_severity,
2380 }) => {
2381 if version != expected_version {
2382 return LatestVerdict::Unverified;
2383 }
2384 if *worst_severity == Some(VulnSeverity::Informational) {
2385 LatestVerdict::Verified
2386 } else {
2387 LatestVerdict::Flagged {
2388 advisory_ids: advisory_ids.items().to_vec(),
2389 malicious: *worst_severity == Some(VulnSeverity::Malicious),
2390 }
2391 }
2392 }
2393 }
2394}
2395
2396/// Resolves `dep`'s per-version candidate status from `candidate_status` (typically
2397/// [`VersionData::candidate_status`]), using the same three-tier `VulnKey` resolution
2398/// [`resolve_latest_status`] uses.
2399///
2400/// # Examples
2401///
2402/// ```
2403/// use deps_core::lsp_helpers::resolve_candidate_status;
2404/// use deps_core::osv::{CandidateStatusMap, UpgradeStatus};
2405/// use deps_core::position::{Position, Range};
2406/// use deps_core::{Dependency, PackageName, VersionReq};
2407/// use std::any::Any;
2408///
2409/// struct SimpleDep {
2410/// name: PackageName,
2411/// name_range: Range,
2412/// }
2413///
2414/// impl Dependency for SimpleDep {
2415/// fn name(&self) -> &PackageName {
2416/// &self.name
2417/// }
2418/// fn name_range(&self) -> Range {
2419/// self.name_range
2420/// }
2421/// fn version_requirement(&self) -> Option<&VersionReq> {
2422/// None
2423/// }
2424/// fn version_range(&self) -> Option<Range> {
2425/// None
2426/// }
2427/// fn source(&self) -> deps_core::parser::DependencySource {
2428/// deps_core::parser::DependencySource::Registry
2429/// }
2430/// fn as_any(&self) -> &dyn Any {
2431/// self
2432/// }
2433/// }
2434///
2435/// let dep = SimpleDep {
2436/// name: PackageName::new("time"),
2437/// name_range: Range::new(Position::new(0, 0), Position::new(0, 4)).into(),
2438/// };
2439///
2440/// let mut candidate_status = CandidateStatusMap::new();
2441/// candidate_status.insert(
2442/// deps_core::test_util::vuln_key("time"),
2443/// deps_core::osv::CandidateStatuses::PerVersion(
2444/// std::iter::once((
2445/// deps_core::ConcreteVersion::new("0.1.43"),
2446/// UpgradeStatus::CandidateClean {
2447/// version: deps_core::ConcreteVersion::new("0.1.43"),
2448/// },
2449/// ))
2450/// .collect(),
2451/// ),
2452/// );
2453///
2454/// let statuses = resolve_candidate_status(&candidate_status, &dep, None, "time").unwrap();
2455/// let deps_core::osv::CandidateStatuses::PerVersion(per_version) = statuses else {
2456/// unreachable!()
2457/// };
2458/// assert!(matches!(
2459/// per_version.get(&deps_core::ConcreteVersion::new("0.1.43")),
2460/// Some(UpgradeStatus::CandidateClean { .. })
2461/// ));
2462/// ```
2463#[must_use]
2464pub fn resolve_candidate_status<'a>(
2465 candidate_status: &'a CandidateStatusMap,
2466 dep: &dyn Dependency,
2467 keys: Option<&crate::osv::VulnKeys>,
2468 normalized_name: &str,
2469) -> Option<&'a CandidateStatuses> {
2470 resolve_by_vuln_key(candidate_status, dep, keys, normalized_name)
2471}
2472
2473/// Computes `dep`'s [`LatestVerdict`] for one specific candidate version (#1524).
2474///
2475/// The sibling of [`latest_verdict`] for a candidate-offering surface (code actions' "update
2476/// to X" list, completion's version items) that needs a verdict for more than just the
2477/// registry's single "latest" pick.
2478///
2479/// A dependency with no entry at all in `candidate_status` (phase B's candidate-check round
2480/// never covered it) resolves to [`LatestVerdict::Unverified`], matching [`latest_verdict`]'s
2481/// identical fail-closed default. A structural skip is recorded once per dependency as
2482/// [`crate::osv::CandidateStatuses::Structural`] (see that type's doc) and applies uniformly to
2483/// every candidate version, never mixed with real per-version data for the same dependency.
2484///
2485/// # Examples
2486///
2487/// ```
2488/// use deps_core::lsp_helpers::{LatestVerdict, candidate_verdict};
2489/// use deps_core::osv::{CandidateStatusMap, UpgradeStatus};
2490/// use deps_core::position::{Position, Range};
2491/// use deps_core::{Dependency, PackageName, VersionReq};
2492/// use std::any::Any;
2493///
2494/// struct SimpleDep {
2495/// name: PackageName,
2496/// name_range: Range,
2497/// }
2498///
2499/// impl Dependency for SimpleDep {
2500/// fn name(&self) -> &PackageName {
2501/// &self.name
2502/// }
2503/// fn name_range(&self) -> Range {
2504/// self.name_range
2505/// }
2506/// fn version_requirement(&self) -> Option<&VersionReq> {
2507/// None
2508/// }
2509/// fn version_range(&self) -> Option<Range> {
2510/// None
2511/// }
2512/// fn source(&self) -> deps_core::parser::DependencySource {
2513/// deps_core::parser::DependencySource::Registry
2514/// }
2515/// fn as_any(&self) -> &dyn Any {
2516/// self
2517/// }
2518/// }
2519///
2520/// let dep = SimpleDep {
2521/// name: PackageName::new("left-pad"),
2522/// name_range: Range::new(Position::new(0, 0), Position::new(0, 8)).into(),
2523/// };
2524/// let formatter = deps_core::test_util::StubFormatter::new();
2525///
2526/// // No map attached at all (OSV disabled/offline) — not applicable, mirrors `latest_verdict`.
2527/// assert_eq!(
2528/// candidate_verdict(None, &dep, None, "left-pad", "1.0.6", &formatter),
2529/// LatestVerdict::NotApplicable
2530/// );
2531///
2532/// // A map with an entry for this dependency, but not for this exact version — fails closed
2533/// // to `Unverified`, never silently treated as safe.
2534/// let mut candidate_status = CandidateStatusMap::new();
2535/// candidate_status.insert(
2536/// deps_core::test_util::vuln_key("left-pad"),
2537/// deps_core::osv::CandidateStatuses::PerVersion(std::collections::HashMap::new()),
2538/// );
2539/// assert_eq!(
2540/// candidate_verdict(Some(&candidate_status), &dep, None, "left-pad", "1.0.6", &formatter),
2541/// LatestVerdict::Unverified
2542/// );
2543/// ```
2544#[must_use]
2545pub fn candidate_verdict(
2546 candidate_status: Option<&CandidateStatusMap>,
2547 dep: &dyn Dependency,
2548 keys: Option<&crate::osv::VulnKeys>,
2549 normalized_name: &str,
2550 version: &str,
2551 formatter: &dyn EcosystemFormatter,
2552) -> LatestVerdict {
2553 let Some(map) = candidate_status else {
2554 return LatestVerdict::NotApplicable;
2555 };
2556 match resolve_candidate_status(map, dep, keys, normalized_name) {
2557 None => LatestVerdict::Unverified,
2558 Some(CandidateStatuses::Structural(reason)) => upgrade_status_to_verdict(
2559 Some(&UpgradeStatus::StructurallyUnchecked(*reason)),
2560 version,
2561 dep,
2562 formatter,
2563 ),
2564 Some(CandidateStatuses::PerVersion(per_version)) => {
2565 let status = per_version.get(&ConcreteVersion::from(version));
2566 upgrade_status_to_verdict(status, version, dep, formatter)
2567 }
2568 }
2569}
2570
2571/// Converts byte offsets in source text to LSP `Position` values.
2572///
2573/// Precomputes line-start byte offsets once, then maps any byte offset to a
2574/// `(line, character)` position. Characters are counted as UTF-16 code units
2575/// as required by the LSP specification.
2576///
2577/// Also precomputes, per line, whether that line is pure ASCII (`line_is_ascii`): for an
2578/// ASCII-only line, byte offset and UTF-16 code-unit
2579/// offset are always equal, so [`byte_offset_to_position`](Self::byte_offset_to_position)
2580/// can skip the `chars().map(char::len_utf16).sum()` scan entirely and compute the
2581/// character offset in O(1). This matters because a single minified manifest line (e.g. a
2582/// `package.json` with hundreds of dependencies on one line) turns per-offset lookups into
2583/// an O(n) scan each, and O(n) lookups across the line's length make the whole document
2584/// O(n^2) (#742) — the common case of an ASCII-only line now stays O(1) per lookup; a
2585/// non-ASCII line still takes an O(1) lookup too, via a lazily-built per-line index (#882) —
2586/// see `with_non_ascii_line_index`.
2587///
2588/// Interior mutability: this type is `Send` but not `Sync` (the non-ASCII line index cache
2589/// uses a `RefCell`). Build one per document parse and never share it across threads.
2590pub struct LineOffsetTable {
2591 line_starts: Vec<usize>,
2592 line_is_ascii: Vec<bool>,
2593 // Lazily populated, keyed by line number. A `HashMap` rather than a `Vec` sized to the
2594 // line count so cost is proportional to lines actually queried non-ASCII (#882).
2595 non_ascii_line_index_cache:
2596 std::cell::RefCell<std::collections::HashMap<usize, NonAsciiLineIndex>>,
2597}
2598
2599/// A non-ASCII line's char-boundary index, lazily built and cached by
2600/// `LineOffsetTable::with_non_ascii_line_index`.
2601///
2602/// `entries[i]` is `(byte_offset, utf16_units_before)` for the line's `i`-th char boundary
2603/// (`byte_offset` relative to the line's own start, `utf16_units_before` the cumulative UTF-16
2604/// code-unit count of every char *before* it). A final sentinel entry for the end of the line
2605/// (`byte_offset == line.len()`, `utf16_units_before` = the line's total UTF-16 length) makes
2606/// end-of-line byte offsets resolvable the same way as any other char boundary.
2607///
2608/// Serves both directions #882 needs on a non-ASCII line, from one build pass: char index ->
2609/// byte offset (`entries.get(col)`, what [`marker_byte_offset`] needs) and byte offset ->
2610/// UTF-16 units (`entries.binary_search_by_key`, what
2611/// [`byte_offset_to_position`](LineOffsetTable::byte_offset_to_position) needs) — a second,
2612/// independent cache would duplicate the exact `char_indices()` walk this one pass already
2613/// performs.
2614struct NonAsciiLineIndex {
2615 entries: Vec<(u32, u32)>,
2616}
2617
2618impl NonAsciiLineIndex {
2619 /// Builds the index for one line's text via a single `char_indices()` walk.
2620 fn build(line_text: &str) -> Self {
2621 let mut entries = Vec::with_capacity(line_text.len() + 1);
2622 let mut utf16_units = 0u32;
2623 for (byte_offset, c) in line_text.char_indices() {
2624 entries.push((u32::try_from(byte_offset).unwrap_or(u32::MAX), utf16_units));
2625 utf16_units = utf16_units.saturating_add(u32::try_from(c.len_utf16()).unwrap_or(2));
2626 }
2627 entries.push((
2628 u32::try_from(line_text.len()).unwrap_or(u32::MAX),
2629 utf16_units,
2630 ));
2631 // The initial `line_text.len() + 1` capacity over-reserves 3-4x on a multi-byte-heavy
2632 // line (CJK, Devanagari, ...): it reserves per byte but pushes one entry per char.
2633 entries.shrink_to_fit();
2634 Self { entries }
2635 }
2636}
2637
2638impl LineOffsetTable {
2639 /// Builds the table for `content`.
2640 pub fn new(content: &str) -> Self {
2641 let mut line_starts = vec![0];
2642 let mut line_is_ascii = Vec::new();
2643 let mut current_ascii = true;
2644 for (i, c) in content.char_indices() {
2645 if !c.is_ascii() {
2646 current_ascii = false;
2647 }
2648 if c == '\n' {
2649 line_starts.push(i + 1);
2650 line_is_ascii.push(current_ascii);
2651 current_ascii = true;
2652 }
2653 }
2654 line_is_ascii.push(current_ascii);
2655 debug_assert_eq!(
2656 line_starts.len(),
2657 line_is_ascii.len(),
2658 "line_starts and line_is_ascii must stay in lockstep — one entry per line"
2659 );
2660 Self {
2661 line_starts,
2662 line_is_ascii,
2663 non_ascii_line_index_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
2664 }
2665 }
2666
2667 /// Runs `f` against the cached (building it on first call) [`NonAsciiLineIndex`] for the
2668 /// non-ASCII line starting at `line0` (0-indexed), given that line's own text.
2669 ///
2670 /// Callers must pass the exact text of line `line0` as sliced from the same `content` this
2671 /// table was built from — the cache trusts its first-seen `line_text` for every later
2672 /// lookup of the same `line0` and never re-validates it. Building a line's index the first
2673 /// time it is looked up is still O(line length) (one `char_indices()` walk); only repeat
2674 /// lookups on an already-cached line become O(1) — a huge non-ASCII line's *first* lookup
2675 /// is not free, it is the same one-time cost `LineOffsetTable::new` always paid.
2676 ///
2677 /// Takes a closure rather than returning a `Ref` so the `RefCell` borrow never outlives one
2678 /// call — a caller cannot accidentally hold it open across an unrelated later borrow.
2679 #[expect(
2680 clippy::expect_used,
2681 reason = "borrow_mut() is dropped before borrow() runs, and the inserted entry is \
2682 never removed, so the lookup below always succeeds"
2683 )]
2684 fn with_non_ascii_line_index<R>(
2685 &self,
2686 line0: usize,
2687 line_text: &str,
2688 f: impl FnOnce(&NonAsciiLineIndex) -> R,
2689 ) -> R {
2690 self.non_ascii_line_index_cache
2691 .borrow_mut()
2692 .entry(line0)
2693 .or_insert_with(|| NonAsciiLineIndex::build(line_text));
2694 let cache = self.non_ascii_line_index_cache.borrow();
2695 let index = cache
2696 .get(&line0)
2697 .expect("just inserted above if it was missing");
2698 f(index)
2699 }
2700
2701 /// Byte offset (relative to the line's own start) of the `col`-th char boundary within the
2702 /// non-ASCII line starting at `line0`, given that line's text. Clamps to `line_text.len()`
2703 /// if `col` is past the line's char count, matching a plain `char_indices().nth(col)` walk.
2704 fn non_ascii_char_byte_offset(&self, line0: usize, line_text: &str, col: usize) -> usize {
2705 self.with_non_ascii_line_index(line0, line_text, |index| {
2706 index
2707 .entries
2708 .get(col)
2709 .map_or(line_text.len(), |&(byte_offset, _)| byte_offset as usize)
2710 })
2711 }
2712
2713 /// UTF-16 code-unit count preceding `byte_offset` (relative to the line's own start) within
2714 /// the non-ASCII line starting at `line0`, given that line's text.
2715 ///
2716 /// `byte_offset` must be a char boundary within the line (guaranteed by
2717 /// [`byte_offset_to_position`](Self::byte_offset_to_position)'s `floor_char_boundary` clamp
2718 /// before calling this) — every char boundary, including end-of-line, has an exact entry in
2719 /// the index, so this never needs to interpolate between two boundaries.
2720 fn non_ascii_utf16_units_before(
2721 &self,
2722 line0: usize,
2723 line_text: &str,
2724 byte_offset: usize,
2725 ) -> u32 {
2726 self.with_non_ascii_line_index(line0, line_text, |index| {
2727 let byte_offset = u32::try_from(byte_offset).unwrap_or(u32::MAX);
2728 let found = index
2729 .entries
2730 .binary_search_by_key(&byte_offset, |&(b, _)| b);
2731 // `Err` shouldn't happen given the caller's `floor_char_boundary` guarantee, but
2732 // falls back to the nearest preceding entry rather than panicking.
2733 let i = found.unwrap_or_else(|i| i.saturating_sub(1));
2734 index.entries.get(i).map_or(0, |&(_, units)| units)
2735 })
2736 }
2737
2738 /// Absolute byte offset where `line` (0-indexed) starts, or `None` if
2739 /// `line` is out of range.
2740 ///
2741 /// Prefer this over re-deriving a line's start via cursor arithmetic
2742 /// (`cursor += line.len() + 1`): `str::lines()` strips a trailing `\r`,
2743 /// so that approach under-counts by one byte per CRLF line and corrupts
2744 /// every subsequent offset in the file. This table is built by scanning
2745 /// `char_indices()` for `\n` (see [`new`](Self::new)), which counts the
2746 /// `\r`, so it stays correct for LF, CRLF and mixed line endings alike.
2747 ///
2748 /// # Examples
2749 ///
2750 /// ```
2751 /// use deps_core::lsp_helpers::LineOffsetTable;
2752 ///
2753 /// let table = LineOffsetTable::new("a\r\nb\r\nc");
2754 /// assert_eq!(table.line_start(0), Some(0));
2755 /// assert_eq!(table.line_start(1), Some(3));
2756 /// assert_eq!(table.line_start(2), Some(6));
2757 /// assert_eq!(table.line_start(3), None);
2758 /// ```
2759 pub fn line_start(&self, line: usize) -> Option<usize> {
2760 self.line_starts.get(line).copied()
2761 }
2762
2763 /// Converts a byte offset into an LSP `Position`.
2764 pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
2765 let offset = offset.min(content.len());
2766 // The requirements.txt line parser derives offsets via hand-rolled byte arithmetic,
2767 // which can land inside a multi-byte character — clamp to the nearest char boundary
2768 // rather than panicking on the slice below.
2769 let offset = content.floor_char_boundary(offset);
2770 let line = self
2771 .line_starts
2772 .partition_point(|&start| start <= offset)
2773 .saturating_sub(1);
2774 #[expect(
2775 clippy::indexing_slicing,
2776 reason = "line_starts always has at least one element (vec![0] at construction), \
2777 so partition_point().saturating_sub(1) is always a valid index into it"
2778 )]
2779 let line_start = self.line_starts[line];
2780 // #673 M1: `line`/`character` aren't bounded by any size cap for editor-sent
2781 // full-document-sync text, so this saturates rather than assuming an upstream cap
2782 // (mirrors `completion::byte_to_utf16_offset`'s identical fix).
2783 //
2784 // #742: an ASCII-only line has 1 UTF-16 unit per byte, so no need to walk `chars()`.
2785 let character = if self.line_is_ascii.get(line).copied().unwrap_or(false) {
2786 u32::try_from(offset - line_start).unwrap_or(u32::MAX)
2787 } else {
2788 // #882: a naive `chars().map(len_utf16).sum()` per call is O(line length); N
2789 // lookups on the same wide line made this O(N x line length). The cached
2790 // per-line index (`with_non_ascii_line_index`) makes repeat lookups O(1).
2791 let line_end = self
2792 .line_starts
2793 .get(line + 1)
2794 .copied()
2795 .unwrap_or(content.len());
2796 let line_text = content.get(line_start..line_end).unwrap_or_default();
2797 self.non_ascii_utf16_units_before(line, line_text, offset - line_start)
2798 };
2799 Position::new(u32::try_from(line).unwrap_or(u32::MAX), character)
2800 }
2801
2802 /// Converts an LSP `Position` back into a byte offset — the inverse of
2803 /// [`byte_offset_to_position`](Self::byte_offset_to_position). Out-of-range lines or
2804 /// UTF-16 characters clamp to `content.len()` rather than panicking, matching the
2805 /// forward conversion's `.min(content.len())` guard.
2806 ///
2807 /// A `character` landing inside a surrogate pair rounds up to the next char boundary, and
2808 /// one past the line's end (including its `\n`) resolves to the line's end — the same
2809 /// results as [`utf16_to_byte_offset`] on the line, but O(1) on an ASCII line and
2810 /// O(log line length) on a non-ASCII one via the cached per-line index (#1711).
2811 #[expect(
2812 clippy::string_slice,
2813 reason = "line_start/line_end come from line_starts (post-newline offsets, always \
2814 char boundaries) or content.len()"
2815 )]
2816 pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
2817 let line0 = position.line as usize;
2818 let Some(&line_start) = self.line_starts.get(line0) else {
2819 return content.len();
2820 };
2821 let line_end = self
2822 .line_starts
2823 .get(line0 + 1)
2824 .copied()
2825 .unwrap_or(content.len());
2826 let line_len = line_end - line_start;
2827 let relative = if self.line_is_ascii.get(line0).copied().unwrap_or(false) {
2828 let character = position.character as usize;
2829 if character <= line_len {
2830 character
2831 } else {
2832 line_len
2833 }
2834 } else {
2835 let line_text = &content[line_start..line_end];
2836 self.with_non_ascii_line_index(line0, line_text, |index| {
2837 let i = index
2838 .entries
2839 .partition_point(|&(_, units)| units < position.character);
2840 index
2841 .entries
2842 .get(i)
2843 .map_or(line_len, |&(byte_offset, _)| byte_offset as usize)
2844 })
2845 };
2846 (line_start + relative).min(content.len())
2847 }
2848}
2849
2850/// Escapes Markdown syntax characters so untrusted text cannot break out of the
2851/// Markdown structure it is embedded in.
2852///
2853/// Applied to manifest-controlled text (dependency names) before it is written into
2854/// hover markdown link labels, and to registry-controlled completion metadata
2855/// (package name/version, description, repository/documentation URLs) before it is
2856/// written into completion-item link labels and link destinations. Every ASCII
2857/// punctuation character is backslash-escaped (CommonMark's full escapable set — not
2858/// just brackets/parens, which would still leave e.g. `<https://evil.example>`
2859/// autolinks live), and control characters (including newlines) plus a narrow set of
2860/// invisible/bidi-override characters (see `is_markdown_unsafe`, #1248) are replaced
2861/// with a space so the text cannot terminate the single-line block it is embedded in,
2862/// splice in new content, or visually spoof the rendered name via a Trojan Source
2863/// (CVE-2021-42574) bidi override.
2864///
2865/// Backslash-escaping is valid in link destinations as well as regular text, so this
2866/// also neutralizes `)`/`]` breakout attempts in a `[label](destination)` URL. It does
2867/// *not* block dangerous URI schemes (e.g. `javascript:`) in a destination — that is a
2868/// separate concern from breaking out of the surrounding Markdown structure.
2869///
2870/// Backslash-escaping does *not* work inside inline code spans (CommonMark §6.1) —
2871/// use [`markdown_code_span`] for text embedded in `` `...` `` instead.
2872///
2873/// # Examples
2874///
2875/// ```
2876/// use deps_core::lsp_helpers::escape_markdown;
2877///
2878/// assert_eq!(escape_markdown("pkg](evil)[pkg"), r"pkg\]\(evil\)\[pkg");
2879/// assert_eq!(escape_markdown("a\nb"), "a b");
2880/// ```
2881pub fn escape_markdown(s: &str) -> String {
2882 let mut escaped = String::with_capacity(s.len());
2883 for c in s.chars() {
2884 if is_markdown_unsafe(c) {
2885 escaped.push(' ');
2886 continue;
2887 }
2888 if c.is_ascii_punctuation() {
2889 escaped.push('\\');
2890 }
2891 escaped.push(c);
2892 }
2893 escaped
2894}
2895
2896/// Predicate for a character that must not survive [`escape_markdown`]/
2897/// [`markdown_code_span`] unescaped (#1248, widened #1323): every Unicode `Cc`/`Cf`/`Zl`/`Zp`
2898/// character except a small, named exempt set with genuine mid-string use in real-world free
2899/// text — RTL/ZWNJ/ZWJ marks and Arabic/Syriac/Kaithi prefixed-format signs (see individual
2900/// `matches!` arms below, and `EXEMPT_CHARS` in
2901/// `test_is_markdown_unsafe_exempt_set_matches_sanitize_invisible_drift_guard` for the exact
2902/// list). Deliberately narrower than [`crate::redact::sanitize_invisible`]'s full sweep for
2903/// that reason; a name/version-shaped caller with no such legitimate-mark concern should
2904/// layer `sanitize_invisible` on top for full parity, as
2905/// `deps_npm::catalog::CatalogOrigin::hover_detail` does (#1266) — this crate's own
2906/// `deprecation.replacement` hover field does not yet (#1311).
2907///
2908/// The drift-guard test above scans the full Unicode code space and enforces that the
2909/// exempt set is exact. Do not widen or narrow this function without updating that test's
2910/// `EXEMPT_CHARS`, and do not fold in `sanitize_invisible`'s RTL/ZWNJ/ZWJ exemption without
2911/// also revisiting #1248.
2912fn is_markdown_unsafe(c: char) -> bool {
2913 c.is_control()
2914 || matches!(c,
2915 '\u{202A}'..='\u{202E}' // LRE RLE PDF LRO RLO
2916 | '\u{2060}'..='\u{206F}' // WORD JOINER, invisible math ops, bidi isolates,
2917 // deprecated format-control block (U+2065 unassigned,
2918 // deliberately included)
2919 | '\u{200B}' // ZWSP
2920 | '\u{2028}' | '\u{2029}' // LS, PS
2921 | '\u{FEFF}' // ZWNBSP / BOM
2922 | '\u{FFF9}'..='\u{FFFB}' // interlinear annotation anchor/separator/terminator
2923 | '\u{E0000}'..='\u{E007F}' // Unicode tag characters
2924 | '\u{00AD}' // SOFT HYPHEN
2925 | '\u{180E}' // MONGOLIAN VOWEL SEPARATOR
2926 | '\u{13430}'..='\u{1343F}' // Egyptian Hieroglyph format controls
2927 | '\u{1BCA0}'..='\u{1BCA3}' // Shorthand format controls
2928 | '\u{1D173}'..='\u{1D17A}' // Musical symbol format controls
2929 )
2930}
2931
2932/// Replaces every [`is_markdown_unsafe`] character in `s` with a single space.
2933///
2934/// Shared by [`markdown_code_span`] and `diagnostics::sanitize_advisory_text_for_diagnostic`
2935/// (#1262 code-review follow-up) so the narrow bidi/invisible-character filter has exactly
2936/// one loop to keep in sync with [`is_markdown_unsafe`]'s policy, instead of two copies that
2937/// could silently drift. Also called from `crate::diagnostic::Diagnostic::new` and
2938/// `crate::diagnostic::RelatedInformation::new` as a defense-in-depth backstop on their
2939/// constructor path (#1276) — `pub(crate)` rather than private for that, but still not
2940/// reachable from outside `deps-core`.
2941///
2942/// Idempotent: `' '` is not itself [`is_markdown_unsafe`], so re-applying this to
2943/// already-sanitized input is a no-op — safe to double-apply through both a producer-side
2944/// call and the constructor-path backstop.
2945///
2946/// Only correct for a text/label sink or an inline code span, where a stray space is
2947/// harmless — **not** for a Markdown link *destination*: per CommonMark, an unbracketed
2948/// `[label](destination)` destination cannot contain a literal space at all, so
2949/// substituting one would turn a fired hazard into a broken (non-)link instead of a
2950/// sanitized one. [`strip_markdown_unsafe_chars`] is the destination-safe sibling.
2951pub(crate) fn replace_markdown_unsafe_chars(s: &str) -> String {
2952 s.chars()
2953 .map(|c| if is_markdown_unsafe(c) { ' ' } else { c })
2954 .collect()
2955}
2956
2957/// Same filter as [`replace_markdown_unsafe_chars`], except `\n` is never replaced.
2958///
2959/// [`is_markdown_unsafe`] starts with `c.is_control()`, which classifies `\n` as unsafe —
2960/// correct for a single-line diagnostic message, but wrong for hover content: a hover card
2961/// is a multi-section Markdown document that relies on structural newlines between
2962/// sections, so running it through [`replace_markdown_unsafe_chars`] verbatim would
2963/// collapse the whole card onto one line. Used as `crate::hover::Hover`'s constructor-path
2964/// sanitization backstop (#1277), the hover equivalent of `Diagnostic::new`'s use of
2965/// [`replace_markdown_unsafe_chars`] (#1276).
2966///
2967/// Idempotent for the same reason [`replace_markdown_unsafe_chars`] is: neither `' '` nor
2968/// `'\n'` is itself [`is_markdown_unsafe`], so re-applying this to already-sanitized input
2969/// (or to input already sanitized by [`replace_markdown_unsafe_chars`], which is strictly
2970/// more aggressive) is a no-op.
2971///
2972/// Same **not destination-safe** caveat as [`replace_markdown_unsafe_chars`], but more load-
2973/// bearing here: this filter runs over the *entire* hover document on every construction and
2974/// mutation, so unlike the sibling's narrow, hand-picked call sites, it always spans any
2975/// Markdown link destination the document happens to contain — a substituted space there
2976/// would break the link (CommonMark forbids a literal space in an unbracketed destination)
2977/// rather than neutralize a hazard. This is not a gap in practice: every hover-rendered URL
2978/// is already stripped (not space-substituted) per-site via
2979/// [`crate::lsp_helpers::strip_markdown_unsafe_chars`] before it reaches this filter (see
2980/// `push_header_hover_section` in `lsp_helpers/hover.rs`), so this document-wide pass finds
2981/// nothing left to fire on a correctly-sanitized destination — but a future call site that
2982/// skips the per-site strip would silently break instead of just risking a hazard.
2983#[cfg(feature = "lsp-responses")]
2984pub(crate) fn replace_markdown_unsafe_chars_keep_newlines(s: &str) -> String {
2985 s.chars()
2986 .map(|c| {
2987 if c != '\n' && is_markdown_unsafe(c) {
2988 ' '
2989 } else {
2990 c
2991 }
2992 })
2993 .collect()
2994}
2995
2996/// Removes every `is_markdown_unsafe` character from `s` outright, rather than
2997/// substituting a space (`replace_markdown_unsafe_chars`'s behavior).
2998///
2999/// Used for a Markdown link *destination* (#1259 critic S3): CommonMark forbids a
3000/// literal, unescaped space inside an unbracketed `[label](destination)` destination,
3001/// so replacing a stripped character with a space there would break the link (render it
3002/// as non-link literal text) instead of sanitizing it in place. Dropping the character
3003/// keeps the surrounding URL syntactically intact — the removed character carried no
3004/// meaningful display information to begin with (it is invisible/bidi-control by
3005/// definition), so there is nothing worth preserving a placeholder for.
3006///
3007/// # Examples
3008///
3009/// ```
3010/// use deps_core::lsp_helpers::strip_markdown_unsafe_chars;
3011///
3012/// assert_eq!(strip_markdown_unsafe_chars("https://example.com/pkg"), "https://example.com/pkg");
3013/// assert_eq!(
3014/// strip_markdown_unsafe_chars("https://example.com/real\u{202E}gnp.sj"),
3015/// "https://example.com/realgnp.sj"
3016/// );
3017/// ```
3018pub fn strip_markdown_unsafe_chars(s: &str) -> String {
3019 s.chars().filter(|c| !is_markdown_unsafe(*c)).collect()
3020}
3021
3022/// Wraps `content` in a Markdown inline code span (backticks included) that safely
3023/// contains arbitrary untrusted text, regardless of embedded backticks.
3024///
3025/// Backslash-escaping does not work inside code spans (CommonMark §6.1), so instead
3026/// this fences with one more backtick than the longest run found in `content`, and
3027/// pads with a single space on each side when `content` starts or ends with a
3028/// backtick or space (required by CommonMark to keep the fence unambiguous). Control
3029/// characters (including newlines) plus the same narrow invisible/bidi-override subset
3030/// `escape_markdown` neutralizes (see `is_markdown_unsafe`, #1248) are replaced with a
3031/// space first, since the raw hover string is otherwise free to merge into an adjacent
3032/// Markdown block or carry a Trojan Source bidi override into the rendered code span.
3033///
3034/// # Examples
3035///
3036/// ```
3037/// use deps_core::lsp_helpers::markdown_code_span;
3038///
3039/// assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
3040/// assert_eq!(markdown_code_span("a`b"), "``a`b``");
3041/// ```
3042pub fn markdown_code_span(content: &str) -> String {
3043 let sanitized = replace_markdown_unsafe_chars(content);
3044
3045 let max_backtick_run = sanitized
3046 .split(|c| c != '`')
3047 .map(str::len)
3048 .max()
3049 .unwrap_or(0);
3050 let fence = "`".repeat(max_backtick_run + 1);
3051
3052 if sanitized.is_empty() {
3053 format!("{fence} {fence}")
3054 } else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
3055 format!("{fence} {sanitized} {fence}")
3056 } else {
3057 format!("{fence}{sanitized}{fence}")
3058 }
3059}
3060
3061/// Checks if two version strings have the same major and minor version.
3062pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
3063 if v1.is_empty() || v2.is_empty() {
3064 return false;
3065 }
3066
3067 let mut parts1 = v1.split('.');
3068 let mut parts2 = v2.split('.');
3069
3070 if parts1.next() != parts2.next() {
3071 return false;
3072 }
3073
3074 match (parts1.next(), parts2.next()) {
3075 (Some(m1), Some(m2)) => m1 == m2,
3076 _ => true,
3077 }
3078}
3079
3080/// The length of the maximal `[a-zA-Z_][a-zA-Z0-9_]*`-shaped identifier starting at `start` in
3081/// `bytes`, or `None` if `bytes[start]` does not start one — the identifier grammar shared by
3082/// shell/envsubst-style variable expansion (`$VAR`, `${VAR}`) and the `@VAR@`/`%VAR%` forms
3083/// below.
3084fn template_placeholder_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
3085 let first = *bytes.get(start)?;
3086 if !(first.is_ascii_alphabetic() || first == b'_') {
3087 return None;
3088 }
3089 let mut end = start + 1;
3090 while bytes
3091 .get(end)
3092 .is_some_and(|&c| c.is_ascii_alphanumeric() || c == b'_')
3093 {
3094 end += 1;
3095 }
3096 Some(end)
3097}
3098
3099/// Like [`template_placeholder_identifier_end`], but also allows `-` (never `.`) in the
3100/// identifier's tail — the `$(...)` Makefile/MSBuild form's own grammar (spec 070, issue
3101/// #1421): MSBuild property names may contain a hyphen in subsequent positions
3102/// (`$(MOD-VERSION)`) but never a period, unlike the `@VAR@` form's
3103/// [`dotted_identifier_end`], which permits both. Deliberately not reused verbatim for
3104/// `$(...)` — doing so would silently admit the dotted `$(A.VERSION)` form this project's
3105/// prevalence research rejected as unsupported by real tooling.
3106fn hyphenated_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
3107 let first = *bytes.get(start)?;
3108 if !(first.is_ascii_alphabetic() || first == b'_') {
3109 return None;
3110 }
3111 let mut end = start + 1;
3112 while bytes
3113 .get(end)
3114 .is_some_and(|&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-'))
3115 {
3116 end += 1;
3117 }
3118 Some(end)
3119}
3120
3121/// Like [`template_placeholder_identifier_end`], but also allows `.`/`-` in the identifier's
3122/// tail (never as the leading character) — the shape a CMake/Autotools `configure_file`
3123/// placeholder needs (`@project.version@`, `@some-flag@`), which is the actual common
3124/// real-world `@..@` form (impl-critic M1, #1379 follow-up): the stricter
3125/// `[a-zA-Z_][a-zA-Z0-9_]*` grammar the `$VAR`/`%VAR%` forms use never matches it, since those
3126/// forms' own real-world identifiers (shell/environment variable names, Windows env vars)
3127/// never contain `.`/`-` to begin with.
3128fn dotted_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
3129 let first = *bytes.get(start)?;
3130 if !(first.is_ascii_alphabetic() || first == b'_') {
3131 return None;
3132 }
3133 let mut end = start + 1;
3134 while bytes
3135 .get(end)
3136 .is_some_and(|&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'-'))
3137 {
3138 end += 1;
3139 }
3140 Some(end)
3141}
3142
3143/// Whether `text` contains a `delim IDENT delim`-shaped placeholder (`@VAR@`/`@project.version@`
3144/// autoconf/CMake `configure_file`, `%VAR%` Windows batch/NSIS), anywhere in `text`. Unlike the
3145/// `$`-prefixed forms, both delimiters are required — `@`/`%` are common enough in ordinary text
3146/// (email addresses, percentages) that a bare opening delimiter alone would be too permissive.
3147fn contains_delimited_identifier_placeholder(
3148 text: &str,
3149 delim: u8,
3150 identifier_end: impl Fn(&[u8], usize) -> Option<usize>,
3151) -> bool {
3152 let bytes = text.as_bytes();
3153 bytes.iter().enumerate().any(|(i, &b)| {
3154 b == delim && identifier_end(bytes, i + 1).is_some_and(|end| bytes.get(end) == Some(&delim))
3155 })
3156}
3157
3158/// Whether `text` contains an `open ... close`-shaped placeholder with non-blank content
3159/// between the delimiters (`{{ VAR }}` Liquid/Jinja2/Mustache/Go `text/template`, `<%= VAR %>`
3160/// ERB/lodash/Yeoman) — content is not restricted to a single identifier, since these grammars
3161/// commonly carry dotted field access (`{{ .NetVersion }}`), filters, or an `=`/`-` modifier
3162/// right after `open`.
3163///
3164/// The closing delimiter is not required (failing safe on an unclosed `{{VAR` — still treating
3165/// it as a placeholder — mirrors the `${VAR` precedent below): only `open` followed by
3166/// whitespace-then-non-blank content is checked, so a stray, immediately-closed `open` (`{{}}`,
3167/// `{{ }}`) is deliberately NOT flagged, matching `${}`'s empty-placeholder carve-out.
3168fn contains_bracketed_placeholder(text: &str, open: &str, close_or_skip: &[u8]) -> bool {
3169 let bytes = text.as_bytes();
3170 text.match_indices(open).any(|(pos, _)| {
3171 let mut i = pos + open.len();
3172 if close_or_skip.contains(bytes.get(i).unwrap_or(&0)) {
3173 i += 1;
3174 }
3175 while bytes.get(i).is_some_and(|b| b.is_ascii_whitespace()) {
3176 i += 1;
3177 }
3178 bytes.get(i).is_some_and(|&b| b != b'}' && b != b'%')
3179 })
3180}
3181
3182/// Whether `requirement` contains an unresolved external-templating placeholder.
3183///
3184/// Recognizes several common generator syntaxes: `$VAR`/`${VAR}` (shell/envsubst), `$(VAR)`
3185/// (Makefile/MSBuild variable reference), `{{ VAR }}` (Liquid/Jinja2/Mustache/Go `text/template`
3186/// expression tags), `{% ... %}` (Jinja2/Liquid statement tags), `@VAR@`/`@project.version@`
3187/// (autoconf/CMake `configure_file`), `%VAR%` (Windows batch/NSIS), or `<%= VAR %>`
3188/// (ERB/lodash/Yeoman templates).
3189///
3190/// Detected anywhere in the text, not just as the whole value: `"${REACT_VERSION}"`, `"$VUE"`,
3191/// `"$(LODASH_VERSION)"`, `"{{ .NetVersion }}"`, `"{% if x %}"`, `"@PACKAGE_VERSION@"`,
3192/// `"%VERSION%"`, `"<%= version %>"`, and an embedded form like `"1.0.0-$BUILD"` are all
3193/// detected.
3194///
3195/// Issues #1374/#1379/#1417: manifests pre-processed by external templating (`envsubst`, CI
3196/// templating, cookiecutter/Yeoman-style generators, `configure_file`, `Makefile`-orchestrated
3197/// codegen) commonly carry one of these shapes in a version-requirement slot. npm, Cargo, Dart,
3198/// Poetry (`[tool.poetry.dependencies]`), Deno and Go have no expansion syntax of their own for
3199/// any of them — unlike Maven's `${property}` or Gradle's `$var`/`${var}`, which their own build
3200/// tools resolve — so this crate can never expand one either, and a requirement containing it
3201/// must never be classified as outdated/unsatisfiable or rewritten to a literal version.
3202///
3203/// The `$`/`{{`/`{%`/`<%` forms do not require a closing delimiter (failing safe on an unclosed
3204/// `${VAR`/`{{VAR`/`{%VAR`/`<%VAR` — still treating it as a placeholder — mirrors the GitLab
3205/// precedent this predicate was originally extracted alongside); `@VAR@`/`%VAR%` require both
3206/// delimiters, since a bare `@`/`%` is too common in ordinary text to treat as an opening
3207/// delimiter alone. `$(VAR)` also requires both delimiters (unlike `${VAR`'s fail-open
3208/// precedent): an unclosed `$(` is far more likely to be a real, unrelated `$` immediately
3209/// followed by literal `(text...` (parenthetical prose, a pasted shell command-substitution
3210/// snippet) than an unterminated Makefile reference (#1417).
3211///
3212/// NuGet's `.csproj`/`.fsproj`/`.vbproj`/`Directory.Packages.props` MSBuild Central Package
3213/// Management references (also `$(VAR)`-shaped, plus `%(VAR)`/`@(VAR)`) are natively recognized
3214/// by that crate's own, independent `is_msbuild_reference` guard — `NuGetFormatter`'s
3215/// `requirement_is_placeholder` ORs this function with that guard (`shared || native`), so this
3216/// function is still reachable and consulted for NuGet, but its own `$(VAR)` extension is not
3217/// load-bearing there: `is_msbuild_reference` already covered NuGet's `$(VAR)` case before this
3218/// function did.
3219///
3220/// The `$(VAR)` identifier grammar additionally accepts `-` in subsequent (never leading)
3221/// positions (`$(MOD-VERSION)`), matching real MSBuild property-name syntax (spec 070, issue
3222/// #1421). A `.` anywhere before the closing `)` (`$(A.VERSION)`) is a deliberate, researched
3223/// exclusion — not valid MSBuild syntax and not a documented real-world convention — and is
3224/// never matched, even in combination with a hyphen (`$(MOD.SUB-VERSION)`).
3225///
3226/// # Examples
3227///
3228/// ```
3229/// use deps_core::lsp_helpers::requirement_contains_template_placeholder;
3230///
3231/// assert!(requirement_contains_template_placeholder("${REACT_VERSION}"));
3232/// assert!(requirement_contains_template_placeholder("$VUE"));
3233/// assert!(requirement_contains_template_placeholder("1.0.0-$BUILD"));
3234/// assert!(requirement_contains_template_placeholder("$(LODASH_VERSION)"));
3235/// assert!(requirement_contains_template_placeholder("1.0.0-$(BUILD_SUFFIX)"));
3236/// assert!(requirement_contains_template_placeholder("$(MOD-VERSION)"));
3237/// assert!(requirement_contains_template_placeholder("1.0.0-$(BUILD-SUFFIX)"));
3238/// assert!(requirement_contains_template_placeholder("{{ .NetVersion }}"));
3239/// assert!(requirement_contains_template_placeholder("{% if x %}"));
3240/// assert!(requirement_contains_template_placeholder("@PACKAGE_VERSION@"));
3241/// assert!(requirement_contains_template_placeholder("@project.version@"));
3242/// assert!(requirement_contains_template_placeholder("%VERSION%"));
3243/// assert!(requirement_contains_template_placeholder("<%= version %>"));
3244/// assert!(!requirement_contains_template_placeholder("1.2.3"));
3245/// assert!(!requirement_contains_template_placeholder("price-is-$5"));
3246/// assert!(!requirement_contains_template_placeholder("price-is-$(five"));
3247/// assert!(!requirement_contains_template_placeholder("me@example.com"));
3248/// assert!(!requirement_contains_template_placeholder("100%"));
3249/// assert!(!requirement_contains_template_placeholder("$(A.VERSION)"));
3250/// assert!(!requirement_contains_template_placeholder("$(MOD.SUB-VERSION)"));
3251/// assert!(!requirement_contains_template_placeholder("$(-VERSION)"));
3252/// ```
3253pub fn requirement_contains_template_placeholder(requirement: &str) -> bool {
3254 let bytes = requirement.as_bytes();
3255 let has_dollar_form = bytes.iter().enumerate().any(|(i, &b)| {
3256 b == b'$'
3257 && match bytes.get(i + 1) {
3258 Some(b'{') => template_placeholder_identifier_end(bytes, i + 2).is_some(),
3259 // #1421: `[a-zA-Z_][a-zA-Z0-9_-]*` — a hyphen is accepted in subsequent
3260 // positions (real MSBuild property-name syntax, `$(MOD-VERSION)`); a dot
3261 // (`$(A.VERSION)`) remains a deliberate, researched exclusion (spec 070).
3262 Some(b'(') => hyphenated_identifier_end(bytes, i + 2)
3263 .is_some_and(|end| bytes.get(end) == Some(&b')')),
3264 _ => template_placeholder_identifier_end(bytes, i + 1).is_some(),
3265 }
3266 });
3267 has_dollar_form
3268 || contains_delimited_identifier_placeholder(requirement, b'@', dotted_identifier_end)
3269 || contains_delimited_identifier_placeholder(
3270 requirement,
3271 b'%',
3272 template_placeholder_identifier_end,
3273 )
3274 || contains_bracketed_placeholder(requirement, "{{", b"")
3275 || contains_bracketed_placeholder(requirement, "{%", b"-")
3276 || contains_bracketed_placeholder(requirement, "<%", b"=-")
3277}
3278
3279/// Result of checking whether a dependency's declared requirement is already satisfied by
3280/// the latest known version.
3281///
3282/// Diagnostics and inlay hints read this result differently: diagnostics only need to know
3283/// whether it is safe to skip the "Newer version available" warning, so both `UpToDate` and
3284/// `Unresolved` suppress it. Inlay hints additionally need to distinguish `Unresolved` from
3285/// `UpToDate`, since an unresolved requirement (e.g. a dangling Gradle version-catalog
3286/// `version.ref` alias, or an unexpanded Maven `${property}`) must not render an "up to
3287/// date" badge that was never actually verified.
3288// Exhaustive: a wildcard arm at any consuming match site would silently render the wrong
3289// label for a new variant instead of failing to compile (#769).
3290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3291pub enum RequirementStatus {
3292 /// The latest version satisfies the declared requirement.
3293 UpToDate,
3294 /// The latest version does not satisfy the declared requirement — a newer version is
3295 /// available.
3296 Outdated,
3297 /// The requirement could not be resolved to a concrete constraint, so no comparison
3298 /// could be made.
3299 Unresolved,
3300}
3301
3302/// A `requirement` compiled by one ecosystem, ready to test candidate versions against.
3303///
3304/// Produced by [`formatter::RequirementResolution::compile_bounded_requirement`]. Kept as a separate object
3305/// (rather than a single "does any version match" function) so the requirement is parsed
3306/// once per dependency, and so the scanning loop — including the empty-list guard, the
3307/// early-exit on first match, and the "skip an unparseable candidate" rule — lives once in
3308/// [`requirement_is_unsatisfiable`] instead of being reimplemented by all eleven ecosystems.
3309///
3310/// # Examples
3311///
3312/// ```
3313/// use deps_core::lsp_helpers::RequirementMatcher;
3314/// use deps_core::ConcreteVersion;
3315///
3316/// struct ExactMatch(String);
3317///
3318/// impl RequirementMatcher for ExactMatch {
3319/// fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
3320/// Some(version.as_str() == self.0)
3321/// }
3322///
3323/// fn strict_prerelease_exclusion(&self) -> bool {
3324/// false
3325/// }
3326/// }
3327///
3328/// let matcher = ExactMatch("1.0.0".to_string());
3329/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
3330/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
3331/// ```
3332pub trait RequirementMatcher: Send + Sync {
3333 /// Tests one candidate version string against the compiled requirement.
3334 ///
3335 /// `Some(true)` / `Some(false)`: this candidate provably does / does not satisfy the
3336 /// requirement. `None`: this candidate *string* could not be parsed by this ecosystem's
3337 /// version format (e.g. a PyPI legacy release identifier, a Maven timestamped snapshot
3338 /// qualifier) — the caller skips it and keeps scanning the rest of the list. Never
3339 /// return `None` to mean "the requirement itself is unusable"; that is
3340 /// [`formatter::RequirementResolution::compile_bounded_requirement`]'s job, via returning `None` from that
3341 /// method instead of constructing a matcher at all.
3342 fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
3343
3344 /// Whether this compiled matcher follows strict SemVer 2.0.0 pre-release semantics: a
3345 /// pre-release version (`X.Y.Z-pre`) is excluded from matching unless the compiled
3346 /// requirement itself pins to the same `X.Y.Z` tuple with a pre-release tag — the rule
3347 /// `semver::VersionReq` and `node_semver::Range` both implement.
3348 ///
3349 /// `matching_prerelease_would_satisfy`'s "requirement is unsatisfiable only because of
3350 /// default pre-release exclusion" enrichment (#299) is gated on this, read from the
3351 /// already-compiled matcher instance rather than from a separate per-formatter flag
3352 /// (#1478) — so a formatter that reuses an existing matcher implementation (e.g.
3353 /// `deps-npm`'s shared `compile_node_semver_range`, also used by `deps-deno`) inherits the
3354 /// correct answer for free; only a formatter introducing a genuinely new matcher type
3355 /// needs to decide this at all, and only in the one place that type is defined.
3356 ///
3357 /// No default: a new matcher type must decide this explicitly rather than silently
3358 /// inheriting `false` by omission (#1478) — the whole point of moving this property off a
3359 /// per-formatter flag and onto the matcher type is that the answer can no longer go
3360 /// unconsidered. Return `true` only for a matcher whose underlying comparator itself
3361 /// implements this exclusion ([`compile_semver_requirement`]'s shared `semver::VersionReq`
3362 /// wrapper, used by `deps-cargo` and `deps-swift`; `deps-npm`'s shared
3363 /// `compile_node_semver_range`/`node_semver::Range` wrapper, used by `deps-npm` and
3364 /// `deps-deno`). Maven/NuGet/Composer/Gradle/PyPI/Go/Bundler/Dart's own matchers use
3365 /// non-strict, ecosystem-specific range models where this premise does not hold — they must
3366 /// return `false`.
3367 ///
3368 /// # Examples
3369 ///
3370 /// ```
3371 /// use deps_core::ConcreteVersion;
3372 /// use deps_core::lsp_helpers::RequirementMatcher;
3373 ///
3374 /// struct NonStrictMatcher;
3375 /// impl RequirementMatcher for NonStrictMatcher {
3376 /// fn matches(&self, _version: &ConcreteVersion) -> Option<bool> {
3377 /// Some(true)
3378 /// }
3379 ///
3380 /// fn strict_prerelease_exclusion(&self) -> bool {
3381 /// false
3382 /// }
3383 /// }
3384 ///
3385 /// assert!(!NonStrictMatcher.strict_prerelease_exclusion());
3386 /// ```
3387 fn strict_prerelease_exclusion(&self) -> bool;
3388
3389 /// Whether this requirement's own grammar punches a hole that individually bans exactly
3390 /// `version`, independent of whether `version` would otherwise fall inside the
3391 /// requirement's nominal range — whether via a literal exclusion term (PyPI's `!=`/`!=X.*`,
3392 /// Composer's `!=`, Bundler's `!=`) or, for a grammar with no such operator, a gap between
3393 /// two segments of a disjoint range union (Maven's `[1.0,1.5),(1.5,2.0)`, which bans exactly
3394 /// `1.5.0`).
3395 ///
3396 /// Fix-cycle (#1571, generalized by #1590): [`crate::lsp_helpers::fallback_edit_excludes_newer`]'s
3397 /// `OriginalExcludesFallback` check needs this as an intensional signal — scanning
3398 /// `available` for "does some newer entry also match" cannot distinguish a punched-out
3399 /// hole from a fallback that legitimately exceeds the requirement's ceiling, since both
3400 /// produce the same `matches(fallback) == Some(false)` result and, when no newer entry
3401 /// happens to be listed in `available` either, the identical "nothing newer matches" scan
3402 /// outcome (critic-reproduced: `>=1.0,!=1.5.0,<2.0` and `>=1.0,<1.5` give the same verdict
3403 /// over `available = [2.0.0, 1.5.0, 1.0.0]` unless the matcher is asked directly).
3404 ///
3405 /// Default `false` — most ecosystems have no such shape (range/caret/tilde bounds only ever
3406 /// exclude by falling outside an interval, never by naming or gapping out one banned value
3407 /// inside it). A matcher overrides this exactly when its grammar CAN express such a hole —
3408 /// via a literal `!=`-style term, or, lacking one, via a gap between disjoint range/union
3409 /// segments — something `matches()` alone cannot surface as "explicitly banned" rather than
3410 /// "merely out of range".
3411 ///
3412 /// # Examples
3413 ///
3414 /// ```
3415 /// use deps_core::ConcreteVersion;
3416 /// use deps_core::lsp_helpers::RequirementMatcher;
3417 ///
3418 /// struct RangeOnlyMatcher;
3419 /// impl RequirementMatcher for RangeOnlyMatcher {
3420 /// fn matches(&self, _version: &ConcreteVersion) -> Option<bool> {
3421 /// Some(true)
3422 /// }
3423 /// fn strict_prerelease_exclusion(&self) -> bool {
3424 /// false
3425 /// }
3426 /// }
3427 ///
3428 /// assert!(!RangeOnlyMatcher.explicitly_excludes(&ConcreteVersion::new("1.5.0")));
3429 /// ```
3430 fn explicitly_excludes(&self, _version: &ConcreteVersion) -> bool {
3431 false
3432 }
3433}
3434
3435/// Parses `version` as [`semver::Version`] and tests it against `req`, `None` on parse failure.
3436/// Shared by [`SemverReqMatcher::matches`] and the test-only `NonStrictSemverMatcher` in
3437/// `diagnostics.rs` (identical logic, differing only in `strict_prerelease_exclusion()`).
3438pub(crate) fn semver_req_matches(
3439 req: &semver::VersionReq,
3440 version: &ConcreteVersion,
3441) -> Option<bool> {
3442 version
3443 .as_str()
3444 .parse::<semver::Version>()
3445 .ok()
3446 .map(|v| req.matches(&v))
3447}
3448
3449/// Precise [`semver::VersionReq`] matcher, constructed only through [`compile_semver_requirement`].
3450/// Named to avoid shadowing the public `crate::version_matcher::SemverMatcher`.
3451struct SemverReqMatcher(semver::VersionReq);
3452
3453impl RequirementMatcher for SemverReqMatcher {
3454 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
3455 semver_req_matches(&self.0, version)
3456 }
3457
3458 /// `semver::VersionReq::matches` excludes pre-releases unless `requirement` itself pins to
3459 /// the same `X.Y.Z` tuple with a pre-release tag — strict SemVer 2.0.0 semantics (#299).
3460 /// Declared once, here, on the matcher type itself: any formatter that reuses
3461 /// [`compile_semver_requirement`] (`deps-cargo`'s `CargoFormatter`, `deps-swift`'s
3462 /// `SwiftFormatter`) inherits this answer for free, with no separate per-formatter flag to
3463 /// keep in sync (#1478, #1495).
3464 fn strict_prerelease_exclusion(&self) -> bool {
3465 true
3466 }
3467}
3468
3469/// Compiles `requirement` as a plain [`semver::VersionReq`], the range grammar Cargo's registry
3470/// and Swift Package Manager's `from:`/closed-range/`upToNextMajor` translations both use for
3471/// matching.
3472///
3473/// The single source of truth for `deps-cargo`'s `CargoFormatter::compile_bounded_requirement` and
3474/// `deps-swift`'s `SwiftFormatter::compile_bounded_requirement` (#1495) — mirroring how #1478 unified
3475/// the analogous `node_semver::Range` case into `deps_npm::compile_node_semver_range`.
3476///
3477/// Unlike that npm/JSR case, this function has no built-in unresolved-placeholder guard: Cargo
3478/// and Swift's [`formatter::RequirementResolution::bounded_requirement_is_unresolved`] overrides
3479/// diverge (Swift additionally rejects its own native `\(...)` string-interpolation syntax), so
3480/// each formatter must run its own `self.bounded_requirement_is_unresolved(requirement)` guard before
3481/// calling this function, rather than the guard being hardcoded here.
3482///
3483/// # Examples
3484///
3485/// ```
3486/// use deps_core::lsp_helpers::{RequirementMatcher, compile_semver_requirement};
3487/// use deps_core::{ConcreteVersion, VersionReq};
3488///
3489/// let matcher = compile_semver_requirement(&VersionReq::new("^1.0.0")).unwrap();
3490/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
3491/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
3492/// assert!(matcher.strict_prerelease_exclusion());
3493///
3494/// assert!(compile_semver_requirement(&VersionReq::new("not a semver req")).is_none());
3495/// ```
3496pub fn compile_semver_requirement(requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
3497 requirement
3498 .as_str()
3499 .parse::<semver::VersionReq>()
3500 .ok()
3501 .map(|req| Box::new(SemverReqMatcher(req)) as Box<dyn RequirementMatcher>)
3502}
3503
3504#[cfg(test)]
3505mod semver_req_matcher_tests {
3506 use super::compile_semver_requirement;
3507 use crate::{ConcreteVersion, VersionReq};
3508
3509 /// #299/#1495: `semver::VersionReq::matches` itself excludes a pre-release candidate unless
3510 /// the requirement pins to that exact `X.Y.Z` tuple with a pre-release tag — this is the
3511 /// actual behavior `strict_prerelease_exclusion() == true` promises callers, not just a flag
3512 /// value. `1.5.0-alpha.1` falls inside `>=1.0.0, <2.0.0`'s stable range but must still be
3513 /// rejected, since the requirement never pins to `1.5.0` specifically.
3514 #[test]
3515 fn matches_rejects_prerelease_candidate_not_pinned_by_requirement() {
3516 let matcher = compile_semver_requirement(&VersionReq::new(">=1.0.0, <2.0.0"))
3517 .expect("valid semver requirement must compile");
3518 assert_eq!(
3519 matcher.matches(&ConcreteVersion::new("1.5.0")),
3520 Some(true),
3521 "a stable candidate in range must match"
3522 );
3523 assert_eq!(
3524 matcher.matches(&ConcreteVersion::new("1.5.0-alpha.1")),
3525 Some(false),
3526 "a pre-release candidate must be rejected unless the requirement pins to its exact \
3527 X.Y.Z tuple with a pre-release tag"
3528 );
3529 }
3530
3531 /// The one documented exception: a requirement that itself pins to the same `X.Y.Z` tuple
3532 /// with a pre-release tag does admit that exact pre-release.
3533 #[test]
3534 fn matches_accepts_prerelease_candidate_pinned_by_requirement() {
3535 let matcher = compile_semver_requirement(&VersionReq::new("=1.5.0-alpha.1"))
3536 .expect("valid semver requirement must compile");
3537 assert_eq!(
3538 matcher.matches(&ConcreteVersion::new("1.5.0-alpha.1")),
3539 Some(true)
3540 );
3541 }
3542}
3543
3544/// Whether `segment` is exactly `.` or `..`.
3545///
3546/// Unlike ordinary path characters, a literal `.`/`..` segment is not neutralized by
3547/// percent-encoding: `.` is an unreserved character (RFC 3986), so `urlencoding::encode`
3548/// leaves it untouched, and the URL parser's dot-segment removal (RFC 3986 §5.2.4) still
3549/// collapses it after encoding — `%2E` decodes back to `.` before that normalization runs.
3550/// A registry-fetch URL built as `{base}/{prefix}/{name}` (no fixed suffix after `name`)
3551/// must reject a `name`/path segment satisfying this predicate rather than encode it,
3552/// since encoding alone does not stop the collapse (#341, #349).
3553///
3554/// Shared by `deps-npm`'s scope/package segment guard and `deps-dart`'s package-name
3555/// guard — both ecosystems' registry APIs key a fetch on a bare, suffix-less path segment.
3556///
3557/// **Scope**: this predicate (and the `#365` regression sweep built around it) guards
3558/// registry-*fetch* URL builders — the sink is a request this process actually
3559/// dereferences, so a retargeted URL can make it fetch attacker-chosen data. It
3560/// deliberately does *not* extend to a "docs link"/`package_url`-style builder (the
3561/// per-ecosystem hover/display link, e.g. `deps_cargo::crate_url`, `deps_go::package_url`):
3562/// those interpolate the name into a link rendered in hover text and never fetched by
3563/// this process, so an unrejected `.`/`..` name there produces at worst a misleading
3564/// same-host link (the registry's package-listing root), not a traversal off-host (#379).
3565///
3566/// # Examples
3567///
3568/// ```
3569/// use deps_core::lsp_helpers::is_dot_segment;
3570///
3571/// assert!(is_dot_segment(".."));
3572/// assert!(is_dot_segment("."));
3573/// assert!(!is_dot_segment("left-pad"));
3574/// ```
3575pub fn is_dot_segment(segment: &str) -> bool {
3576 segment == "." || segment == ".."
3577}
3578
3579/// Whether `version` is safe to embed in a manifest `TextEdit` or completion item.
3580///
3581/// Guards every call into
3582/// [`formatter::PackageRendering::format_version_replacing`]/[`formatter::PackageRendering::format_version_for_text_edit`]
3583/// and every completion item's `insert_text`/`text_edit`.
3584///
3585/// Must be applied to the raw version string *before* formatting, never to a
3586/// formatter's output: some formatters legitimately produce structural
3587/// characters in their output from an already-validated version plus fixed,
3588/// trusted operators (e.g. PyPI's `>=1.2.3,<2`), so validating the output
3589/// would wrongly reject those.
3590///
3591/// An allowlist, not a denylist: `version` must be non-empty, at most 64
3592/// bytes, and contain only `[A-Za-z0-9.+_~:*^!-]` — the character set real
3593/// version strings use across every ecosystem this workspace supports
3594/// (SemVer, PEP 440 including epochs like `1!2.0`, Maven qualifiers, npm's
3595/// `^`/`~`/`*` range tokens, Go's `+incompatible` suffix). A denylist here
3596/// would need to anticipate every dangerous token a target manifest format
3597/// (or a build tool evaluating it, e.g. Gradle's Kotlin/Groovy DSL
3598/// interpolating `${...}` inside a version literal) could ever act on;
3599/// failing closed on an unrecognized character is cheaper and safer.
3600///
3601/// This is the single validation chokepoint shared by every producer of a
3602/// version-derived `TextEdit`/completion item in this workspace, including
3603/// OSV advisory data (an `Advisory.fixed_versions` entry is exactly as
3604/// untrusted as a registry-reported version).
3605///
3606/// # Examples
3607///
3608/// ```
3609/// use deps_core::is_safe_version_string;
3610///
3611/// assert!(is_safe_version_string("1.2.3-alpha.1+build"));
3612/// assert!(!is_safe_version_string("1.2.3\", git = \"https://evil"));
3613/// ```
3614pub fn is_safe_version_string(version: &str) -> bool {
3615 !version.is_empty()
3616 && version.len() <= 64
3617 && version.chars().all(|c| {
3618 c.is_ascii_alphanumeric()
3619 || matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
3620 })
3621}
3622
3623/// Whether `segment` is safe to embed as a Maven `groupId`/`artifactId` value in a
3624/// pom.xml `TextEdit` or completion item.
3625///
3626/// Guards Maven's group/artifact completion producer, which builds a completion item's
3627/// `insert_text`/`text_edit` from one field of a Maven Central search result — a value
3628/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
3629/// why version-derived and non-version-derived sinks each get their own allowlist).
3630///
3631/// An allowlist, not a denylist: `segment` must be non-empty, at most 128 bytes, not
3632/// exactly `.`/`..` (see [`is_dot_segment`]), and contain only `[A-Za-z0-9._-]` — the
3633/// character set real Maven Central group ids (reverse-DNS style, e.g.
3634/// `org.apache.commons`) and artifact ids (hyphen/underscore separated, e.g.
3635/// `commons-lang3`) use. Deliberately excludes `:` — the `groupId:artifactId` separator —
3636/// because this validates one already-split coordinate field at a time, never the joined
3637/// pair. Failing closed on an unrecognized character (e.g. `<`, `"`, a newline) keeps a
3638/// malicious/compromised search result from restructuring the pom.xml it's inserted into;
3639/// the dedicated `.`/`..` rejection closes the same dot-segment URL-normalization gap
3640/// [`is_dot_segment`] guards elsewhere (`artifactId` reaches a registry-fetch URL as a bare
3641/// path segment in `deps-maven::registry::metadata_urls`, unlike `groupId`, whose `.`→`/`
3642/// expansion can never itself produce a literal `..` component).
3643///
3644/// # Examples
3645///
3646/// ```
3647/// use deps_core::is_safe_maven_coordinate_segment;
3648///
3649/// assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
3650/// assert!(is_safe_maven_coordinate_segment("commons-lang3"));
3651/// assert!(!is_safe_maven_coordinate_segment("commons</artifactId><parent>"));
3652/// assert!(!is_safe_maven_coordinate_segment(".."));
3653/// ```
3654pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
3655 !segment.is_empty()
3656 && segment.len() <= 128
3657 && !is_dot_segment(segment)
3658 && segment
3659 .chars()
3660 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
3661}
3662
3663/// Builds the Maven repository URL path (`"group/path/segments/artifact"`) for a
3664/// `groupId`/`artifactId` coordinate pair, or `None` if either fails validation.
3665///
3666/// The single shared home for `groupId:artifactId` -> URL-path construction (#702):
3667/// validates `group_id` and `artifact_id` as whole strings via
3668/// [`is_safe_maven_coordinate_segment`] (preserving its 128-byte total-length cap per
3669/// segment) *and* validates every `.`-separated component of `group_id` individually,
3670/// rejecting an empty component outright. The whole-string check alone is not enough —
3671/// it permits internal `.` characters, so a naive `group_id.replace('.', "/")` on a
3672/// coordinate like `"com..evil"` would produce the empty path segment `"com//evil"`; the
3673/// per-component check closes that gap without dropping the whole-string length bound a
3674/// pure per-segment `split('.')` validation would lose (a 10-segment, 128-byte-each group
3675/// would otherwise pass at 1280 bytes total).
3676///
3677/// # Examples
3678///
3679/// ```
3680/// use deps_core::maven_coordinate_path;
3681///
3682/// assert_eq!(
3683/// maven_coordinate_path("com.example", "artifact").as_deref(),
3684/// Some("com/example/artifact")
3685/// );
3686/// assert_eq!(
3687/// maven_coordinate_path("junit", "junit").as_deref(),
3688/// Some("junit/junit")
3689/// );
3690/// assert_eq!(maven_coordinate_path("com..evil", "artifact"), None);
3691/// ```
3692pub fn maven_coordinate_path(group_id: &str, artifact_id: &str) -> Option<String> {
3693 if !is_safe_maven_coordinate_segment(group_id) || !is_safe_maven_coordinate_segment(artifact_id)
3694 {
3695 return None;
3696 }
3697 let group_segments: Vec<&str> = group_id.split('.').collect();
3698 if group_segments
3699 .iter()
3700 .any(|segment| !is_safe_maven_coordinate_segment(segment))
3701 {
3702 return None;
3703 }
3704 Some(format!("{}/{artifact_id}", group_segments.join("/")))
3705}
3706
3707/// Whether `url` is safe to embed as a Swift Package Manager repository URL in a
3708/// Package.swift `TextEdit` or completion item.
3709///
3710/// Guards Swift's URL-completion producer, which builds a `.package(url: "...")`
3711/// string-literal replacement from a package registry search result's URL — a value
3712/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
3713/// why version-derived and non-version-derived sinks each get their own allowlist).
3714///
3715/// An allowlist, not a denylist: `url` must be non-empty, at most 2048 bytes, start with
3716/// `https://` — every real Swift package registry response is HTTPS (GitHub's `html_url`
3717/// never downgrades), so accepting plain `http://` would only hand a
3718/// compromised/malicious registry a transport-downgrade lever for zero legitimate
3719/// benefit — and otherwise contain only RFC 3986 URL characters (`A-Za-z0-9` plus
3720/// `` -._~:/?#[]@!$&'()*+,;=% ``). Deliberately excludes `"`, `\`, control characters,
3721/// and whitespace — none of those are valid unencoded URL characters, and any of them
3722/// could close the surrounding Swift string literal or otherwise corrupt the manifest.
3723/// Failing closed on an unrecognized character keeps a malicious/compromised search
3724/// result from breaking out of the string it's inserted into.
3725///
3726/// # Examples
3727///
3728/// ```
3729/// use deps_core::is_safe_registry_url;
3730///
3731/// assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
3732/// assert!(!is_safe_registry_url("https://evil.example\", .exact(\"1\")) // "));
3733/// ```
3734pub fn is_safe_registry_url(url: &str) -> bool {
3735 !url.is_empty()
3736 && url.len() <= 2048
3737 && url.starts_with("https://")
3738 && url.chars().all(|c| {
3739 c.is_ascii_alphanumeric()
3740 || matches!(
3741 c,
3742 '-' | '.'
3743 | '_'
3744 | '~'
3745 | ':'
3746 | '/'
3747 | '?'
3748 | '#'
3749 | '['
3750 | ']'
3751 | '@'
3752 | '!'
3753 | '$'
3754 | '&'
3755 | '\''
3756 | '('
3757 | ')'
3758 | '*'
3759 | '+'
3760 | ','
3761 | ';'
3762 | '='
3763 | '%'
3764 )
3765 })
3766}
3767
3768/// Whether `name` is safe to embed as a package name in a manifest `TextEdit` or
3769/// completion item.
3770///
3771/// Guards every arm of `create_package_completion_item`
3772/// (`crates/deps-lsp/src/handlers/completion.rs`) as a single upfront check, applied
3773/// before the raw `name` reaches any ecosystem-specific snippet — including Maven and
3774/// Swift, which additionally validate a *derived* value on top of this gate
3775/// ([`is_safe_maven_coordinate_segment`] on each split coordinate segment,
3776/// [`is_safe_registry_url`] on the constructed URL) because a value type distinct from
3777/// the raw name needs its own allowlist — see [`is_safe_version_string`]'s doc comment
3778/// for why version-derived and non-version-derived sinks each get their own allowlist.
3779/// [`PackageName::new`](crate::PackageName::new) is documented as never validating or
3780/// modifying its input, so this predicate is the first gate a registry-reported name
3781/// passes through before reaching a manifest. Two sinks that key a bare TOML/YAML
3782/// entry by `name` (Cargo/PyPI, Dart) additionally quote that key in the snippet, since
3783/// `.` and `@` are legal here but would otherwise be read as TOML's dotted-key
3784/// separator or break a YAML plain scalar.
3785///
3786/// An allowlist, not a denylist: `name` must be non-empty, at most 256 bytes, and
3787/// contain only `[A-Za-z0-9._@:/~-]` — the character set real package names use across
3788/// every ecosystem this predicate guards: Cargo/PyPI/Dart/NuGet/Bundler
3789/// (alphanumeric, `-`, `_`, `.`), npm/Deno scoped names (`@scope/name`, adding `@` and
3790/// `/`), Composer (`vendor/package`, `/`), Go module paths (domain-qualified paths like
3791/// `github.com/org/repo`, `/`, `.`, and `~` — legal in a Go path element and already
3792/// allowed by [`is_safe_version_string`]/[`is_safe_registry_url`]), and Gradle's
3793/// colon-delimited `group:artifact` short form (`:`). A denylist here would need to
3794/// anticipate every dangerous token a target manifest format (TOML/JSON/YAML/XML
3795/// string literals, a live Kotlin/Groovy build-script DSL) could ever act on; failing
3796/// closed on an unrecognized character — notably `"`, `'`, `<`, `>`, `` ` ``, and all
3797/// control characters/newlines — is cheaper and safer.
3798///
3799/// # Examples
3800///
3801/// ```
3802/// use deps_core::is_safe_package_name;
3803///
3804/// assert!(is_safe_package_name("serde"));
3805/// assert!(is_safe_package_name("@scope/name"));
3806/// assert!(is_safe_package_name("org.apache.commons:commons-lang3"));
3807/// assert!(!is_safe_package_name("evil\"\nbackdoor = \"9.9.9"));
3808/// ```
3809pub fn is_safe_package_name(name: &str) -> bool {
3810 !name.is_empty()
3811 && name.len() <= 256
3812 && name.chars().all(|c| {
3813 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
3814 })
3815}
3816
3817/// Whether `name` is safe to embed as a Cargo feature name in a manifest `TextEdit` or
3818/// completion item.
3819///
3820/// Guards [`crate::completion::build_feature_completion`] the same way
3821/// [`is_safe_package_name`] guards [`crate::completion::build_package_completion`]:
3822/// `feature_name` there is registry-supplied (a key of `Version::features()`, read from
3823/// the index JSON's `features`/`features2` maps), not user-typed, so it must pass this
3824/// allowlist before reaching `label`/`insert_text`/`text_edit`/`sort_text`.
3825///
3826/// An allowlist, narrower than [`is_safe_package_name`]: Cargo's own feature-name grammar
3827/// (<https://doc.rust-lang.org/cargo/reference/features.html#the-features-section>) permits
3828/// Unicode `XID_Start`/`XID_Continue` characters, but crates.io's publish-time check — the
3829/// registry `deps-cargo` actually talks to — is stricter and only accepts ASCII
3830/// alphanumerics, `_`, `-`, and `+`; this predicate matches that stricter, ASCII-only set
3831/// (plus `.`, this predicate's own addition, not part of crates.io's check, allowed because
3832/// it's already permitted in [`is_safe_package_name`] and carries the same low risk here).
3833/// `dep:`, `?`, and `/` are never part of a feature *name* (an index JSON map key) either
3834/// way — they only ever appear inside a feature's *value* list (e.g.
3835/// `"avif" = ["dep:ravif", "rgb?/serde"]`), which this predicate never sees. A
3836/// registry-supplied string containing any of those characters is therefore not a
3837/// plausible feature name regardless of intent, so it is rejected the same as any other
3838/// out-of-allowlist character.
3839///
3840/// Tradeoff, accepted rather than incidental: a legitimate feature name published with a
3841/// Unicode `XID` character to an *alternate* (non-crates.io) registry — which Cargo's own
3842/// grammar permits but crates.io's check does not — would fail this allowlist and be
3843/// silently dropped rather than rendered. Failing closed on a character class this
3844/// predicate cannot cheaply distinguish from a homograph/bidi-spoofing attempt is judged
3845/// safer than accepting the full Cargo grammar, consistent with [`is_safe_package_name`]'s
3846/// own ASCII-only rationale.
3847///
3848/// # Examples
3849///
3850/// ```
3851/// use deps_core::is_safe_feature_name;
3852///
3853/// assert!(is_safe_feature_name("derive"));
3854/// assert!(is_safe_feature_name("std_alloc-v2+extra"));
3855/// assert!(!is_safe_feature_name("dep:ravif"));
3856/// assert!(!is_safe_feature_name("rgb?/serde"));
3857/// assert!(!is_safe_feature_name("evil\"\nbackdoor = \"9.9.9"));
3858/// ```
3859pub fn is_safe_feature_name(name: &str) -> bool {
3860 !name.is_empty()
3861 && name.len() <= 256
3862 && name
3863 .chars()
3864 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
3865}
3866
3867/// Logs a `tracing::warn!` for a value rejected by an `is_safe_*` predicate (or an
3868/// equivalent value-rejecting gate) before it reaches a manifest edit or registry URL.
3869///
3870/// Deliberately logs only `value`'s byte length, never its content: `value` is
3871/// registry-controlled and, by construction, already failed an allowlist — logging it
3872/// verbatim at `warn` would let a malicious/compromised registry response inject arbitrary
3873/// content into this project's own log stream (a second-order log-injection concern),
3874/// mirroring why `deps-pypi`'s `truncate_for_log` bounds a logged excerpt instead of
3875/// logging a value verbatim. This is this helper's own contract, not a claim that every
3876/// `tracing` call site in the workspace avoids logging a raw value — e.g. an OSV
3877/// malformed-`fixed`-version warning predates this helper and logs its rejected value
3878/// directly; that is an unrelated call site, not a place this helper is used.
3879///
3880/// `gate` names the predicate/guard that rejected `value` (e.g.
3881/// `"is_safe_maven_coordinate_segment"`); `context` is a short description of the call site
3882/// (e.g. `"maven groupId completion"`).
3883///
3884/// # Examples
3885///
3886/// ```
3887/// use deps_core::lsp_helpers::warn_rejected_value;
3888///
3889/// warn_rejected_value("is_safe_version_string", "code lens latest version", "1.0.0\"; evil");
3890/// ```
3891pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
3892 tracing::warn!(
3893 gate,
3894 context,
3895 len = value.len(),
3896 "rejected unsafe value before manifest/registry sink"
3897 );
3898}
3899
3900/// Logs a [`warn_rejected_value`] warning and builds the [`DepsError::PackageNotFound`] a
3901/// caller returns after its own `is_dot_segment`/`has_dot_segment` predicate rejects `name`.
3902///
3903/// Extracted from four equivalent `warn_rejected_value` + `PackageNotFound`
3904/// constructions in `deps-dart`, `deps-composer`, `deps-nuget`, and `deps-npm`
3905/// (deps-lsp#929 item 2) — `deps-npm`'s `gate` label was `"npm_dot_segment_guard"` rather
3906/// than the other three's `"is_dot_segment"` before this extraction normalized it. Each
3907/// crate keeps its own dot-segment *predicate* and the call to it exactly where it is
3908/// today; only this trailing boilerplate is shared. Deliberately does **not** fold in
3909/// `deps-go`'s guard, which returns `DepsError::InvalidPackageName`, not `PackageNotFound`.
3910///
3911/// # Examples
3912///
3913/// ```
3914/// use deps_core::lsp_helpers::dot_segment_rejection_error;
3915/// use deps_core::DepsError;
3916///
3917/// let err = dot_segment_rejection_error(
3918/// "is_dot_segment",
3919/// "example package metadata request URL",
3920/// "..",
3921/// "example",
3922/// );
3923/// assert!(matches!(err, DepsError::PackageNotFound { .. }));
3924/// ```
3925#[must_use]
3926pub fn dot_segment_rejection_error(
3927 gate: &str,
3928 context: &str,
3929 name: &str,
3930 registry: &'static str,
3931) -> DepsError {
3932 warn_rejected_value(gate, context, name);
3933 DepsError::PackageNotFound {
3934 package: name.into(),
3935 registry,
3936 }
3937}
3938
3939/// Converts a domain [`url::Url`] back into a `tower_lsp_server::ls_types::Uri` for
3940/// embedding into an actual LSP response object (`WorkspaceEdit`, `Location`, a code-lens
3941/// command argument, ...).
3942///
3943/// This is the one blessed conversion path from `deps-core`'s internal `url::Url` domain
3944/// type to `tower_lsp_server`'s `Uri` — ecosystem crates and `deps-core` itself must route
3945/// through it rather than re-deriving the conversion (e.g. `uri.as_str().parse()`) locally,
3946/// so a future change to how URLs round-trip through the LSP wire format only needs fixing
3947/// in one place. It mirrors `deps-lsp`'s own `lsp_types_interop::to_lsp_uri` (issue #1071):
3948/// `deps-core` cannot reuse that function (the dependency direction points the other way)
3949/// or express this as a `From` impl (neither `url::Url` nor `ls_types::Uri` is local to
3950/// `deps-core` — the same orphan-rule constraint that makes the `deps-lsp` boundary a pair
3951/// of free functions instead of a trait impl), so this is `deps-core`'s independent
3952/// counterpart, needed because `lsp_helpers` still builds real `ls_types` response objects
3953/// directly even though `Dependency`/`ParseResult` no longer carry an `ls_types::Uri` (see
3954/// `lib.rs`'s "LSP type stability" doc section).
3955///
3956/// # Panics
3957///
3958/// Panics if `url`'s string form does not round-trip into an `Uri`. In practice this
3959/// never happens: every `url::Url` reaching `lsp_helpers` originates from a real
3960/// editor-opened document (an `ls_types::Uri` converted to `Url` at the `deps-lsp`
3961/// boundary), so failing to convert it back indicates upstream corruption, not
3962/// attacker-controlled manifest content.
3963///
3964/// # Examples
3965///
3966/// ```
3967/// use deps_core::to_ls_uri;
3968/// use url::Url;
3969///
3970/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
3971/// let uri = to_ls_uri(&url);
3972/// assert_eq!(uri.as_str(), "file:///tmp/Cargo.toml");
3973/// ```
3974#[cfg(feature = "lsp-responses")]
3975#[must_use]
3976pub fn to_ls_uri(url: &url::Url) -> tower_lsp_server::ls_types::Uri {
3977 url.as_str()
3978 .parse()
3979 .unwrap_or_else(|e| panic!("document URL {url} did not round-trip to an LSP Uri: {e}"))
3980}
3981
3982/// Builds a single-entry [`tower_lsp_server::ls_types::WorkspaceEdit::changes`] map replacing `range` in `uri`
3983/// with `new_text`.
3984///
3985/// Shared by every quickfix/refactor code action in `code_actions` that edits exactly
3986/// one span in the current document (`build_vulnerability_fix_action`,
3987/// `build_unsatisfiable_fix_action`, and the plain "update to `<version>`" loop in
3988/// [`generate_code_actions`]) — and by any ecosystem crate hand-building a
3989/// `WorkspaceEdit` for a single-span fix instead of reimplementing this map shape
3990/// locally.
3991///
3992/// # Panics
3993///
3994/// Panics if `uri` does not round-trip through [`to_ls_uri`] — see that function's own
3995/// `# Panics` section.
3996///
3997/// # Examples
3998///
3999/// ```
4000/// use deps_core::single_file_edit;
4001/// use deps_core::position::{Position, Range};
4002/// use url::Url;
4003///
4004/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
4005/// let range = Range::new(Position::new(0, 0), Position::new(0, 5));
4006/// let edits = single_file_edit(&url, range, "1.2.3".to_string());
4007///
4008/// assert_eq!(edits.len(), 1);
4009/// ```
4010#[cfg(feature = "lsp-responses")]
4011#[must_use]
4012pub fn single_file_edit(
4013 uri: &url::Url,
4014 range: Range,
4015 new_text: String,
4016) -> HashMap<tower_lsp_server::ls_types::Uri, Vec<tower_lsp_server::ls_types::TextEdit>> {
4017 let mut edits = HashMap::new();
4018 edits.insert(
4019 to_ls_uri(uri),
4020 vec![tower_lsp_server::ls_types::TextEdit {
4021 range: range.into(),
4022 new_text,
4023 }],
4024 );
4025 edits
4026}
4027
4028/// Strips every whitespace character from `s`, so two textually-equivalent strings that
4029/// differ only in spacing compare equal.
4030///
4031/// Shared by every no-op/literal-match guard across `code_actions` and `code_lenses`
4032/// (`build_vulnerability_fix_action`'s N1 guard, `generate_code_actions`'s REFACTOR-loop
4033/// guard, `literal_span_matches`, and `collect_update_all_edits`'s no-op guard), all of
4034/// which compare a declared requirement
4035/// string against a differently-normalized counterpart — e.g. pep508's `>=1.7, <2.0` vs. a
4036/// formatter's `>=1.7,<2.0`.
4037pub(crate) fn strip_whitespace(s: &str) -> String {
4038 s.chars().filter(|c| !c.is_whitespace()).collect()
4039}
4040
4041/// Slices `content` over an LSP `Range` using a pre-built `LineOffsetTable`, returning
4042/// `""` for an inverted or out-of-bounds range instead of panicking.
4043///
4044/// `table` is document-invariant — callers iterating over multiple dependencies in the
4045/// same document must build it once and reuse it, rather than rebuilding it (an O(n)
4046/// scan of `content`) per dependency.
4047pub(crate) fn slice_for_range<'a>(
4048 content: &'a str,
4049 table: &LineOffsetTable,
4050 range: Range,
4051) -> &'a str {
4052 let start = table.position_to_byte_offset(content, range.start);
4053 let end = table.position_to_byte_offset(content, range.end);
4054 if start > end {
4055 return "";
4056 }
4057 content.get(start..end).unwrap_or("")
4058}
4059
4060/// Checks whether `slice` — `content` sliced over a dependency's `version_range` — still
4061/// holds the literal version text declared by `requirement`.
4062///
4063/// Whitespace is stripped from both sides before comparison, since pep508's normalized
4064/// requirement string can diverge from the original source spacing (PyPI's `>=1.7,<2.0`
4065/// renders as `>=1.7, <2.0`) while `version_range` still spans the un-normalized source.
4066///
4067/// The second branch accepts `slice` wrapped in brackets matching `requirement` — the
4068/// exact inverse of NuGet's parser wrapping a bare source version as `format!("[{v}]")`
4069/// (`crates/deps-nuget/src/parser.rs`). This is deliberately **not** a symmetric bracket
4070/// strip: NuGet's `Version="1.0.0"` produces requirement `[1.0.0]` over a bare-literal
4071/// span, so a *symmetric* strip (stripping one bracket pair from both operands) would
4072/// compare `1.0.0` against `1.0.0` — coincidentally correct there, but the same strip
4073/// applied to `Version="[1.0.0]"` (requirement `[[1.0.0]]`, a spelling
4074/// `crates/deps-nuget/src/formatter.rs` explicitly supports) leaves `[1.0.0]` vs
4075/// `1.0.0` and **falsely rejects** an editable dependency. Wrapping only the slice side
4076/// handles both spellings without that false reject.
4077pub(crate) fn literal_span_matches(slice: &str, requirement: &str) -> bool {
4078 let norm_slice = strip_whitespace(slice);
4079 let norm_req = strip_whitespace(requirement);
4080 norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
4081}
4082
4083/// Whether `version_range`'s slice of `content` still holds `dep`'s own declared literal
4084/// version text.
4085///
4086/// The same literal-span discipline [`generate_code_actions`] and
4087/// `collect_update_all_edits` already apply before writing a `TextEdit` there, generalized
4088/// (issue #919) for any caller about to treat `version_range` as an editable literal: a
4089/// completion context, or a raw-text-scanning ecosystem's own completion dispatch (Maven,
4090/// Gradle) that never goes through [`crate::completion::detect_completion_context`] at all.
4091///
4092/// Returns `false` — not editable — when the slice is syntactically reference-shaped (see
4093/// below), or the slice doesn't textually match the declared literal. When `dep` has no
4094/// [`Dependency::version_requirement`] at all, an empty (or whitespace-only) slice is admitted
4095/// — e.g. Maven's `<version></version>`, which has no text for its parser to capture and thus
4096/// nothing that could be misread as a reference/wildcard (#1161) — but a *non-empty* slice is
4097/// still rejected, since there is then no requirement to validate it against.
4098///
4099/// The reference-shape check runs **independently** of the text comparison (#919 C1): an
4100/// *unresolved* Maven `${property}` or Gradle `$var`/`${var}` interpolation is left by its
4101/// parser exactly as-is in both `version_range`'s slice and `version_requirement` (there is
4102/// nothing else to put there), so the two would otherwise textually agree and wrongly pass —
4103/// this is precisely the corruption #919 was filed over, and the dominant real-world Maven
4104/// case (a version inherited from a parent/BOM POM this crate cannot resolve) always lands
4105/// here. A slice containing `$` anywhere is rejected up front, before ever comparing text —
4106/// this single check subsumes both a whole-value `$var`/`${var}` interpolation and a Gradle
4107/// GString with the reference embedded mid-string (`1.0.$patch`), which `resolve_variable_ref`
4108/// only ever resolves in its whole-value form, leaving the mid-string case byte-identical
4109/// between slice and requirement and otherwise invisible to a `starts_with`-only check. A
4110/// slice that starts with `*` is rejected the same way *unless* [`crate::is_existence_wildcard_str`]
4111/// recognizes it as the project-wide existence-wildcard spelling (synthesized by `deps-npm`,
4112/// `deps-composer`, ...) — that case must keep offering a full version list at the exact
4113/// moment a user wants to replace the wildcard with a pin, while a `*anchor` YAML alias (any
4114/// other leading-`*` shape) still gets rejected.
4115///
4116/// An empty `version_requirement` is deliberately **not** special-cased here (unlike
4117/// `generate_code_actions`'s "nothing to update" early return, which does not apply to
4118/// completion): `serde = ""` / `"lodash": ""` — what an editor's auto-closing quotes produce
4119/// the instant the opening quote is typed — must still offer the full version list.
4120/// `literal_span_matches` alone already handles this correctly: `("", "")` matches (empty
4121/// slice, empty requirement — admitted), while a non-empty non-literal slice against an empty
4122/// requirement still fails to match (rejected).
4123///
4124/// Compares against [`Dependency::version_literal`] when the ecosystem provides one, falling
4125/// back to `version_requirement` otherwise (see that method's doc for why).
4126///
4127/// Builds its own [`LineOffsetTable`] for `content` on every call rather than accepting a
4128/// caller-built one — unlike `slice_for_range`'s multi-dependency callers (`generate_code_actions`,
4129/// `code_lenses`), every current call site invokes this at most once per request (a single
4130/// matched dependency), so a table-accepting overload would add API surface with no caller to
4131/// exercise it.
4132///
4133/// # Examples
4134///
4135/// ```
4136/// use deps_core::lsp_helpers::dependency_version_range_is_literal;
4137/// use deps_core::{Dependency, PackageName, VersionReq};
4138/// use std::any::Any;
4139/// use tower_lsp_server::ls_types::{Position, Range};
4140///
4141/// struct MockDep {
4142/// name: PackageName,
4143/// version_req: VersionReq,
4144/// version_range: deps_core::position::Range,
4145/// }
4146/// impl Dependency for MockDep {
4147/// fn name(&self) -> &PackageName {
4148/// &self.name
4149/// }
4150/// fn name_range(&self) -> deps_core::position::Range {
4151/// deps_core::position::Range::default()
4152/// }
4153/// fn version_requirement(&self) -> Option<&VersionReq> {
4154/// Some(&self.version_req)
4155/// }
4156/// fn version_range(&self) -> Option<deps_core::position::Range> {
4157/// Some(self.version_range)
4158/// }
4159/// fn source(&self) -> deps_core::parser::DependencySource {
4160/// deps_core::parser::DependencySource::Registry
4161/// }
4162/// fn as_any(&self) -> &dyn Any {
4163/// self
4164/// }
4165/// }
4166///
4167/// // A plain literal version is admitted.
4168/// let content = r#"serde = "1.0.0""#;
4169/// let dep = MockDep {
4170/// name: PackageName::new("serde"),
4171/// version_req: VersionReq::new("1.0.0"),
4172/// version_range: Range::new(Position::new(0, 9), Position::new(0, 14)).into(),
4173/// };
4174/// assert!(dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
4175///
4176/// // A Maven `${property}` interpolation is rejected even when unresolved — slice and
4177/// // requirement are byte-identical raw reference text, which the shape check catches
4178/// // independently of the (otherwise-matching) text comparison.
4179/// let content = r"<version>${slf4j.version}</version>";
4180/// let dep = MockDep {
4181/// name: PackageName::new("slf4j-api"),
4182/// version_req: VersionReq::new("${slf4j.version}"),
4183/// version_range: Range::new(Position::new(0, 9), Position::new(0, 25)).into(),
4184/// };
4185/// assert!(!dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
4186/// ```
4187#[must_use]
4188pub fn dependency_version_range_is_literal(
4189 dep: &dyn Dependency,
4190 content: &str,
4191 version_range: Range,
4192) -> bool {
4193 if version_range == Range::default() {
4194 // `Range::default()` ((0,0)-(0,0)) is a purely syntactic "not a real position"
4195 // sentinel, never a genuine in-document location — `deps-gradle`'s catalog context
4196 // deliberately returns it for a `version.ref = "alias"` reference (#931), including a
4197 // dangling or still-being-typed one, so this function must always reject it
4198 // unconditionally, before the `version_requirement()` check below ever runs.
4199 return false;
4200 }
4201
4202 let table = LineOffsetTable::new(content);
4203 let slice = slice_for_range(content, &table, version_range);
4204 let trimmed = slice.trim();
4205
4206 let Some(version_req) = dep.version_requirement() else {
4207 // No parsed requirement at all — e.g. Maven's `<version></version>`, which has no
4208 // text for its parser to capture. An empty span here has no existing text that could
4209 // be misread as a reference/wildcard, so it is always safe to offer completion
4210 // (#1161) — now that the `Range::default()` sentinel above is rejected first, this can
4211 // only be a genuine in-document position.
4212 return trimmed.is_empty();
4213 };
4214 if trimmed.contains('$')
4215 || (trimmed.starts_with('*') && !crate::is_existence_wildcard_str(trimmed))
4216 {
4217 return false;
4218 }
4219 let literal_target = dep
4220 .version_literal()
4221 .unwrap_or_else(|| version_req.as_str());
4222 literal_span_matches(slice, literal_target)
4223}
4224
4225#[cfg(test)]
4226#[expect(
4227 clippy::string_slice,
4228 reason = "fixtures are single-line ASCII literals with hand-computed byte offsets"
4229)]
4230mod tests {
4231 use super::*;
4232 use crate::lsp_helpers::test_support::*;
4233 use crate::osv::SkipReason;
4234 use crate::{DependencySource, PackageName, VersionReq};
4235
4236 // --- gossip_cooldown_for (issue #1456, spec 072 FR-008/FR-011, S2 tri-state) ---
4237
4238 fn gossip_findings_fixture(version: &str, end_unix_secs: i64) -> GossipFindings {
4239 GossipFindings {
4240 version: version.to_string(),
4241 cooldown: Some(crate::GossipCooldown {
4242 end: PublishTime::from_unix_secs(end_unix_secs),
4243 risk: crate::GossipRiskLevel::High,
4244 }),
4245 low_usage: None,
4246 }
4247 }
4248
4249 fn gossip_findings_fixture_not_in_cooldown(version: &str) -> GossipFindings {
4250 GossipFindings {
4251 version: version.to_string(),
4252 cooldown: None,
4253 low_usage: None,
4254 }
4255 }
4256
4257 #[test]
4258 fn gossip_cooldown_for_none_prefetch_is_unavailable() {
4259 let name = PackageName::new("vite");
4260 assert!(matches!(
4261 gossip_cooldown_for(None, &name, "8.3.1", PublishTime::now()),
4262 GossipCooldownLookup::Unavailable
4263 ));
4264 }
4265
4266 #[test]
4267 fn gossip_cooldown_for_matching_active_version_is_active() {
4268 let name = PackageName::new("vite");
4269 let mut prefetch = HashMap::new();
4270 prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 2_000));
4271
4272 let lookup = gossip_cooldown_for(
4273 Some(&prefetch),
4274 &name,
4275 "8.3.1",
4276 PublishTime::from_unix_secs(1_000),
4277 );
4278 assert_eq!(lookup, GossipCooldownLookup::Active);
4279 }
4280
4281 #[test]
4282 fn gossip_cooldown_for_version_mismatch_is_unavailable() {
4283 let name = PackageName::new("vite");
4284 let mut prefetch = HashMap::new();
4285 prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 2_000));
4286
4287 // FR-008: the prefetch entry is for 8.3.1, but the caller is displaying 8.4.0 (a
4288 // newer release the prefetch hasn't caught up with yet) — must be `Unavailable`,
4289 // not the stale 8.3.1 answer.
4290 let lookup = gossip_cooldown_for(
4291 Some(&prefetch),
4292 &name,
4293 "8.4.0",
4294 PublishTime::from_unix_secs(1_000),
4295 );
4296 assert!(matches!(lookup, GossipCooldownLookup::Unavailable));
4297 }
4298
4299 #[test]
4300 fn gossip_cooldown_for_ended_cooldown_self_clears_to_not_active_at_read_time() {
4301 let name = PackageName::new("vite");
4302 let mut prefetch = HashMap::new();
4303 prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 1_000));
4304
4305 // FR-011: `end` compared against `now` at read time, never a stored bool — a `now`
4306 // past `end` must self-clear to `NotActive` (GOSSIP data is present and version-
4307 // matched, it just no longer reports an active cooldown), never `Unavailable`
4308 // (which would wrongly let the caller fall back to the local heuristic).
4309 let lookup = gossip_cooldown_for(
4310 Some(&prefetch),
4311 &name,
4312 "8.3.1",
4313 PublishTime::from_unix_secs(2_000),
4314 );
4315 assert!(matches!(lookup, GossipCooldownLookup::NotActive));
4316 }
4317
4318 /// Spec 075 FR-005 (R-S3), inverting the earlier `..._is_not_active` expectation: GOSSIP
4319 /// data present, version-matched, but explicitly reporting no cooldown at all
4320 /// (`cooldown: None` — no `COOLDOWN` finding and no `cooldownEnd` fallback) must be
4321 /// `Unavailable`, not `NotActive` — `NotActive` now means only "a parsed, past `end` was
4322 /// found", so the caller falls back to the local heuristic instead of reading this as an
4323 /// authoritative "not in cooldown" answer (the old mapping was fail-open for a write path).
4324 #[test]
4325 fn gossip_cooldown_for_present_but_no_cooldown_data_is_unavailable() {
4326 let name = PackageName::new("vite");
4327 let mut prefetch = HashMap::new();
4328 prefetch.insert(
4329 name.clone(),
4330 gossip_findings_fixture_not_in_cooldown("8.3.1"),
4331 );
4332
4333 let lookup = gossip_cooldown_for(Some(&prefetch), &name, "8.3.1", PublishTime::now());
4334 assert!(matches!(lookup, GossipCooldownLookup::Unavailable));
4335 }
4336
4337 #[test]
4338 fn gossip_cooldown_for_absent_name_is_unavailable() {
4339 let name = PackageName::new("vite");
4340 let other = PackageName::new("left-pad");
4341 let mut prefetch = HashMap::new();
4342 prefetch.insert(other, gossip_findings_fixture("1.3.0", 2_000));
4343
4344 assert!(matches!(
4345 gossip_cooldown_for(Some(&prefetch), &name, "8.3.1", PublishTime::now()),
4346 GossipCooldownLookup::Unavailable
4347 ));
4348 }
4349
4350 // --- cooldown_disposition (spec 075 FR-004/NFR-002) ---
4351
4352 /// Fix-cycle item 8 (M3/NFR-002): a stored fallback candidate is re-evaluated against
4353 /// read-time `now`/cooldown window, not trusted as a fetch-time snapshot. A
4354 /// fallback whose own `published_at` no longer clears a (narrower, read-time)
4355 /// cooldown window must read as absent, not as a stale "still usable" answer.
4356 #[test]
4357 fn cooldown_disposition_read_time_reevaluates_the_stored_fallback() {
4358 let now = PublishTime::from_unix_secs(10_000);
4359 let versions = PackageVersions::new("2.0.0".into(), Arc::from(vec!["2.0.0".into()]))
4360 .with_published_at(PublishTime::from_unix_secs(9_900)) // 100s old
4361 .with_cooldown_fallback(CooldownFallback::new(
4362 "1.9.0".into(),
4363 PublishTime::from_unix_secs(9_800), // 200s old at read time
4364 ));
4365 let name = PackageName::new("pkg");
4366
4367 // At fetch time, a 150s cooldown window: latest (100s old) is blocked, the fallback
4368 // (200s old) already clears it.
4369 let disposition = cooldown_disposition(
4370 &versions,
4371 &name,
4372 crate::freshness::FreshnessSettings::Enabled {
4373 cooldown: CooldownWindow::from_secs(150),
4374 },
4375 None,
4376 now,
4377 );
4378 assert!(
4379 matches!(
4380 disposition,
4381 CooldownDisposition::Blocked {
4382 fallback: Some(_),
4383 ..
4384 }
4385 ),
4386 "got: {disposition:?}"
4387 );
4388
4389 // Read time later widens the configured cooldown to 10_000s (e.g. a stricter
4390 // `--cooldown` override) — the SAME stored fallback (still only 200s old) no longer
4391 // clears it and must read as absent, never as the earlier fetch-time answer.
4392 let disposition = cooldown_disposition(
4393 &versions,
4394 &name,
4395 crate::freshness::FreshnessSettings::Enabled {
4396 cooldown: CooldownWindow::from_secs(10_000),
4397 },
4398 None,
4399 now,
4400 );
4401 assert!(
4402 matches!(
4403 disposition,
4404 CooldownDisposition::Blocked { fallback: None, .. }
4405 ),
4406 "a fallback that no longer clears cooldown at read time must not be surfaced: {disposition:?}"
4407 );
4408 }
4409
4410 /// #919: a plain literal version — `version_range`'s slice equals the declared
4411 /// requirement exactly — must be admitted as editable.
4412 #[test]
4413 fn test_dependency_version_range_is_literal_plain_literal_admitted() {
4414 let content = r#"serde = "1.0.0""#;
4415 let dep = MockDep {
4416 name: PackageName::new("serde"),
4417 version_req: VersionReq::new("1.0.0"),
4418 version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
4419 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4420 };
4421
4422 assert!(dependency_version_range_is_literal(
4423 &dep,
4424 content,
4425 dep.version_range,
4426 ));
4427 }
4428
4429 /// #919: a Maven-style `${property}` interpolation — `version_range` slices to the raw
4430 /// placeholder text while the parser resolves `version_requirement` to the property's
4431 /// value (a different string) — must be rejected, since a completion/edit there would
4432 /// splice text into the interpolation instead of updating a version.
4433 #[test]
4434 fn test_dependency_version_range_is_literal_rejects_property_interpolation() {
4435 let content = r"<version>${slf4j.version}</version>";
4436 let dep = MockDep {
4437 name: PackageName::new("slf4j-api"),
4438 version_req: VersionReq::new("2.0.16"), // resolved property value
4439 version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
4440 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4441 };
4442
4443 assert!(!dependency_version_range_is_literal(
4444 &dep,
4445 content,
4446 dep.version_range,
4447 ));
4448 }
4449
4450 /// #919: a YAML alias-shaped value (`*anchor`, e.g. GitLab CI/GitHub Actions `ref: *pin`
4451 /// job reuse) never textually equals a declared literal requirement — rejected the same
4452 /// way as the property-interpolation case.
4453 #[test]
4454 fn test_dependency_version_range_is_literal_rejects_yaml_alias() {
4455 let content = "ref: *pin";
4456 let dep = MockDep {
4457 name: PackageName::new("job"),
4458 version_req: VersionReq::new("1.0.0"),
4459 version_range: Range::new(Position::new(0, 5), Position::new(0, 9)), // "*pin"
4460 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4461 };
4462
4463 assert!(!dependency_version_range_is_literal(
4464 &dep,
4465 content,
4466 dep.version_range,
4467 ));
4468 }
4469
4470 /// S1 (critic follow-up): an empty `version_requirement` over an equally-empty slice —
4471 /// exactly what an editor's auto-closing quotes produce the instant `serde = "` is typed
4472 /// — must be **admitted**, not rejected. `generate_code_actions`'s "nothing to update"
4473 /// semantics for an empty requirement do not apply to completion, where an empty value is
4474 /// the single most common moment to offer the full version list.
4475 #[test]
4476 fn test_dependency_version_range_is_literal_admits_empty_requirement_with_empty_slice() {
4477 let content = r#"serde = """#;
4478 let dep = MockDep {
4479 name: PackageName::new("serde"),
4480 version_req: VersionReq::new(""),
4481 version_range: Range::new(Position::new(0, 9), Position::new(0, 9)),
4482 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
4483 };
4484
4485 assert!(dependency_version_range_is_literal(
4486 &dep,
4487 content,
4488 dep.version_range,
4489 ));
4490 }
4491
4492 /// S1 (critic follow-up): an empty `version_requirement` paired with a *non-empty*
4493 /// non-literal slice must still be rejected — dropping the old `is_empty()` early return
4494 /// must not also drop this case, which the plain text comparison already handles on its
4495 /// own (`"1.0" != ""`).
4496 #[test]
4497 fn test_dependency_version_range_is_literal_rejects_empty_requirement_with_mismatched_slice() {
4498 let content = r#"x = "1.0""#;
4499 let dep = MockDep {
4500 name: PackageName::new("x"),
4501 version_req: VersionReq::new(""),
4502 version_range: Range::new(Position::new(0, 5), Position::new(0, 8)), // "1.0"
4503 name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
4504 };
4505
4506 assert!(!dependency_version_range_is_literal(
4507 &dep,
4508 content,
4509 dep.version_range,
4510 ));
4511 }
4512
4513 /// C1 (critic finding): the headline #919 case — an *unresolved* Maven `${property}`
4514 /// reference. The parser leaves an unresolved property as-is in both `version_range`'s
4515 /// slice and `version_requirement` (there is nothing else to put there), so the two
4516 /// textually agree — `literal_span_matches` alone would wrongly admit this. The
4517 /// reference-shape check must reject it independently of that text comparison.
4518 #[test]
4519 fn test_dependency_version_range_is_literal_rejects_unresolved_maven_property() {
4520 let content = r"<version>${slf4j.version}</version>";
4521 let dep = MockDep {
4522 name: PackageName::new("slf4j-api"),
4523 version_req: VersionReq::new("${slf4j.version}"), // left unresolved by the parser
4524 version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
4525 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4526 };
4527
4528 assert!(!dependency_version_range_is_literal(
4529 &dep,
4530 content,
4531 dep.version_range,
4532 ));
4533 }
4534
4535 /// C1 (critic finding): the Gradle sibling of the unresolved-property case — a `$var`
4536 /// reference `resolve_variable_ref` could not resolve, left as raw text in both the slice
4537 /// and `version_requirement`.
4538 #[test]
4539 fn test_dependency_version_range_is_literal_rejects_unresolved_gradle_variable() {
4540 let content = "implementation 'com.example:lib:$libVersion'";
4541 let dep = MockDep {
4542 name: PackageName::new("com.example:lib"),
4543 version_req: VersionReq::new("$libVersion"), // left unresolved by the parser
4544 version_range: Range::new(Position::new(0, 32), Position::new(0, 43)), // "$libVersion"
4545 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4546 };
4547
4548 assert!(!dependency_version_range_is_literal(
4549 &dep,
4550 content,
4551 dep.version_range,
4552 ));
4553 }
4554
4555 /// C1 (critic finding): a partial interpolation embedded mid-string (`1.0-${suffix}`)
4556 /// does not start with `$`, but must still be rejected — the `contains("${")` arm of the
4557 /// reference-shape check, not just the `starts_with` arms.
4558 #[test]
4559 fn test_dependency_version_range_is_literal_rejects_embedded_interpolation() {
4560 let content = r#"x = "1.0-${suffix}""#;
4561 let dep = MockDep {
4562 name: PackageName::new("x"),
4563 version_req: VersionReq::new("1.0-${suffix}"),
4564 version_range: Range::new(Position::new(0, 5), Position::new(0, 18)), // "1.0-${suffix}"
4565 name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
4566 };
4567
4568 assert!(!dependency_version_range_is_literal(
4569 &dep,
4570 content,
4571 dep.version_range,
4572 ));
4573 }
4574
4575 /// M2 (critic follow-up): `"*"` — the project-wide existence-wildcard spelling
4576 /// (`deps_core::registry::is_existence_wildcard`, synthesized by `deps-npm`,
4577 /// `deps-composer`, ...) — must be **admitted**, not caught by the `*`-prefix arm meant
4578 /// for a `*anchor` YAML alias. Rejecting it would silently disable version completion at
4579 /// the exact moment a user wants to replace `"lodash": "*"` with a pinned version.
4580 #[test]
4581 fn test_dependency_version_range_is_literal_admits_existence_wildcard() {
4582 let content = r#"x = "*""#;
4583 let dep = MockDep {
4584 name: PackageName::new("x"),
4585 version_req: VersionReq::new("*"),
4586 version_range: Range::new(Position::new(0, 5), Position::new(0, 6)), // "*"
4587 name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
4588 };
4589
4590 assert!(dependency_version_range_is_literal(
4591 &dep,
4592 content,
4593 dep.version_range,
4594 ));
4595 }
4596
4597 /// M3 (critic follow-up): a `*anchor`-shaped slice that is *not exactly* `"*"` — e.g. a
4598 /// multi-char alias name — must still be rejected. Distinguishes the wildcard exemption
4599 /// above from a genuine leading-`*` reference.
4600 #[test]
4601 fn test_dependency_version_range_is_literal_rejects_multi_char_alias_despite_wildcard_exemption()
4602 {
4603 let content = "ref: *pinned-anchor";
4604 let dep = MockDep {
4605 name: PackageName::new("job"),
4606 version_req: VersionReq::new("1.0.0"),
4607 version_range: Range::new(Position::new(0, 5), Position::new(0, 19)), // "*pinned-anchor"
4608 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4609 };
4610
4611 assert!(!dependency_version_range_is_literal(
4612 &dep,
4613 content,
4614 dep.version_range,
4615 ));
4616 }
4617
4618 /// M3 (critic follow-up): a Gradle GString with a `$var` reference embedded mid-string
4619 /// (`1.0.$patch`, no braces, not at the start) is not resolved by
4620 /// `resolve_variable_ref` (whole-value only) — the slice stays byte-identical to the raw
4621 /// `version_requirement`, so only the shape check (not the text comparison) can reject
4622 /// it. This is #919 C1 in a narrower spelling than the whole-value `$var`/`${var}` case.
4623 #[test]
4624 fn test_dependency_version_range_is_literal_rejects_gradle_mid_string_variable() {
4625 let content = r#"implementation "com.example:lib:1.0.$patch""#;
4626 let dep = MockDep {
4627 name: PackageName::new("com.example:lib"),
4628 version_req: VersionReq::new("1.0.$patch"), // left unresolved by the parser
4629 version_range: Range::new(Position::new(0, 32), Position::new(0, 42)), // "1.0.$patch"
4630 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
4631 };
4632
4633 assert!(!dependency_version_range_is_literal(
4634 &dep,
4635 content,
4636 dep.version_range,
4637 ));
4638 }
4639
4640 /// #1161: a dependency with no `version_requirement()` at all — Maven's parser never
4641 /// sets one for a genuinely empty `<version></version>` tag, since there is no text to
4642 /// capture — must still be admitted when `version_range`'s slice is empty. There is no
4643 /// existing text at that position that could be misread as a reference/wildcard, so
4644 /// offering completion is always safe regardless of whether a requirement was parsed.
4645 #[test]
4646 fn test_dependency_version_range_is_literal_admits_empty_slice_with_no_requirement_at_all() {
4647 let content = "<version></version>";
4648 let dep = MockSyntheticRangeDep {
4649 name: PackageName::new("com.example:foo"),
4650 };
4651 let empty_range = Range::new(Position::new(0, 9), Position::new(0, 9));
4652
4653 assert!(dep.version_requirement().is_none());
4654 assert!(dependency_version_range_is_literal(
4655 &dep,
4656 content,
4657 empty_range
4658 ));
4659 }
4660
4661 /// Critic follow-up (C1, second round) to #1161: a DANGLING or still-being-typed
4662 /// `version.ref` alias (e.g. `version.ref = "gu"` while typing "guava") has
4663 /// `version_requirement() == None` — the exact same shape as Maven's genuinely empty
4664 /// `<version></version>` tag — but its range is `deps-gradle`'s `Range::default()`
4665 /// sentinel, not a real in-document position. This is the *normal* interactive state on
4666 /// every keystroke while typing an alias, not an edge case: admitting it here would offer
4667 /// the full unfiltered version list and splice a version literal into the alias name.
4668 /// Must be rejected regardless of `version_requirement()` being absent.
4669 #[test]
4670 fn test_dependency_version_range_is_literal_rejects_default_range_with_no_requirement_at_all() {
4671 let content = r#"version.ref = "gu""#;
4672 let dep = MockSyntheticRangeDep {
4673 name: PackageName::new("com.example:guava"),
4674 };
4675
4676 assert!(dep.version_requirement().is_none());
4677 assert!(!dependency_version_range_is_literal(
4678 &dep,
4679 content,
4680 Range::default(),
4681 ));
4682 }
4683
4684 /// Critic follow-up (C1) to #1161: an empty slice must NOT be admitted merely because it
4685 /// is empty — only when `version_requirement()` is also absent. `deps-gradle`'s catalog
4686 /// context deliberately returns `Range::default()` (which slices to `""`) for a
4687 /// `version.ref = "alias"` reference while `version_requirement()` still holds the
4688 /// alias's real, non-empty resolved value (#931) — the "always reject" guarantee that
4689 /// sentinel relies on comes from comparing that non-empty value against the empty slice,
4690 /// not from any special-casing of the empty slice itself. A version that checks
4691 /// `trimmed.is_empty()` before/independent of `version_requirement()` would invert this
4692 /// into an always-accept and let a full completion list splice a version literal into
4693 /// the alias name.
4694 #[test]
4695 fn test_dependency_version_range_is_literal_rejects_default_range_with_nonempty_requirement() {
4696 let content = r#"version.ref = "guavaVersion""#;
4697 let dep = MockDep {
4698 name: PackageName::new("com.example:guava"),
4699 version_req: VersionReq::new("32.0.1"), // the alias's resolved value
4700 version_range: Range::default(), // deps-gradle's deliberate always-reject sentinel
4701 name_range: Range::default(),
4702 };
4703
4704 assert!(!dependency_version_range_is_literal(
4705 &dep,
4706 content,
4707 dep.version_range,
4708 ));
4709 }
4710
4711 /// The empty-entry pruning invariant: an entry is removed once its last set channel
4712 /// is cleared, one channel at a time, in every order — never left behind as a
4713 /// vacuous `Some(DependencyOutcome::default())` that would inflate `len()`.
4714 #[test]
4715 fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
4716 let mut outcomes = DependencyOutcomes::new()
4717 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
4718 .with_deprecation(
4719 "pkg",
4720 Deprecation {
4721 reason: None,
4722 replacement: None,
4723 },
4724 )
4725 .with_fetch_failure("pkg", FetchFailure::Transient);
4726 assert_eq!(outcomes.len(), 1);
4727
4728 outcomes.clear_yanked("pkg");
4729 assert!(
4730 outcomes.get("pkg").is_some(),
4731 "entry must survive while other channels are still set"
4732 );
4733
4734 outcomes.clear_deprecation("pkg");
4735 assert!(
4736 outcomes.get("pkg").is_some(),
4737 "entry must survive while the fetch-failure channel is still set"
4738 );
4739
4740 outcomes.clear_fetch_failure("pkg");
4741 assert!(
4742 outcomes.is_empty(),
4743 "entry must be pruned once its last channel is cleared, not left as a vacuous Some"
4744 );
4745 assert_eq!(outcomes.len(), 0);
4746 }
4747
4748 /// Clearing a channel that was never set on an existing entry must not spuriously
4749 /// prune channels that ARE still set (each `clear_*` only nulls its own field).
4750 #[test]
4751 fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
4752 let mut outcomes = DependencyOutcomes::new()
4753 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
4754
4755 outcomes.clear_deprecation("pkg");
4756 outcomes.clear_fetch_failure("pkg");
4757
4758 assert!(
4759 outcomes.yanked("pkg").is_some(),
4760 "clearing unset channels must not touch the still-set yanked channel"
4761 );
4762 assert_eq!(outcomes.len(), 1);
4763 }
4764
4765 /// `remove` drops the whole entry regardless of which channels are set, unlike the
4766 /// per-channel `clear_*` methods.
4767 #[test]
4768 fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
4769 let mut outcomes = DependencyOutcomes::new()
4770 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
4771 .with_fetch_failure("pkg", FetchFailure::Transient);
4772
4773 outcomes.remove("pkg");
4774
4775 assert!(outcomes.get("pkg").is_none());
4776 assert!(outcomes.is_empty());
4777 }
4778
4779 /// `clear_all_fetch_failures` (deps-lsp issue #592) drops the fetch-failure channel for
4780 /// every entry, pruning an entry that becomes empty, while leaving other channels
4781 /// (yanked/deprecation/no-comparable-versions) on a still-mixed entry untouched.
4782 #[test]
4783 fn test_dependency_outcomes_clear_all_fetch_failures() {
4784 let mut outcomes = DependencyOutcomes::new()
4785 .with_fetch_failure("only-failure", FetchFailure::Transient)
4786 .with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
4787 .with_fetch_failure("mixed", FetchFailure::Transient);
4788
4789 outcomes.clear_all_fetch_failures();
4790
4791 assert!(
4792 outcomes.get("only-failure").is_none(),
4793 "an entry whose only channel was fetch-failure must be pruned"
4794 );
4795 assert!(outcomes.fetch_failure("mixed").is_none());
4796 assert!(
4797 outcomes.yanked("mixed").is_some(),
4798 "clearing fetch-failure must not touch a mixed entry's other channels"
4799 );
4800 }
4801
4802 /// `clear_all_fetch_failures` on an empty map is a no-op, not a panic.
4803 #[test]
4804 fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
4805 let mut outcomes = DependencyOutcomes::new();
4806 outcomes.clear_all_fetch_failures();
4807 assert!(outcomes.is_empty());
4808 }
4809
4810 /// `set_fetch_failure_if_absent` (impl-critic M2) must never clobber a genuine
4811 /// `Actionable`/`Transient` failure already recorded for the name — only `set_fetch_failure`
4812 /// unconditionally overwrites.
4813 #[test]
4814 fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
4815 let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
4816 "pkg",
4817 FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
4818 );
4819
4820 outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
4821
4822 assert_eq!(
4823 outcomes.fetch_failure("pkg"),
4824 Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
4825 "an existing genuine failure must survive a collided-name NotAttempted marker"
4826 );
4827 }
4828
4829 /// The mirror case: when no failure is recorded yet, `set_fetch_failure_if_absent` does
4830 /// populate the entry.
4831 #[test]
4832 fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
4833 let mut outcomes = DependencyOutcomes::new();
4834
4835 outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
4836
4837 assert_eq!(
4838 outcomes.fetch_failure("pkg"),
4839 Some(&FetchFailure::NotAttempted)
4840 );
4841 }
4842
4843 #[test]
4844 fn test_line_offset_table_line_start_crlf() {
4845 let table = LineOffsetTable::new("a\r\nbb\r\nc");
4846 assert_eq!(table.line_start(0), Some(0));
4847 assert_eq!(table.line_start(1), Some(3));
4848 assert_eq!(table.line_start(2), Some(7));
4849 assert_eq!(table.line_start(3), None);
4850 }
4851
4852 #[test]
4853 fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
4854 // "é" is a 2-byte UTF-8 sequence; offset 1 lands inside it.
4855 let content = "é";
4856 let table = LineOffsetTable::new(content);
4857 // Must not panic; clamps down to the nearest boundary (offset 0).
4858 let pos = table.byte_offset_to_position(content, 1);
4859 assert_eq!(pos, Position::new(0, 0));
4860 }
4861
4862 #[test]
4863 fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
4864 let content = "ab é cd";
4865 let table = LineOffsetTable::new(content);
4866 // Byte 3 is 'é's leading byte (boundary); byte 4 is its continuation
4867 // byte (not a boundary) and must clamp back to 3 rather than panic.
4868 assert!(content.is_char_boundary(3));
4869 assert!(!content.is_char_boundary(4));
4870 let pos = table.byte_offset_to_position(content, 4);
4871 assert_eq!(pos, table.byte_offset_to_position(content, 3));
4872 }
4873
4874 /// Covers the ASCII/non-ASCII fast-path split introduced for #742: an ASCII-only line
4875 /// (line 0), a non-ASCII line mixing a BMP accented character with a surrogate-pair
4876 /// emoji (line 1), and a mixed line (line 2) must all still resolve to the same
4877 /// `Position`s as the original `chars().map(char::len_utf16).sum()` scan would produce.
4878 #[test]
4879 fn test_byte_offset_to_position_ascii_and_non_ascii_lines_agree() {
4880 let content = "abcde\nhéllo 😀\nab café end";
4881 let table = LineOffsetTable::new(content);
4882
4883 // Line 0 ("abcde"): ASCII fast path, byte offset == UTF-16 character offset.
4884 assert_eq!(
4885 table.byte_offset_to_position(content, 3),
4886 Position::new(0, 3)
4887 );
4888 assert_eq!(
4889 table.byte_offset_to_position(content, 5),
4890 Position::new(0, 5)
4891 );
4892
4893 // Line 1 ("héllo 😀"): non-ASCII scan path.
4894 // Offset after "h\u{e9}" (1 + 2 bytes into the line): 'h' + 'é' = 2 UTF-16 units.
4895 assert_eq!(
4896 table.byte_offset_to_position(content, 6 + 3),
4897 Position::new(1, 2)
4898 );
4899 // Offset at the end of the line: "héllo 😀" = 8 UTF-16 units (emoji is a surrogate pair).
4900 assert_eq!(
4901 table.byte_offset_to_position(content, 6 + 11),
4902 Position::new(1, 8)
4903 );
4904
4905 // Line 2 ("ab café end"): mixed line, still takes the non-ASCII scan path.
4906 // Offset after "ab café" (8 bytes into the line): 7 UTF-16 units ('é' is 1 BMP unit).
4907 assert_eq!(
4908 table.byte_offset_to_position(content, 18 + 8),
4909 Position::new(2, 7)
4910 );
4911 }
4912
4913 fn reference_position_to_byte_offset(
4914 table: &LineOffsetTable,
4915 content: &str,
4916 position: Position,
4917 ) -> usize {
4918 let Some(line_start) = table.line_start(position.line as usize) else {
4919 return content.len();
4920 };
4921 let line_end = table
4922 .line_start(position.line as usize + 1)
4923 .unwrap_or(content.len());
4924 utf16_to_byte_offset(&content[line_start..line_end], position.character)
4925 .map_or(line_end, |offset| line_start + offset)
4926 .min(content.len())
4927 }
4928
4929 /// #1711: the indexed inverse conversion must match the old per-call line scan for every
4930 /// position, including out-of-range lines/characters and ones splitting a surrogate pair.
4931 #[test]
4932 fn test_position_to_byte_offset_matches_line_scan_reference() {
4933 for content in [
4934 "",
4935 "abc",
4936 "abc\n",
4937 "a\r\nbb\r\nc",
4938 "h\u{e9}llo \u{1f600}\nascii\n\u{65e5}\u{672c}\u{8a9e}x\n",
4939 "\u{1f600}\u{1f600}",
4940 "\n\n\u{e9}",
4941 ] {
4942 let table = LineOffsetTable::new(content);
4943 for line in 0..6 {
4944 for character in 0..24 {
4945 let position = Position::new(line, character);
4946 assert_eq!(
4947 table.position_to_byte_offset(content, position),
4948 reference_position_to_byte_offset(&table, content, position),
4949 "content {content:?} position {position:?}"
4950 );
4951 }
4952 }
4953 }
4954 }
4955
4956 #[test]
4957 fn test_position_to_byte_offset_surrogate_split_rounds_up_and_overshoot_clamps() {
4958 let content = "a\u{1f600}b\nzz";
4959 let table = LineOffsetTable::new(content);
4960 assert_eq!(
4961 table.position_to_byte_offset(content, Position::new(0, 2)),
4962 5
4963 );
4964 assert_eq!(
4965 table.position_to_byte_offset(content, Position::new(0, 4)),
4966 6
4967 );
4968 assert_eq!(
4969 table.position_to_byte_offset(content, Position::new(0, 99)),
4970 7
4971 );
4972 assert_eq!(
4973 table.position_to_byte_offset(content, Position::new(1, 99)),
4974 content.len()
4975 );
4976 assert_eq!(
4977 table.position_to_byte_offset(content, Position::new(9, 0)),
4978 content.len()
4979 );
4980 }
4981
4982 /// #1711: many edits on one long non-ASCII line must round-trip through the cached index.
4983 #[test]
4984 fn test_position_to_byte_offset_long_non_ascii_line_round_trips() {
4985 let content = "\u{e9}\u{1f600}dep = \"1.0.0\", ".repeat(5000);
4986 let table = LineOffsetTable::new(&content);
4987 for (offset, _) in content.char_indices().step_by(7) {
4988 let position = table.byte_offset_to_position(&content, offset);
4989 assert_eq!(table.position_to_byte_offset(&content, position), offset);
4990 }
4991 }
4992
4993 #[test]
4994 fn test_position_to_byte_offset_round_trips_across_mixed_ascii_and_non_ascii_lines() {
4995 let content = "plain = 1\r\nh\u{e9}llo \u{1f600}\nascii again\n\u{65e5}\u{672c}\n\nend";
4996 let table = LineOffsetTable::new(content);
4997 let offsets: Vec<usize> = content
4998 .char_indices()
4999 .map(|(i, _)| i)
5000 .chain([content.len()])
5001 .collect();
5002 for &offset in offsets.iter().rev().chain(offsets.iter()) {
5003 let position = table.byte_offset_to_position(content, offset);
5004 assert_eq!(
5005 table.position_to_byte_offset(content, position),
5006 offset,
5007 "offset {offset}"
5008 );
5009 }
5010 }
5011
5012 /// Regression guard for #742: `byte_offset_to_position` on a large single-line
5013 /// (minified) manifest must stay near-instant. The pre-fix implementation rescanned
5014 /// the line from its start on every call, making repeated lookups over such a line
5015 /// O(n^2) in the line's length (~350ms for 8000 calls on an ~180KB line on the
5016 /// reporter's machine); the ASCII fast path makes each call O(1), so this generous
5017 /// wall-clock bound leaves wide margin without being flaky.
5018 #[test]
5019 fn test_byte_offset_to_position_minified_line_stays_fast() {
5020 let mut content = String::from("{\"dependencies\":{");
5021 for i in 0..8000 {
5022 content.push_str(&format!("\"dep{i}\":\"1.0.{i}\","));
5023 }
5024 content.push_str("}}");
5025
5026 let table = LineOffsetTable::new(&content);
5027 let step = (content.len() / 8000).max(1);
5028 let start = std::time::Instant::now();
5029 for offset in (0..content.len()).step_by(step) {
5030 std::hint::black_box(table.byte_offset_to_position(&content, offset));
5031 }
5032 let elapsed = start.elapsed();
5033
5034 assert!(
5035 elapsed < std::time::Duration::from_millis(100),
5036 "8000 byte_offset_to_position calls on a minified line took {elapsed:?}, expected < 100ms"
5037 );
5038 }
5039
5040 /// Regression guard for #882 (widened scope): `byte_offset_to_position`'s non-ASCII branch
5041 /// used to walk `chars().map(len_utf16).sum()` from the line's start on every call, so N
5042 /// lookups on the same wide non-ASCII line cost O(N x line length) — the same shape #882
5043 /// reported for `marker_byte_offset`, and, per the #882 review, ~300x its cost since
5044 /// `make_range` calls this twice per dependency. The shared per-line index makes every
5045 /// lookup after the first on a given line O(1).
5046 #[test]
5047 fn test_byte_offset_to_position_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
5048 let filler = "x".repeat(200 * 1024);
5049 let content = format!("{filler}\u{2014}{filler}");
5050 let table = LineOffsetTable::new(&content);
5051 let step = (content.len() / 5000).max(1);
5052 let start = std::time::Instant::now();
5053 for offset in (0..content.len()).step_by(step) {
5054 std::hint::black_box(table.byte_offset_to_position(&content, offset));
5055 }
5056 assert!(
5057 start.elapsed() < std::time::Duration::from_secs(1),
5058 "byte_offset_to_position took {:?} for ~5000 lookups on a huge non-ASCII line, \
5059 expected well under 1s",
5060 start.elapsed()
5061 );
5062 }
5063
5064 /// Differential test: the cached non-ASCII path must agree with a plain reimplemented
5065 /// `chars().map(len_utf16).sum()` walk for every char-boundary offset on a non-ASCII line,
5066 /// including offsets on more than one non-ASCII line in the same document (cache isolation
5067 /// across lines, sharing the same underlying index `marker_byte_offset` populates).
5068 #[test]
5069 fn test_byte_offset_to_position_non_ascii_cache_matches_char_sum_result_across_lines() {
5070 fn slow_path(content: &str, line_start: usize, offset: usize) -> u32 {
5071 u32::try_from(
5072 content[line_start..offset]
5073 .chars()
5074 .map(char::len_utf16)
5075 .sum::<usize>(),
5076 )
5077 .unwrap_or(u32::MAX)
5078 }
5079
5080 let content = "\u{1f680}rocket \u{2014} dash\nascii line\n\u{3000}ideographic \u{e9}nd";
5081 let table = LineOffsetTable::new(content);
5082 let line_starts = [
5083 table.line_start(0).unwrap(),
5084 table.line_start(1).unwrap(),
5085 table.line_start(2).unwrap(),
5086 ];
5087 let line_ends = [line_starts[1] - 1, line_starts[2] - 1, content.len()];
5088
5089 for (line, (&line_start, &line_end)) in line_starts.iter().zip(&line_ends).enumerate() {
5090 let line_text = &content[line_start..line_end];
5091 for (byte_offset, _) in line_text
5092 .char_indices()
5093 .chain(std::iter::once((line_text.len(), '\0')))
5094 {
5095 let offset = line_start + byte_offset;
5096 assert_eq!(
5097 table.byte_offset_to_position(content, offset).character,
5098 slow_path(content, line_start, offset),
5099 "line {line} byte offset {byte_offset} desynced from the char-sum walk"
5100 );
5101 }
5102 }
5103 }
5104
5105 #[test]
5106 fn test_position_in_range_inside() {
5107 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5108 let position = Position::new(5, 15);
5109 assert!(position_in_range(position, range));
5110 }
5111
5112 #[test]
5113 fn test_position_in_range_at_start() {
5114 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5115 let position = Position::new(5, 10);
5116 assert!(position_in_range(position, range));
5117 }
5118
5119 #[test]
5120 fn test_position_in_range_at_end() {
5121 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5122 let position = Position::new(5, 20);
5123 assert!(position_in_range(position, range));
5124 }
5125
5126 #[test]
5127 fn test_position_in_range_before() {
5128 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5129 let position = Position::new(5, 5);
5130 assert!(!position_in_range(position, range));
5131 }
5132
5133 #[test]
5134 fn test_position_in_range_after() {
5135 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5136 let position = Position::new(5, 25);
5137 assert!(!position_in_range(position, range));
5138 }
5139
5140 #[test]
5141 fn test_position_in_range_different_line_before() {
5142 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5143 let position = Position::new(4, 15);
5144 assert!(!position_in_range(position, range));
5145 }
5146
5147 #[test]
5148 fn test_position_in_range_different_line_after() {
5149 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
5150 let position = Position::new(6, 15);
5151 assert!(!position_in_range(position, range));
5152 }
5153
5154 #[test]
5155 fn test_position_in_range_multiline() {
5156 let range = Range::new(Position::new(5, 10), Position::new(7, 5));
5157 let position = Position::new(6, 0);
5158 assert!(position_in_range(position, range));
5159 }
5160
5161 #[test]
5162 fn test_escape_markdown_link_breakout_payload() {
5163 let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
5164 let escaped = escape_markdown(payload);
5165 assert_eq!(
5166 escaped,
5167 r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
5168 );
5169 assert!(!escaped.contains("]("));
5170 }
5171
5172 #[test]
5173 fn test_escape_markdown_backslash_and_backtick() {
5174 assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
5175 }
5176
5177 #[test]
5178 fn test_escape_markdown_autolink_angle_brackets() {
5179 // `<...>` around a bare URL is a CommonMark autolink; `<`/`>` must be escaped
5180 // so it cannot render as a live link independent of the `[]`/`()` escaping.
5181 let escaped = escape_markdown("pkg <https://evil.example>");
5182 assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
5183 assert!(!escaped.contains('<') || escaped.contains(r"\<"));
5184 }
5185
5186 #[test]
5187 fn test_escape_markdown_control_chars_become_spaces() {
5188 assert_eq!(escape_markdown("a\nb"), "a b");
5189 assert_eq!(escape_markdown("a\r\nb"), "a b");
5190 assert_eq!(escape_markdown("a\tb"), "a b");
5191 assert_eq!(escape_markdown("a\0b"), "a b");
5192 }
5193
5194 #[test]
5195 fn test_escape_markdown_newline_cannot_break_out_of_heading() {
5196 // A raw newline used to terminate the ATX heading line early, letting the
5197 // rest of the name (potentially another "# [...](...)" sequence) render as
5198 // separate, unescaped Markdown blocks.
5199 let escaped = escape_markdown("react\n# [fake](https://evil.example)");
5200 assert!(!escaped.contains('\n'));
5201 }
5202
5203 #[test]
5204 fn test_escape_markdown_hyphenated_name_round_trips_visually() {
5205 // Escaping a hyphen (ASCII punctuation) is visually inert on render — CommonMark
5206 // renders `\-` as a literal `-` — so common package names are unaffected in
5207 // practice even though the raw Markdown source now escapes them.
5208 assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
5209 }
5210
5211 #[test]
5212 fn test_markdown_code_span_plain_content() {
5213 assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
5214 }
5215
5216 #[test]
5217 fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
5218 assert_eq!(markdown_code_span("a`b"), "``a`b``");
5219 assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
5220 }
5221
5222 #[test]
5223 fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
5224 let span = markdown_code_span("`leading");
5225 assert!(span.starts_with("`` `"));
5226 }
5227
5228 #[test]
5229 fn test_markdown_code_span_replaces_control_chars() {
5230 let span = markdown_code_span("1.0\n[evil](https://evil.example)");
5231 assert!(!span.contains('\n'));
5232 }
5233
5234 #[test]
5235 fn test_markdown_code_span_empty_content() {
5236 assert_eq!(markdown_code_span(""), "` `");
5237 }
5238
5239 #[test]
5240 fn test_markdown_code_span_backtick_payload_cannot_break_span() {
5241 // A payload closing the code span early to splice in a live link must not succeed.
5242 let payload = "1.0` <https://evil.example>` more";
5243 let span = markdown_code_span(payload);
5244 // The fence must be strictly longer than any backtick run in the content, so no
5245 // substring after the opening fence can act as a closing fence before the real one.
5246 let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
5247 let inner = &span[opening_fence_len..span.len() - opening_fence_len];
5248 assert!(
5249 !inner.contains(&"`".repeat(opening_fence_len)),
5250 "content contains a run of backticks as long as the fence: {span}"
5251 );
5252 }
5253
5254 #[test]
5255 fn test_escape_markdown_replaces_bidi_and_invisible_characters() {
5256 for c in [
5257 '\u{202E}', // RLO — the Trojan Source vector
5258 '\u{2066}', // LRI
5259 '\u{200B}', // ZWSP
5260 '\u{2060}', // WORD JOINER
5261 '\u{2028}', // LS
5262 '\u{FEFF}', // BOM
5263 '\u{FFFA}', // interlinear annotation separator
5264 '\u{E0041}', // Unicode tag character ("A")
5265 ] {
5266 let escaped = escape_markdown(&format!("a{c}b"));
5267 assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
5268 }
5269 }
5270
5271 /// #1323: `sanitize_invisible` already stripped these `Cf` characters while
5272 /// `is_markdown_unsafe`'s hand-picked list let them through.
5273 #[test]
5274 fn test_escape_markdown_replaces_1323_gap_characters() {
5275 for c in [
5276 '\u{00AD}', // SOFT HYPHEN
5277 '\u{180E}', // MONGOLIAN VOWEL SEPARATOR
5278 '\u{2061}', // FUNCTION APPLICATION
5279 '\u{2062}', // INVISIBLE TIMES
5280 '\u{2063}', // INVISIBLE SEPARATOR
5281 '\u{2064}', // INVISIBLE PLUS
5282 '\u{206A}', // INHIBIT SYMMETRIC SWAPPING
5283 '\u{206B}', // ACTIVATE SYMMETRIC SWAPPING
5284 '\u{206C}', // INHIBIT ARABIC FORM SHAPING
5285 '\u{206D}', // ACTIVATE ARABIC FORM SHAPING
5286 '\u{206E}', // NATIONAL DIGIT SHAPES
5287 '\u{206F}', // NOMINAL DIGIT SHAPES
5288 '\u{13430}', // Egyptian Hieroglyph format control (first)
5289 '\u{1343F}', // Egyptian Hieroglyph format control (last)
5290 '\u{1BCA0}', // Shorthand format control (first)
5291 '\u{1BCA3}', // Shorthand format control (last)
5292 '\u{1D173}', // Musical symbol format control (first)
5293 '\u{1D17A}', // Musical symbol format control (last)
5294 ] {
5295 let escaped = escape_markdown(&format!("a{c}b"));
5296 assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
5297 }
5298 }
5299
5300 /// #1323 M1: U+2065 (unassigned, sitting inside the consolidated U+2060-U+206F range)
5301 /// is now deliberately swept in by the range rather than carved out, so a future
5302 /// Unicode assignment of it can't silently open a hole.
5303 #[test]
5304 fn test_escape_markdown_now_blocks_unassigned_u2065() {
5305 assert_eq!(escape_markdown("a\u{2065}b"), "a b");
5306 }
5307
5308 /// #1323 must not regress #1248: legitimate directional marks and ZWNJ/ZWJ survive.
5309 #[test]
5310 fn test_escape_markdown_still_preserves_legitimate_bidi_marks_after_1323() {
5311 for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200C}', '\u{200D}'] {
5312 let escaped = escape_markdown(&format!("a{c}b"));
5313 assert_eq!(
5314 escaped,
5315 format!("a{c}b"),
5316 "{c:?} must survive verbatim (legitimate RTL/emoji use, #1248)"
5317 );
5318 }
5319 }
5320
5321 /// #1323 M2/critic S2: prefixed-format signs with genuine Arabic/Syriac/Kaithi
5322 /// mid-string use must also survive — they were exempt before #1323 and remain so.
5323 #[test]
5324 fn test_escape_markdown_preserves_prefixed_format_signs() {
5325 for c in [
5326 '\u{0600}',
5327 '\u{0601}',
5328 '\u{0602}',
5329 '\u{0603}',
5330 '\u{0604}',
5331 '\u{0605}',
5332 '\u{06DD}',
5333 '\u{070F}',
5334 '\u{0890}',
5335 '\u{0891}',
5336 '\u{08E2}',
5337 '\u{110BD}',
5338 '\u{110CD}',
5339 ] {
5340 let escaped = escape_markdown(&format!("a{c}b"));
5341 assert_eq!(
5342 escaped,
5343 format!("a{c}b"),
5344 "{c:?} must survive verbatim (legitimate Arabic/Syriac/Kaithi use, #1248)"
5345 );
5346 }
5347 }
5348
5349 /// #1323 critic S3: exhaustive drift guard. Scans the full Unicode code space and
5350 /// asserts that every character `sanitize_invisible` strips but `is_markdown_unsafe`
5351 /// does not is exactly the documented, named exempt set (#1248's RTL/ZWNJ/ZWJ marks
5352 /// plus the prefixed-format signs) — not a superset (an undocumented, silently
5353 /// widened exemption) and not a subset (a stale exempt-set constant hiding a real
5354 /// gap). Fails CI if `unicode-general-category` changes classification for any code
5355 /// point, or if either function's character list is hand-edited without updating the
5356 /// other.
5357 #[test]
5358 fn test_is_markdown_unsafe_exempt_set_matches_sanitize_invisible_drift_guard() {
5359 const EXEMPT_CHARS: &[char] = &[
5360 // #1248: RTL/ZWNJ/ZWJ marks, load-bearing in Persian/Arabic/Indic text shaping
5361 // and emoji ZWJ sequences.
5362 '\u{200E}',
5363 '\u{200F}',
5364 '\u{061C}',
5365 '\u{200C}',
5366 '\u{200D}',
5367 // #1248/critic S2: prefixed-format signs with genuine Arabic/Syriac/Kaithi
5368 // mid-string annotation use.
5369 '\u{0600}',
5370 '\u{0601}',
5371 '\u{0602}',
5372 '\u{0603}',
5373 '\u{0604}',
5374 '\u{0605}',
5375 '\u{06DD}',
5376 '\u{070F}',
5377 '\u{0890}',
5378 '\u{0891}',
5379 '\u{08E2}',
5380 '\u{110BD}',
5381 '\u{110CD}',
5382 ];
5383
5384 let mut undocumented_exemptions = Vec::new();
5385 for cp in 0..=0x0010_FFFFu32 {
5386 let Some(c) = char::from_u32(cp) else {
5387 continue;
5388 };
5389 if crate::redact::is_invisible(c)
5390 && !is_markdown_unsafe(c)
5391 && !EXEMPT_CHARS.contains(&c)
5392 {
5393 undocumented_exemptions.push(c);
5394 }
5395 }
5396 assert!(
5397 undocumented_exemptions.is_empty(),
5398 "is_markdown_unsafe silently exempts characters sanitize_invisible strips, \
5399 outside the documented #1248/#1323 exempt set: {undocumented_exemptions:?}"
5400 );
5401
5402 for &c in EXEMPT_CHARS {
5403 assert!(
5404 crate::redact::is_invisible(c) && !is_markdown_unsafe(c),
5405 "{c:?} is listed in the exempt set but is either not stripped by \
5406 sanitize_invisible or is already blocked by is_markdown_unsafe — the \
5407 exempt-set constant is stale"
5408 );
5409 }
5410 }
5411
5412 /// #1276: `Diagnostic::new`/`RelatedInformation::new` apply `replace_markdown_unsafe_chars`
5413 /// on top of producer-side sanitization that may have already called it —
5414 /// double-application must be a no-op, since `' '` (the substitution) is not itself
5415 /// `is_markdown_unsafe`.
5416 #[test]
5417 fn test_replace_markdown_unsafe_chars_is_idempotent() {
5418 for c in [
5419 '\u{202E}',
5420 '\u{2066}',
5421 '\u{200B}',
5422 '\u{2060}',
5423 '\u{2028}',
5424 '\u{2029}',
5425 '\u{FEFF}',
5426 '\u{FFFA}',
5427 '\u{E0041}',
5428 '\n',
5429 '\t',
5430 ] {
5431 let input = format!("a{c}b");
5432 let once = replace_markdown_unsafe_chars(&input);
5433 assert_eq!(once, "a b", "{c:?} must be replaced with a single space");
5434 let twice = replace_markdown_unsafe_chars(&once);
5435 assert_eq!(twice, once, "{c:?} must not change on re-application");
5436 }
5437 assert!(
5438 !is_markdown_unsafe(' '),
5439 "substituted space must be stable under re-application"
5440 );
5441 }
5442
5443 #[test]
5444 fn test_escape_markdown_preserves_legitimate_bidi_marks() {
5445 for c in [
5446 '\u{200F}', // RLM
5447 '\u{061C}', // ALM
5448 '\u{200E}', // LRM
5449 '\u{200D}', // ZWJ
5450 ] {
5451 let escaped = escape_markdown(&format!("a{c}b"));
5452 assert_eq!(
5453 escaped,
5454 format!("a{c}b"),
5455 "{c:?} must survive verbatim (legitimate RTL/emoji use)"
5456 );
5457 }
5458 }
5459
5460 #[test]
5461 fn test_markdown_code_span_replaces_bidi_and_invisible_characters() {
5462 for c in [
5463 '\u{202E}',
5464 '\u{2066}',
5465 '\u{200B}',
5466 '\u{2060}',
5467 '\u{2029}',
5468 '\u{FEFF}',
5469 '\u{FFFA}',
5470 '\u{E0041}',
5471 ] {
5472 let span = markdown_code_span(&format!("a{c}b"));
5473 assert_eq!(span, "`a b`", "{c:?} must be replaced with a space");
5474 }
5475 }
5476
5477 #[test]
5478 fn test_markdown_code_span_preserves_legitimate_bidi_marks() {
5479 for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200D}'] {
5480 let span = markdown_code_span(&format!("a{c}b"));
5481 assert_eq!(
5482 span,
5483 format!("`a{c}b`"),
5484 "{c:?} must survive verbatim (legitimate RTL/emoji use)"
5485 );
5486 }
5487 }
5488
5489 #[test]
5490 fn test_is_same_major_minor_full_match() {
5491 assert!(is_same_major_minor("1.2.3", "1.2.9"));
5492 }
5493
5494 #[test]
5495 fn test_is_same_major_minor_exact_match() {
5496 assert!(is_same_major_minor("1.2.3", "1.2.3"));
5497 }
5498
5499 #[test]
5500 fn test_is_same_major_minor_major_only_match() {
5501 assert!(is_same_major_minor("1", "1.2.3"));
5502 assert!(is_same_major_minor("1.2.3", "1"));
5503 }
5504
5505 #[test]
5506 fn test_is_same_major_minor_no_match_different_minor() {
5507 assert!(!is_same_major_minor("1.2.3", "1.3.0"));
5508 }
5509
5510 #[test]
5511 fn test_is_same_major_minor_no_match_different_major() {
5512 assert!(!is_same_major_minor("1.2.3", "2.2.3"));
5513 }
5514
5515 #[test]
5516 fn test_is_same_major_minor_empty_strings() {
5517 assert!(!is_same_major_minor("", ""));
5518 assert!(!is_same_major_minor("1.2.3", ""));
5519 assert!(!is_same_major_minor("", "1.2.3"));
5520 }
5521
5522 #[test]
5523 fn test_requirement_contains_template_placeholder_bare_and_braced() {
5524 assert!(requirement_contains_template_placeholder("$REACT_VERSION"));
5525 assert!(requirement_contains_template_placeholder(
5526 "${REACT_VERSION}"
5527 ));
5528 assert!(requirement_contains_template_placeholder("1.0.0-$BUILD"));
5529 assert!(requirement_contains_template_placeholder(
5530 "v${MAJOR}.${MINOR}"
5531 ));
5532 }
5533
5534 #[test]
5535 fn test_requirement_contains_template_placeholder_lowercase_and_mixed_case() {
5536 assert!(requirement_contains_template_placeholder("$react_version"));
5537 assert!(requirement_contains_template_placeholder(
5538 "${React_Version}"
5539 ));
5540 }
5541
5542 #[test]
5543 fn test_requirement_contains_template_placeholder_ordinary_requirements_not_flagged() {
5544 assert!(!requirement_contains_template_placeholder("1.2.3"));
5545 assert!(!requirement_contains_template_placeholder("^1.2.3"));
5546 assert!(!requirement_contains_template_placeholder("~1.2.3"));
5547 assert!(!requirement_contains_template_placeholder(">=1.0.0 <2.0.0"));
5548 assert!(!requirement_contains_template_placeholder(""));
5549 assert!(!requirement_contains_template_placeholder("price-is-$5"));
5550 assert!(!requirement_contains_template_placeholder("trailing-$"));
5551 assert!(!requirement_contains_template_placeholder("empty-${}"));
5552 assert!(!requirement_contains_template_placeholder("${123}"));
5553 }
5554
5555 #[test]
5556 fn test_requirement_contains_template_placeholder_unclosed_brace_fails_safe() {
5557 assert!(requirement_contains_template_placeholder("${VAR"));
5558 }
5559
5560 #[test]
5561 fn test_requirement_contains_template_placeholder_dollar_paren_form() {
5562 assert!(requirement_contains_template_placeholder(
5563 "$(LODASH_VERSION)"
5564 ));
5565 assert!(requirement_contains_template_placeholder(
5566 "require golang.org/x/net $(NET_VERSION)"
5567 ));
5568 }
5569
5570 /// #1417: unlike `${VAR`'s fail-open precedent, `$(` requires the closing `)` — an
5571 /// unclosed `$(VAR` must NOT be classified as a placeholder on that basis alone.
5572 #[test]
5573 fn test_requirement_contains_template_placeholder_dollar_paren_unclosed_not_matched() {
5574 assert!(!requirement_contains_template_placeholder("$(VAR"));
5575 assert!(!requirement_contains_template_placeholder(
5576 "price-is-$(five"
5577 ));
5578 }
5579
5580 #[test]
5581 fn test_requirement_contains_template_placeholder_dollar_paren_no_identifier_not_matched() {
5582 assert!(!requirement_contains_template_placeholder("$()"));
5583 assert!(!requirement_contains_template_placeholder("$(123)"));
5584 assert!(!requirement_contains_template_placeholder(
5585 "$(SERDE VERSION)"
5586 ));
5587 }
5588
5589 /// Spec 070 / issue #1421: `-` is accepted in subsequent positions inside `$(...)`, real
5590 /// MSBuild property-name syntax (`$(MOD-VERSION)`).
5591 #[test]
5592 fn test_requirement_contains_template_placeholder_dollar_paren_hyphen_form() {
5593 assert!(requirement_contains_template_placeholder("$(MOD-VERSION)"));
5594 assert!(requirement_contains_template_placeholder(
5595 "$(SERDE-VERSION)"
5596 ));
5597 assert!(requirement_contains_template_placeholder(
5598 "1.0.0-$(BUILD-SUFFIX)"
5599 ));
5600 assert!(!requirement_contains_template_placeholder("$(-VERSION)"));
5601 }
5602
5603 /// Spec 070 / issue #1421 impl-critic M2: a trailing hyphen, consecutive hyphens, and a
5604 /// leading underscore followed by a hyphen are all still valid subsequent-position
5605 /// characters and must match; a bracketed content consisting of only a hyphen fails the
5606 /// first-character constraint (never a valid leading character) and must NOT match.
5607 #[test]
5608 fn test_requirement_contains_template_placeholder_dollar_paren_hyphen_edge_cases() {
5609 assert!(requirement_contains_template_placeholder("$(VERSION-)"));
5610 assert!(requirement_contains_template_placeholder("$(MOD--VERSION)"));
5611 assert!(requirement_contains_template_placeholder("$(_-X)"));
5612 assert!(!requirement_contains_template_placeholder("$(-)"));
5613 }
5614
5615 /// Spec 070 / issue #1421: `.` anywhere before the closing `)` remains a deliberate,
5616 /// researched exclusion (not valid MSBuild syntax) — never matched, even combined with a
5617 /// hyphen.
5618 #[test]
5619 fn test_requirement_contains_template_placeholder_dollar_paren_dotted_form_not_matched() {
5620 assert!(!requirement_contains_template_placeholder("$(A.VERSION)"));
5621 assert!(!requirement_contains_template_placeholder(
5622 "$(MOD.SUB-VERSION)"
5623 ));
5624 }
5625
5626 /// #1391 fixture/detector drift guard: every entry in
5627 /// [`crate::conformance::GENERIC_TEMPLATE_PLACEHOLDERS`] (the mandatory conformance
5628 /// fixture every ecosystem's `formatter_conformance!` invocation is checked against) must
5629 /// actually be recognized by the shared detector itself — otherwise a fixture entry that
5630 /// silently stopped matching would make every crate's conformance test pass vacuously
5631 /// instead of proving anything about that form.
5632 #[test]
5633 fn test_generic_template_placeholders_fixture_matches_detector() {
5634 for &placeholder in crate::conformance::GENERIC_TEMPLATE_PLACEHOLDERS {
5635 assert!(
5636 requirement_contains_template_placeholder(placeholder),
5637 "{placeholder:?} (from GENERIC_TEMPLATE_PLACEHOLDERS) must be recognized by \
5638 requirement_contains_template_placeholder"
5639 );
5640 }
5641 }
5642
5643 #[test]
5644 fn test_requirement_contains_template_placeholder_mustache_form() {
5645 assert!(requirement_contains_template_placeholder(
5646 "{{ STD_VERSION }}"
5647 ));
5648 assert!(requirement_contains_template_placeholder("{{STD_VERSION}}"));
5649 assert!(requirement_contains_template_placeholder(
5650 "{{ .NetVersion }}"
5651 ));
5652 assert!(requirement_contains_template_placeholder(
5653 "v1.2-{{ BUILD | default }}"
5654 ));
5655 // Unclosed still fails safe, mirroring the `${VAR` precedent.
5656 assert!(requirement_contains_template_placeholder("{{ VAR"));
5657 }
5658
5659 #[test]
5660 fn test_requirement_contains_template_placeholder_mustache_empty_not_flagged() {
5661 assert!(!requirement_contains_template_placeholder("{{}}"));
5662 assert!(!requirement_contains_template_placeholder("{{ }}"));
5663 }
5664
5665 /// Security M1 follow-up (impl-critic M1): `{% ... %}` Jinja2/Liquid statement tags
5666 /// alongside `{{ ... }}` expression tags.
5667 #[test]
5668 fn test_requirement_contains_template_placeholder_jinja_statement_tag() {
5669 assert!(requirement_contains_template_placeholder("{% if x %}"));
5670 assert!(requirement_contains_template_placeholder("{%if x%}"));
5671 assert!(requirement_contains_template_placeholder("{%- if x -%}"));
5672 assert!(requirement_contains_template_placeholder(
5673 "v1.2-{% BUILD %}"
5674 ));
5675 assert!(requirement_contains_template_placeholder("{% VAR"));
5676 assert!(!requirement_contains_template_placeholder("{%}"));
5677 assert!(!requirement_contains_template_placeholder("{% %}"));
5678 }
5679
5680 #[test]
5681 fn test_requirement_contains_template_placeholder_autoconf_form() {
5682 assert!(requirement_contains_template_placeholder(
5683 "@PACKAGE_VERSION@"
5684 ));
5685 assert!(requirement_contains_template_placeholder("v1.2-@BUILD@"));
5686 // No closing '@' — not flagged (unlike the '$'/'{{' forms, both delimiters required).
5687 assert!(!requirement_contains_template_placeholder(
5688 "@PACKAGE_VERSION"
5689 ));
5690 assert!(!requirement_contains_template_placeholder("me@example.com"));
5691 }
5692
5693 /// impl-critic M1: the dotted/hyphenated CMake/Autotools `configure_file` form — the
5694 /// actual common real-world `@..@` shape, unlike the bare-identifier form above.
5695 #[test]
5696 fn test_requirement_contains_template_placeholder_autoconf_dotted_form() {
5697 assert!(requirement_contains_template_placeholder(
5698 "@project.version@"
5699 ));
5700 assert!(requirement_contains_template_placeholder(
5701 "@PACKAGE_VERSION_MAJOR@"
5702 ));
5703 assert!(requirement_contains_template_placeholder("@some-flag@"));
5704 assert!(requirement_contains_template_placeholder(
5705 "v@project.version@"
5706 ));
5707 // No closing '@' — still not flagged, even with dots in the tail.
5708 assert!(!requirement_contains_template_placeholder(
5709 "@project.version"
5710 ));
5711 }
5712
5713 #[test]
5714 fn test_requirement_contains_template_placeholder_percent_form() {
5715 assert!(requirement_contains_template_placeholder("%VERSION%"));
5716 assert!(requirement_contains_template_placeholder("v1.2-%BUILD%"));
5717 assert!(!requirement_contains_template_placeholder("%VERSION"));
5718 assert!(!requirement_contains_template_placeholder("100%"));
5719 }
5720
5721 #[test]
5722 fn test_requirement_contains_template_placeholder_erb_form() {
5723 assert!(requirement_contains_template_placeholder("<%= version %>"));
5724 assert!(requirement_contains_template_placeholder("<%version%>"));
5725 assert!(requirement_contains_template_placeholder("<%- version %>"));
5726 assert!(!requirement_contains_template_placeholder("<%%>"));
5727 assert!(!requirement_contains_template_placeholder("<% %>"));
5728 }
5729
5730 #[test]
5731 fn test_is_safe_version_string_accepts_ordinary_versions() {
5732 assert!(is_safe_version_string("1.2.3"));
5733 assert!(is_safe_version_string("1.2.3-beta.1+build"));
5734 assert!(is_safe_version_string("v1.2.3"));
5735 }
5736
5737 #[test]
5738 fn test_is_safe_version_string_rejects_empty_or_whitespace() {
5739 assert!(!is_safe_version_string(""));
5740 assert!(!is_safe_version_string(" "));
5741 assert!(!is_safe_version_string("\t\n"));
5742 }
5743
5744 #[test]
5745 fn test_is_safe_version_string_rejects_control_and_structural_characters() {
5746 for bad in [
5747 "1.2.3\n",
5748 "1.2.3\t",
5749 "1.2.3\"",
5750 "1.2.3'",
5751 "1.2.3<",
5752 "1.2.3>",
5753 "1.2.3&",
5754 "1.2.3\\",
5755 "1.0.0\", \"malicious\": \"true",
5756 ] {
5757 assert!(
5758 !is_safe_version_string(bad),
5759 "expected {bad:?} to be rejected"
5760 );
5761 }
5762 }
5763
5764 #[test]
5765 fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
5766 // Regression (critic S2): `$`/`{`/`}` are outside the allowlist, so a Gradle
5767 // `${...}` interpolation payload can never reach a version literal via this gate.
5768 for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
5769 assert!(
5770 !is_safe_version_string(bad),
5771 "expected {bad:?} to be rejected"
5772 );
5773 }
5774 }
5775
5776 #[test]
5777 fn test_is_safe_version_string_rejects_invisible_unicode() {
5778 // Regression (critic M1): `char::is_control()` alone only covers category Cc — the
5779 // bidi override U+202E, zero-width space U+200B, and JS/JSON5 line terminators
5780 // U+2028/U+2029 must also be rejected.
5781 for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
5782 assert!(
5783 !is_safe_version_string(bad),
5784 "expected {bad:?} to be rejected"
5785 );
5786 }
5787 }
5788
5789 #[test]
5790 fn test_is_safe_version_string_accepts_pep440_epoch() {
5791 // PEP 440 epochs (`1!2.0`) are legitimate PyPI versions.
5792 assert!(is_safe_version_string("1!2.0"));
5793 }
5794
5795 #[test]
5796 fn test_is_safe_version_string_length_cap() {
5797 assert!(is_safe_version_string(&"1".repeat(64)));
5798 assert!(!is_safe_version_string(&"1".repeat(65)));
5799 }
5800
5801 #[test]
5802 fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
5803 assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
5804 assert!(is_safe_maven_coordinate_segment("commons-lang3"));
5805 assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
5806 }
5807
5808 #[test]
5809 fn test_is_safe_maven_coordinate_segment_rejects_empty() {
5810 assert!(!is_safe_maven_coordinate_segment(""));
5811 }
5812
5813 #[test]
5814 fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
5815 for bad in [
5816 "commons</artifactId><parent>",
5817 "commons\"",
5818 "commons'",
5819 "commons&",
5820 "commons\nlang3",
5821 "commons\tlang3",
5822 ] {
5823 assert!(
5824 !is_safe_maven_coordinate_segment(bad),
5825 "expected {bad:?} to be rejected"
5826 );
5827 }
5828 }
5829
5830 #[test]
5831 fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
5832 assert!(!is_safe_maven_coordinate_segment(
5833 "org.apache.commons:commons-lang3"
5834 ));
5835 }
5836
5837 #[test]
5838 fn test_is_safe_maven_coordinate_segment_length_cap() {
5839 assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
5840 assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
5841 }
5842
5843 #[test]
5844 fn test_is_safe_registry_url_accepts_real_urls() {
5845 assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
5846 assert!(is_safe_registry_url(
5847 "https://github.com/apple/swift-nio.git"
5848 ));
5849 assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
5850 }
5851
5852 #[test]
5853 fn test_is_safe_registry_url_rejects_non_https_scheme() {
5854 // Every real Swift package registry response is HTTPS; accepting `http://` would
5855 // only hand a compromised registry a transport-downgrade lever.
5856 assert!(!is_safe_registry_url("http://example.com/repo"));
5857 assert!(!is_safe_registry_url("file:///etc/passwd"));
5858 assert!(!is_safe_registry_url("javascript:alert(1)"));
5859 assert!(!is_safe_registry_url("ftp://example.com/repo"));
5860 assert!(!is_safe_registry_url(""));
5861 }
5862
5863 #[test]
5864 fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
5865 for bad in [
5866 "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
5867 "https://evil.example\\",
5868 "https://evil.example\nlet x = 1",
5869 "https://evil.example`echo`",
5870 "https://evil.example<script>",
5871 ] {
5872 assert!(
5873 !is_safe_registry_url(bad),
5874 "expected {bad:?} to be rejected"
5875 );
5876 }
5877 }
5878
5879 #[test]
5880 fn test_is_safe_registry_url_length_cap() {
5881 let prefix = "https://example.com/";
5882 let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
5883 assert_eq!(at_cap.len(), 2048);
5884 assert!(is_safe_registry_url(&at_cap));
5885
5886 let over_cap = format!("{at_cap}a");
5887 assert_eq!(over_cap.len(), 2049);
5888 assert!(!is_safe_registry_url(&over_cap));
5889 }
5890
5891 #[test]
5892 fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
5893 for good in [
5894 "serde", // Cargo
5895 "requests", // PyPI
5896 "@scope/name", // npm/Deno scoped
5897 "monolog/monolog", // Composer vendor/package
5898 "github.com/org/repo", // Go module path
5899 "path", // Dart
5900 "org.apache.commons:commons-lang3", // Gradle group:artifact
5901 "Newtonsoft.Json", // NuGet
5902 "rails", // Bundler
5903 "npm:react", // Deno npm-scheme specifier
5904 "jsr:@std/fs", // Deno jsr-scheme specifier
5905 "github.com/foo/bar~compat", // Go path element with `~`
5906 ] {
5907 assert!(
5908 is_safe_package_name(good),
5909 "expected {good:?} to be accepted"
5910 );
5911 }
5912 }
5913
5914 #[test]
5915 fn test_is_safe_package_name_rejects_empty() {
5916 assert!(!is_safe_package_name(""));
5917 }
5918
5919 #[test]
5920 fn test_is_safe_package_name_rejects_non_ascii() {
5921 // Deliberately excluded: the allowlist is ASCII-only, so a legacy non-ASCII
5922 // npm package name (a handful exist, e.g. Unicode-normalized scopes) is
5923 // rejected rather than risking homograph/normalization tricks in a manifest.
5924 assert!(!is_safe_package_name("café"));
5925 assert!(!is_safe_package_name("пакет"));
5926 }
5927
5928 #[test]
5929 fn test_is_safe_package_name_accepts_dot_dot_shapes() {
5930 // `.`/`/` are individually legal (PyPI dotted names, npm/Composer scopes), so
5931 // `..`/`../..` pass the charset too. This is not a path-traversal risk: every
5932 // sink treats `name` as manifest text (a TOML/JSON/YAML/XML value or a
5933 // string-literal argument), never as a filesystem path.
5934 assert!(is_safe_package_name(".."));
5935 assert!(is_safe_package_name("../.."));
5936 }
5937
5938 #[test]
5939 fn test_is_safe_package_name_rejects_structural_breakout_characters() {
5940 for bad in [
5941 "evil\"\nbackdoor = \"9.9.9",
5942 "evil\", git = \"https://evil",
5943 "evil\\",
5944 "evil'",
5945 "evil<script>",
5946 "evil`echo`",
5947 "evil\ninjected = true",
5948 "evil\tname",
5949 ] {
5950 assert!(
5951 !is_safe_package_name(bad),
5952 "expected {bad:?} to be rejected"
5953 );
5954 }
5955 }
5956
5957 #[test]
5958 fn test_is_safe_package_name_length_cap() {
5959 assert!(is_safe_package_name(&"a".repeat(256)));
5960 assert!(!is_safe_package_name(&"a".repeat(257)));
5961 }
5962
5963 #[test]
5964 fn test_is_safe_feature_name_accepts_real_names() {
5965 for good in ["derive", "std_alloc-v2+extra"] {
5966 assert!(
5967 is_safe_feature_name(good),
5968 "expected {good:?} to be accepted"
5969 );
5970 }
5971 }
5972
5973 #[test]
5974 fn test_is_safe_feature_name_rejects_empty() {
5975 assert!(!is_safe_feature_name(""));
5976 }
5977
5978 #[test]
5979 fn test_is_safe_feature_name_rejects_non_ascii() {
5980 // ASCII-only, so homograph/bidi-spoofing characters are rejected outright.
5981 assert!(!is_safe_feature_name("café"));
5982 // U+202E RIGHT-TO-LEFT OVERRIDE.
5983 assert!(!is_safe_feature_name("evil\u{202E}reversed"));
5984 }
5985
5986 #[test]
5987 fn test_is_safe_feature_name_rejects_enable_syntax() {
5988 // `dep:`/`?`/`/` are enable-syntax, only in a feature's value list, never in its name key.
5989 for bad in ["dep:ravif", "rgb?/serde"] {
5990 assert!(
5991 !is_safe_feature_name(bad),
5992 "expected {bad:?} to be rejected"
5993 );
5994 }
5995 }
5996
5997 #[test]
5998 fn test_is_safe_feature_name_length_cap() {
5999 assert!(is_safe_feature_name(&"a".repeat(256)));
6000 assert!(!is_safe_feature_name(&"a".repeat(257)));
6001 }
6002
6003 #[test]
6004 fn test_is_same_major_minor_partial_versions() {
6005 assert!(is_same_major_minor("1.2", "1.2.3"));
6006 assert!(is_same_major_minor("1.2.3", "1.2"));
6007 }
6008
6009 #[test]
6010 fn test_ecosystem_formatter_defaults() {
6011 let formatter = MOCK_FORMATTER;
6012 assert_eq!(
6013 formatter.normalize_package_name(&pkg("test-pkg")),
6014 "test-pkg"
6015 );
6016 assert_eq!(formatter.yanked_message(), "This version has been yanked");
6017 assert_eq!(formatter.yanked_label(), "*(yanked)*");
6018 }
6019
6020 #[test]
6021 fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
6022 let formatter = MOCK_FORMATTER;
6023 let dep = MockDep {
6024 name: pkg("test-pkg"),
6025 version_req: VersionReq::new("1.0.0"),
6026 version_range: Range::default(),
6027 name_range: Range::default(),
6028 };
6029 assert_eq!(
6030 formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
6031 formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
6032 );
6033 }
6034
6035 #[test]
6036 fn test_ecosystem_formatter_version_satisfies() {
6037 let formatter = MOCK_FORMATTER;
6038
6039 assert!(formatter.version_satisfies_requirement(
6040 &ConcreteVersion::new("1.2.3"),
6041 &VersionReq::new("1.2.3")
6042 ));
6043
6044 assert!(formatter.version_satisfies_requirement(
6045 &ConcreteVersion::new("1.2.3"),
6046 &VersionReq::new("^1.2")
6047 ));
6048 assert!(formatter.version_satisfies_requirement(
6049 &ConcreteVersion::new("1.2.3"),
6050 &VersionReq::new("~1.2")
6051 ));
6052
6053 assert!(
6054 formatter.version_satisfies_requirement(
6055 &ConcreteVersion::new("1.2.3"),
6056 &VersionReq::new("1")
6057 )
6058 );
6059 assert!(formatter.version_satisfies_requirement(
6060 &ConcreteVersion::new("1.2.3"),
6061 &VersionReq::new("1.2")
6062 ));
6063
6064 assert!(!formatter.version_satisfies_requirement(
6065 &ConcreteVersion::new("1.2.3"),
6066 &VersionReq::new("2.0.0")
6067 ));
6068 assert!(!formatter.version_satisfies_requirement(
6069 &ConcreteVersion::new("1.2.3"),
6070 &VersionReq::new("1.3")
6071 ));
6072 }
6073
6074 #[test]
6075 fn test_ecosystem_formatter_custom_normalize() {
6076 struct PyPIFormatter;
6077
6078 impl PackageNaming for PyPIFormatter {
6079 fn normalize_package_name(&self, name: &PackageName) -> String {
6080 name.as_str().to_lowercase().replace('-', "_")
6081 }
6082 }
6083
6084 impl PackageRendering for PyPIFormatter {
6085 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6086 format!(
6087 ">={},<{}",
6088 version,
6089 version.as_str().split('.').next().unwrap_or("0")
6090 )
6091 }
6092
6093 fn package_url(&self, name: &PackageName) -> String {
6094 format!("https://pypi.org/project/{}", name.as_str())
6095 }
6096 }
6097
6098 impl RequirementResolution for PyPIFormatter {}
6099
6100 impl DiagnosticMessages for PyPIFormatter {}
6101
6102 impl DiagnosticPolicy for PyPIFormatter {}
6103
6104 impl SourcePolicy for PyPIFormatter {}
6105
6106 impl OsvNaming for PyPIFormatter {}
6107
6108 let formatter = PyPIFormatter;
6109 assert_eq!(
6110 formatter.normalize_package_name(&pkg("Test-Package")),
6111 "test_package"
6112 );
6113 assert_eq!(
6114 formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
6115 ">=1.2.3,<1"
6116 );
6117 assert_eq!(
6118 formatter.package_url(&pkg("requests")),
6119 "https://pypi.org/project/requests"
6120 );
6121 }
6122
6123 // --- upgrade_status_to_verdict structural staleness (issue #1531) ---
6124
6125 /// A [`Dependency`] whose `source()` is configurable, for exercising the
6126 /// `NonRegistrySource` structural-skip staleness gate — [`MockDep`] always reports
6127 /// [`DependencySource::Registry`], which can't represent "started non-registry, source
6128 /// changed to registry".
6129 struct DepWithSource {
6130 name: PackageName,
6131 name_range: crate::position::Range,
6132 source: DependencySource,
6133 }
6134
6135 impl Dependency for DepWithSource {
6136 fn name(&self) -> &PackageName {
6137 &self.name
6138 }
6139 fn name_range(&self) -> crate::position::Range {
6140 self.name_range
6141 }
6142 fn version_requirement(&self) -> Option<&VersionReq> {
6143 None
6144 }
6145 fn version_range(&self) -> Option<crate::position::Range> {
6146 None
6147 }
6148 fn source(&self) -> DependencySource {
6149 self.source.clone()
6150 }
6151 fn as_any(&self) -> &dyn std::any::Any {
6152 self
6153 }
6154 }
6155
6156 fn structural_skip_entry() -> UpgradeStatus {
6157 UpgradeStatus::StructurallyUnchecked(StructuralSkipReason::NonRegistrySource)
6158 }
6159
6160 /// Mirrors `deps-cargo`'s `CargoFormatter::source_is_public_registry_content` override
6161 /// (impl-critic S1, #1531): a verified crates.io mirror (`AlternateRegistry {
6162 /// mirrors_crates_io: true, .. }`, reached via a `[source.crates-io] replace-with` chain)
6163 /// counts as public-registry content alongside plain `Registry`. A local double rather than
6164 /// depending on `deps-cargo` from `deps-core` (wrong dependency direction).
6165 struct MockMirrorFormatter;
6166 impl PackageNaming for MockMirrorFormatter {}
6167 impl PackageRendering for MockMirrorFormatter {
6168 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6169 version.to_string()
6170 }
6171 fn package_url(&self, name: &PackageName) -> String {
6172 name.as_str().to_string()
6173 }
6174 }
6175 impl RequirementResolution for MockMirrorFormatter {}
6176 impl DiagnosticMessages for MockMirrorFormatter {}
6177 impl DiagnosticPolicy for MockMirrorFormatter {}
6178 impl SourcePolicy for MockMirrorFormatter {
6179 fn source_is_public_registry_content(&self, source: &DependencySource) -> bool {
6180 matches!(
6181 source,
6182 DependencySource::Registry
6183 | DependencySource::AlternateRegistry {
6184 mirrors_crates_io: true,
6185 ..
6186 }
6187 )
6188 }
6189 }
6190 impl OsvNaming for MockMirrorFormatter {}
6191
6192 /// #1531 regression: a dependency whose source is *still* non-registry keeps the existing
6193 /// structural-fallback behavior — the cached `NonRegistrySource` skip is not stale, so it
6194 /// resolves to `NotApplicable` for both the "latest" and per-candidate verdicts.
6195 #[test]
6196 fn structural_skip_same_source_stays_not_applicable() {
6197 let dep = DepWithSource {
6198 name: pkg("vendored-lib"),
6199 name_range: crate::position::Range::default(),
6200 source: DependencySource::Path {
6201 path: "../vendored-lib".to_string(),
6202 },
6203 };
6204 let formatter = crate::test_util::StubFormatter::new();
6205
6206 let mut latest_status = LatestStatusMap::new();
6207 latest_status.insert(
6208 crate::test_util::vuln_key("vendored-lib"),
6209 structural_skip_entry(),
6210 );
6211 assert_eq!(
6212 latest_verdict(
6213 Some(&latest_status),
6214 &dep,
6215 None,
6216 "vendored-lib",
6217 "1.0.0",
6218 &formatter
6219 ),
6220 LatestVerdict::NotApplicable
6221 );
6222
6223 let mut candidate_status = CandidateStatusMap::new();
6224 candidate_status.insert(
6225 crate::test_util::vuln_key("vendored-lib"),
6226 CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
6227 );
6228 assert_eq!(
6229 candidate_verdict(
6230 Some(&candidate_status),
6231 &dep,
6232 None,
6233 "vendored-lib",
6234 "1.0.0",
6235 &formatter
6236 ),
6237 LatestVerdict::NotApplicable
6238 );
6239 }
6240
6241 /// #1531: a dependency that *started* with a non-registry source (git/path) picks up a
6242 /// `CandidateUnverified{NonRegistrySource}` structural entry under its plain name key. If
6243 /// the manifest is then edited so the same dependency now resolves to a registry source,
6244 /// that cached entry is stale until the next phase B run overwrites it — the verdict must
6245 /// fail closed to `Unverified` for the new registry version, never silently pass through
6246 /// as `NotApplicable` ("safe to offer").
6247 #[test]
6248 fn structural_skip_source_changed_to_registry_is_not_stale_safe() {
6249 let dep = DepWithSource {
6250 name: pkg("vendored-lib"),
6251 name_range: crate::position::Range::default(),
6252 source: DependencySource::Registry,
6253 };
6254 let formatter = crate::test_util::StubFormatter::new();
6255
6256 let mut latest_status = LatestStatusMap::new();
6257 latest_status.insert(
6258 crate::test_util::vuln_key("vendored-lib"),
6259 structural_skip_entry(),
6260 );
6261 assert_eq!(
6262 latest_verdict(
6263 Some(&latest_status),
6264 &dep,
6265 None,
6266 "vendored-lib",
6267 "2.0.0",
6268 &formatter
6269 ),
6270 LatestVerdict::Unverified
6271 );
6272
6273 let mut candidate_status = CandidateStatusMap::new();
6274 candidate_status.insert(
6275 crate::test_util::vuln_key("vendored-lib"),
6276 CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
6277 );
6278 assert_eq!(
6279 candidate_verdict(
6280 Some(&candidate_status),
6281 &dep,
6282 None,
6283 "vendored-lib",
6284 "2.0.0",
6285 &formatter
6286 ),
6287 LatestVerdict::Unverified
6288 );
6289 }
6290
6291 /// #1531 impl-critic S1: phase B classifies `NonRegistrySource` via
6292 /// `formatter.source_is_public_registry_content`, not a bare `DependencySource::Registry`
6293 /// match — an ecosystem like `deps-cargo` widens that predicate to also accept a verified
6294 /// crates.io mirror (`AlternateRegistry { mirrors_crates_io: true, .. }`). A path dependency
6295 /// edited to such a mirrored registry dependency must be recognized as a source change too,
6296 /// not stay wrongly `NotApplicable` just because it isn't the literal `Registry` variant.
6297 #[test]
6298 fn structural_skip_source_changed_to_mirrored_alternate_registry_is_not_stale_safe() {
6299 let dep = DepWithSource {
6300 name: pkg("vendored-lib"),
6301 name_range: crate::position::Range::default(),
6302 source: DependencySource::AlternateRegistry {
6303 index: "https://crates-mirror.example.com/index".to_string(),
6304 mirrors_crates_io: true,
6305 },
6306 };
6307 let formatter = MockMirrorFormatter;
6308
6309 let mut latest_status = LatestStatusMap::new();
6310 latest_status.insert(
6311 crate::test_util::vuln_key("vendored-lib"),
6312 structural_skip_entry(),
6313 );
6314 assert_eq!(
6315 latest_verdict(
6316 Some(&latest_status),
6317 &dep,
6318 None,
6319 "vendored-lib",
6320 "2.0.0",
6321 &formatter
6322 ),
6323 LatestVerdict::Unverified
6324 );
6325
6326 let mut candidate_status = CandidateStatusMap::new();
6327 candidate_status.insert(
6328 crate::test_util::vuln_key("vendored-lib"),
6329 CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
6330 );
6331 assert_eq!(
6332 candidate_verdict(
6333 Some(&candidate_status),
6334 &dep,
6335 None,
6336 "vendored-lib",
6337 "2.0.0",
6338 &formatter
6339 ),
6340 LatestVerdict::Unverified
6341 );
6342 }
6343
6344 /// #1624 critique M3: `UnmappableName` is structural but — unlike `NonRegistrySource` —
6345 /// [`LatestStatusMap`] still records a real candidate version for it (see
6346 /// `deps_engine::classify::osv::build_latest_check_targets`), so it stays a
6347 /// `CandidateUnverified{version, reason}` entry there while [`CandidateStatusMap`] (which
6348 /// has no single-version slot for the whole dependency) represents the identical skip as
6349 /// `CandidateStatuses::Structural`. Both must resolve to `NotApplicable`.
6350 #[test]
6351 fn unmappable_name_resolves_to_not_applicable_for_latest_and_candidate() {
6352 let dep = DepWithSource {
6353 name: pkg("jsr-pinned"),
6354 name_range: crate::position::Range::default(),
6355 source: DependencySource::Registry,
6356 };
6357 let formatter = crate::test_util::StubFormatter::new();
6358
6359 let mut latest_status = LatestStatusMap::new();
6360 latest_status.insert(
6361 crate::test_util::vuln_key("jsr-pinned"),
6362 UpgradeStatus::CandidateUnverified {
6363 version: ConcreteVersion::new("1.0.0"),
6364 reason: SkipReason::UnmappableName,
6365 },
6366 );
6367 assert_eq!(
6368 latest_verdict(
6369 Some(&latest_status),
6370 &dep,
6371 None,
6372 "jsr-pinned",
6373 "1.0.0",
6374 &formatter
6375 ),
6376 LatestVerdict::NotApplicable
6377 );
6378
6379 let mut candidate_status = CandidateStatusMap::new();
6380 candidate_status.insert(
6381 crate::test_util::vuln_key("jsr-pinned"),
6382 CandidateStatuses::Structural(StructuralSkipReason::UnmappableName),
6383 );
6384 assert_eq!(
6385 candidate_verdict(
6386 Some(&candidate_status),
6387 &dep,
6388 None,
6389 "jsr-pinned",
6390 "1.0.0",
6391 &formatter
6392 ),
6393 LatestVerdict::NotApplicable
6394 );
6395 }
6396
6397 /// Spec 076 SC-014: `fallback_edit_excludes_newer`, one test per [`FallbackEditRejection`]
6398 /// variant plus `Writable`. `deps-core` cannot depend on the ecosystem crates, so this
6399 /// module uses two stub formatters (a semver-backed stub mirroring Cargo's real semantics,
6400 /// and a NuGet-floor stub) — the same probe values are pinned against REAL formatters in
6401 /// `deps-cargo`/`deps-nuget` as part of T005/SC-018.
6402 mod fallback_edit_excludes_newer_tests {
6403 use super::*;
6404 use crate::ParseResult;
6405 use crate::edit::{ManifestEdit, ManifestReparse};
6406 use crate::position::Position;
6407
6408 /// Real-semver-backed matcher, mirroring Cargo's actual resolution semantics.
6409 struct SemverMatcher(semver::VersionReq);
6410 impl RequirementMatcher for SemverMatcher {
6411 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
6412 semver::Version::parse(version.as_str())
6413 .ok()
6414 .map(|v| self.0.matches(&v))
6415 }
6416 fn strict_prerelease_exclusion(&self) -> bool {
6417 true
6418 }
6419 }
6420
6421 /// SC-014's "semver-backed stub": real `semver::VersionReq` compilation, default
6422 /// `is_bounded_requirement_up_to_date`/`bounded_requirement_already_resolves_to` (Cargo-like).
6423 struct SemverFormatter;
6424 impl PackageNaming for SemverFormatter {}
6425 impl PackageRendering for SemverFormatter {
6426 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6427 version.to_string()
6428 }
6429 fn package_url(&self, name: &PackageName) -> String {
6430 name.as_str().to_string()
6431 }
6432 }
6433 impl RequirementResolution for SemverFormatter {
6434 fn compile_bounded_requirement(
6435 &self,
6436 requirement: BoundedVersionReq<'_>,
6437 ) -> Option<Box<dyn RequirementMatcher>> {
6438 semver::VersionReq::parse(requirement.as_str())
6439 .ok()
6440 .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
6441 }
6442 }
6443 impl DiagnosticMessages for SemverFormatter {}
6444 impl DiagnosticPolicy for SemverFormatter {}
6445 impl SourcePolicy for SemverFormatter {}
6446 impl OsvNaming for SemverFormatter {}
6447
6448 /// Same as [`SemverFormatter`], but `compile_bounded_requirement` fails for the literal text
6449 /// `"uncompilable"` — isolates the a1 `EditedUncompilable` check from a0, which a
6450 /// formatter that always fails to compile could not do (phase 1 would reject first).
6451 struct SelectivelyUncompilableFormatter;
6452 impl PackageNaming for SelectivelyUncompilableFormatter {}
6453 impl PackageRendering for SelectivelyUncompilableFormatter {
6454 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6455 version.to_string()
6456 }
6457 fn package_url(&self, name: &PackageName) -> String {
6458 name.as_str().to_string()
6459 }
6460 }
6461 impl RequirementResolution for SelectivelyUncompilableFormatter {
6462 fn compile_bounded_requirement(
6463 &self,
6464 requirement: BoundedVersionReq<'_>,
6465 ) -> Option<Box<dyn RequirementMatcher>> {
6466 if requirement.as_str() == "uncompilable" {
6467 return None;
6468 }
6469 semver::VersionReq::parse(requirement.as_str())
6470 .ok()
6471 .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
6472 }
6473 }
6474 impl DiagnosticMessages for SelectivelyUncompilableFormatter {}
6475 impl DiagnosticPolicy for SelectivelyUncompilableFormatter {}
6476 impl SourcePolicy for SelectivelyUncompilableFormatter {}
6477 impl OsvNaming for SelectivelyUncompilableFormatter {}
6478
6479 /// A formatter with no `compile_bounded_requirement` override at all (trait default `None`) —
6480 /// for the a0 `OriginalUncompilable` check.
6481 struct NoCompileFormatter;
6482 impl PackageNaming for NoCompileFormatter {}
6483 impl PackageRendering for NoCompileFormatter {
6484 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6485 version.to_string()
6486 }
6487 fn package_url(&self, name: &PackageName) -> String {
6488 name.as_str().to_string()
6489 }
6490 }
6491 impl RequirementResolution for NoCompileFormatter {}
6492 impl DiagnosticMessages for NoCompileFormatter {}
6493 impl DiagnosticPolicy for NoCompileFormatter {}
6494 impl SourcePolicy for NoCompileFormatter {}
6495 impl OsvNaming for NoCompileFormatter {}
6496
6497 /// Issue #1580 (CWE-400 defense-in-depth mirroring #1472/#1578): otherwise
6498 /// semver-backed like [`SemverFormatter`] — a real compiling matcher, so the a0-pre/a1-pre
6499 /// gates in `fallback_edit_excludes_newer` are the only thing rejecting an oversized
6500 /// requirement (`compile_bounded_requirement` cannot even receive one).
6501 struct SemverBackedFormatter;
6502 impl PackageNaming for SemverBackedFormatter {}
6503 impl PackageRendering for SemverBackedFormatter {
6504 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6505 version.to_string()
6506 }
6507 fn package_url(&self, name: &PackageName) -> String {
6508 name.as_str().to_string()
6509 }
6510 }
6511 impl RequirementResolution for SemverBackedFormatter {
6512 fn compile_bounded_requirement(
6513 &self,
6514 requirement: BoundedVersionReq<'_>,
6515 ) -> Option<Box<dyn RequirementMatcher>> {
6516 semver::VersionReq::parse(requirement.as_str())
6517 .ok()
6518 .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
6519 }
6520 }
6521 impl DiagnosticMessages for SemverBackedFormatter {}
6522 impl DiagnosticPolicy for SemverBackedFormatter {}
6523 impl SourcePolicy for SemverBackedFormatter {}
6524 impl OsvNaming for SemverBackedFormatter {}
6525
6526 /// A floor matcher: `matches` is membership at-or-above the floor (mirrors NuGet's
6527 /// bare `Version="X"` shape), but resolution never goes below OR above the floor
6528 /// itself — a floor always resolves to its own lowest member.
6529 struct NugetFloorMatcher(semver::Version);
6530 impl RequirementMatcher for NugetFloorMatcher {
6531 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
6532 semver::Version::parse(version.as_str())
6533 .ok()
6534 .map(|v| v >= self.0)
6535 }
6536 fn strict_prerelease_exclusion(&self) -> bool {
6537 false
6538 }
6539 }
6540
6541 /// SC-014's "NuGet-floor stub" (spec 076 M2): `is_bounded_requirement_up_to_date` and
6542 /// `bounded_requirement_already_resolves_to` overridden with the floor rule — floor at or
6543 /// above target is up to date; a floor resolves ONLY to itself, never forward.
6544 struct NugetFloorFormatter;
6545 impl PackageNaming for NugetFloorFormatter {}
6546 impl PackageRendering for NugetFloorFormatter {
6547 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6548 version.to_string()
6549 }
6550 fn package_url(&self, name: &PackageName) -> String {
6551 name.as_str().to_string()
6552 }
6553 }
6554 impl RequirementResolution for NugetFloorFormatter {
6555 fn compile_bounded_requirement(
6556 &self,
6557 requirement: BoundedVersionReq<'_>,
6558 ) -> Option<Box<dyn RequirementMatcher>> {
6559 semver::Version::parse(requirement.as_str())
6560 .ok()
6561 .map(|floor| Box::new(NugetFloorMatcher(floor)) as Box<dyn RequirementMatcher>)
6562 }
6563 fn is_bounded_requirement_up_to_date(
6564 &self,
6565 requirement: BoundedVersionReq<'_>,
6566 latest: &ConcreteVersion,
6567 ) -> bool {
6568 semver::Version::parse(requirement.as_str())
6569 .ok()
6570 .zip(semver::Version::parse(latest.as_str()).ok())
6571 .is_some_and(|(floor, latest)| floor >= latest)
6572 }
6573 fn bounded_requirement_already_resolves_to(
6574 &self,
6575 requirement: BoundedVersionReq<'_>,
6576 target: &ConcreteVersion,
6577 ) -> bool {
6578 semver::Version::parse(requirement.as_str())
6579 .ok()
6580 .zip(semver::Version::parse(target.as_str()).ok())
6581 .is_some_and(|(floor, target)| floor == target)
6582 }
6583 }
6584 impl DiagnosticMessages for NugetFloorFormatter {}
6585 impl DiagnosticPolicy for NugetFloorFormatter {}
6586 impl SourcePolicy for NugetFloorFormatter {}
6587 impl OsvNaming for NugetFloorFormatter {}
6588
6589 /// Fix-cycle (#1571) stub matcher for a `!=`-exclusion requirement grammar
6590 /// (PyPI/Composer/Bundler all support one): `requirement` is `"<range>,!=<excluded>"`,
6591 /// `<range>` compiled with real `semver::VersionReq` and `<excluded>` an exact,
6592 /// string-compared exclusion — mirrors the shape enough to prove the guard's floor
6593 /// comparison alone cannot see a hole punched inside an otherwise-matching span.
6594 struct ExclusionMatcher {
6595 range: semver::VersionReq,
6596 excluded: String,
6597 }
6598 impl RequirementMatcher for ExclusionMatcher {
6599 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
6600 if version.as_str() == self.excluded {
6601 return Some(false);
6602 }
6603 semver::Version::parse(version.as_str())
6604 .ok()
6605 .map(|v| self.range.matches(&v))
6606 }
6607 fn strict_prerelease_exclusion(&self) -> bool {
6608 true
6609 }
6610 // Fix-cycle (#1571 critic S1): the intensional signal a real `!=` matcher
6611 // (PyPI/Composer/Bundler) provides — mirrors this stub's own `excluded` field
6612 // directly, the same way `Pep440Matcher`/`ComposerMatcher`/`RubygemsMatcher` read
6613 // their own parsed `!=` term instead of inferring it from `available`.
6614 fn explicitly_excludes(&self, version: &ConcreteVersion) -> bool {
6615 version.as_str() == self.excluded
6616 }
6617 }
6618
6619 struct ExclusionFormatter;
6620 impl PackageNaming for ExclusionFormatter {}
6621 impl PackageRendering for ExclusionFormatter {
6622 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
6623 version.to_string()
6624 }
6625 fn package_url(&self, name: &PackageName) -> String {
6626 name.as_str().to_string()
6627 }
6628 }
6629 impl RequirementResolution for ExclusionFormatter {
6630 fn compile_bounded_requirement(
6631 &self,
6632 requirement: BoundedVersionReq<'_>,
6633 ) -> Option<Box<dyn RequirementMatcher>> {
6634 let (range, excluded) = requirement.as_str().split_once(",!=")?;
6635 let range = semver::VersionReq::parse(range).ok()?;
6636 Some(Box::new(ExclusionMatcher {
6637 range,
6638 excluded: excluded.to_string(),
6639 }) as Box<dyn RequirementMatcher>)
6640 }
6641 // Never "already up to date" — isolates the new check from c0, mirroring how
6642 // `SemverFormatter`'s own default already keeps c0/d0 independent for its tests.
6643 fn is_bounded_requirement_up_to_date(
6644 &self,
6645 _requirement: BoundedVersionReq<'_>,
6646 _latest: &ConcreteVersion,
6647 ) -> bool {
6648 false
6649 }
6650 }
6651 impl DiagnosticMessages for ExclusionFormatter {}
6652 impl DiagnosticPolicy for ExclusionFormatter {}
6653 impl SourcePolicy for ExclusionFormatter {}
6654 impl OsvNaming for ExclusionFormatter {}
6655
6656 struct StubDep {
6657 name: PackageName,
6658 requirement: Option<VersionReq>,
6659 version_start: Position,
6660 }
6661 impl Dependency for StubDep {
6662 fn name(&self) -> &PackageName {
6663 &self.name
6664 }
6665 fn name_range(&self) -> Range {
6666 Range::default()
6667 }
6668 fn version_requirement(&self) -> Option<&VersionReq> {
6669 self.requirement.as_ref()
6670 }
6671 fn version_range(&self) -> Option<Range> {
6672 Some(Range::new(self.version_start, self.version_start))
6673 }
6674 fn source(&self) -> crate::parser::DependencySource {
6675 crate::parser::DependencySource::Registry
6676 }
6677 fn as_any(&self) -> &dyn std::any::Any {
6678 self
6679 }
6680 }
6681
6682 struct StubParseResult {
6683 deps: Vec<StubDep>,
6684 uri: url::Url,
6685 }
6686 impl ParseResult for StubParseResult {
6687 fn dependencies(&self) -> Vec<&dyn Dependency> {
6688 self.deps.iter().map(|d| d as &dyn Dependency).collect()
6689 }
6690 fn workspace_root(&self) -> Option<&std::path::Path> {
6691 None
6692 }
6693 fn uri(&self) -> &url::Url {
6694 &self.uri
6695 }
6696 fn as_any(&self) -> &dyn std::any::Any {
6697 self
6698 }
6699 }
6700
6701 fn dep(requirement: &str) -> StubDep {
6702 StubDep {
6703 name: pkg("pkg"),
6704 requirement: Some(VersionReq::new(requirement)),
6705 version_start: Position::new(0, 0),
6706 }
6707 }
6708
6709 /// Matches `dep()`'s own `version_range()` (a zero-width span at `version_start`) so
6710 /// the D5 `CandidateSpanMismatch` precondition passes for every test that isn't
6711 /// specifically exercising it.
6712 fn edit() -> ManifestEdit {
6713 ManifestEdit {
6714 range: Range::new(Position::new(0, 0), Position::new(0, 0)),
6715 new_text: "x".to_string(),
6716 }
6717 }
6718
6719 fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
6720 strs.iter().map(|s| ConcreteVersion::new(*s)).collect()
6721 }
6722
6723 fn reparsed_to(requirement: &str) -> impl ManifestReparse {
6724 let requirement = requirement.to_string();
6725 move |_content: &str| -> Option<Box<dyn ParseResult>> {
6726 Some(Box::new(StubParseResult {
6727 deps: vec![StubDep {
6728 name: pkg("pkg"),
6729 requirement: Some(VersionReq::new(requirement.as_str())),
6730 version_start: Position::new(0, 0),
6731 }],
6732 uri: crate::test_util::test_uri("/test/manifest.toml"),
6733 }))
6734 }
6735 }
6736
6737 /// Issue #1580: R0 exceeds `MAX_REQUIREMENT_LEN` — the a0-pre gate must reject before
6738 /// `compile_bounded_requirement` is ever called; `SemverBackedFormatter`
6739 /// compiles normally, so only the gate can produce `Rejected`.
6740 #[test]
6741 fn original_oversized_a0_pre() {
6742 let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
6743 let verdict = fallback_edit_excludes_newer(
6744 &SemverBackedFormatter,
6745 &reparsed_to("1.1.0"),
6746 "content",
6747 &dep(&oversized),
6748 &edit(),
6749 &ConcreteVersion::new("1.1.0"),
6750 &versions(&["1.1.0"]),
6751 );
6752 assert_eq!(
6753 verdict,
6754 FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalOversized)
6755 );
6756 }
6757
6758 #[test]
6759 fn original_uncompilable_a0() {
6760 let verdict = fallback_edit_excludes_newer(
6761 &NoCompileFormatter,
6762 &reparsed_to("1.1.0"),
6763 "content",
6764 &dep("^1.0"),
6765 &edit(),
6766 &ConcreteVersion::new("1.1.0"),
6767 &versions(&["1.1.0"]),
6768 );
6769 assert_eq!(
6770 verdict,
6771 FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalUncompilable)
6772 );
6773 }
6774
6775 #[test]
6776 fn original_already_up_to_date_c0() {
6777 // NuGet-shaped floor `2.0.0`; a fallback below the floor is already "up to date".
6778 let verdict = fallback_edit_excludes_newer(
6779 &NugetFloorFormatter,
6780 &reparsed_to("1.9.0"),
6781 "content",
6782 &dep("2.0.0"),
6783 &edit(),
6784 &ConcreteVersion::new("1.9.0"),
6785 &versions(&["1.9.0"]),
6786 );
6787 assert_eq!(
6788 verdict,
6789 FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalAlreadyUpToDate)
6790 );
6791 }
6792
6793 #[test]
6794 fn original_resolves_past_fallback_d0() {
6795 // Absent-path downgrade: unedited `^2.0` already resolves to 2.0.0, strictly newer
6796 // than the 1.9.0 fallback.
6797 let verdict = fallback_edit_excludes_newer(
6798 &SemverFormatter,
6799 &reparsed_to("1.9.0"),
6800 "content",
6801 &dep("^2.0"),
6802 &edit(),
6803 &ConcreteVersion::new("1.9.0"),
6804 &versions(&["2.0.0", "1.9.0"]),
6805 );
6806 assert_eq!(
6807 verdict,
6808 FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalResolvesPastFallback)
6809 );
6810 }
6811
6812 #[test]
6813 fn original_excludes_fallback_via_not_equal() {
6814 // R0 ">=1.0.0,<2.0.0,!=1.5.0" (PyPI/Composer/Bundler `!=` shape): 1.5.0 sits inside
6815 // the nominal `[1.0.0,2.0.0)` span but is punched out by the exclusion, while 1.6.0
6816 // still matches and is strictly newer — the floor comparison alone (d0) would pass
6817 // this (the floor, 1.0.0, is not newer than 1.5.0), so only the #1571 check catches
6818 // it.
6819 let verdict = fallback_edit_excludes_newer(
6820 &ExclusionFormatter,
6821 &reparsed_to("1.5.0"),
6822 "content",
6823 &dep(">=1.0.0,<2.0.0,!=1.5.0"),
6824 &edit(),
6825 &ConcreteVersion::new("1.5.0"),
6826 &versions(&["1.6.0", "1.5.0", "1.0.0"]),
6827 );
6828 assert_eq!(
6829 verdict,
6830 FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalExcludesFallback)
6831 );
6832 }
6833
6834 #[test]
6835 fn original_excludes_fallback_via_not_equal_is_caught_even_when_vacuous() {
6836 // Critic fix-cycle S1: NO listed `available` entry strictly newer than `fallback`
6837 // matches R0 here (2.0.0 fails the `<2.0.0` ceiling too), so the purely structural
6838 // "some newer entry still matches" heuristic alone is vacuous — it cannot tell this
6839 // apart from `>=1.0.0,<1.5.0` (a legitimate ceiling) over the identical `available`
6840 // list. Only `RequirementMatcher::explicitly_excludes` catches it.
6841 let verdict = fallback_edit_excludes_newer(
6842 &ExclusionFormatter,
6843 &reparsed_to("1.5.0"),
6844 "content",
6845 &dep(">=1.0.0,<2.0.0,!=1.5.0"),
6846 &edit(),
6847 &ConcreteVersion::new("1.5.0"),
6848 &versions(&["2.0.0", "1.5.0", "1.0.0"]),
6849 );
6850 assert_eq!(
6851 verdict,
6852 FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalExcludesFallback)
6853 );
6854 }
6855
6856 #[test]
6857 fn ceiling_exceeding_fallback_is_not_excluded_via_not_equal_check() {
6858 // Cargo-shaped out-of-range acceptance (FR-025): `^1.0` fails `matches` for a
6859 // fallback of 2.5.0 too, but no R0-matching entry is newer than it — the #1571
6860 // check must not fire here, or it would reject spec 076's intentional
6861 // ceiling-exceeding case.
6862 let verdict = fallback_edit_excludes_newer(
6863 &SemverFormatter,
6864 &reparsed_to("^2.5.0"),
6865 "content",
6866 &dep("^1.0"),
6867 &edit(),
6868 &ConcreteVersion::new("2.5.0"),
6869 &versions(&["3.0.0", "2.5.0", "1.0.0"]),
6870 );
6871 assert_eq!(verdict, FallbackEditVerdict::Writable);
6872 }
6873
6874 #[test]
6875 fn reparse_failed() {
6876 // R0 "1.0" is a caret [1.0.0,2.0.0) — fallback 1.5.0 sits at its own floor (the
6877 // only listed entry, matching), so phase 1 passes and re-parse is actually reached.
6878 let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
6879 let verdict = fallback_edit_excludes_newer(
6880 &SemverFormatter,
6881 &reparse,
6882 "content",
6883 &dep("1.0"),
6884 &edit(),
6885 &ConcreteVersion::new("1.5.0"),
6886 &versions(&["1.5.0"]),
6887 );
6888 assert_eq!(
6889 verdict,
6890 FallbackEditVerdict::Rejected(FallbackEditRejection::ReparseFailed)
6891 );
6892 }
6893
6894 #[test]
6895 fn occurrence_not_unique() {
6896 let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> {
6897 Some(Box::new(StubParseResult {
6898 deps: vec![
6899 StubDep {
6900 name: pkg("pkg"),
6901 requirement: Some(VersionReq::new("1.5.0")),
6902 version_start: Position::new(0, 0),
6903 },
6904 StubDep {
6905 name: pkg("pkg"),
6906 requirement: Some(VersionReq::new("1.5.0")),
6907 version_start: Position::new(0, 0),
6908 },
6909 ],
6910 uri: crate::test_util::test_uri("/test/manifest.toml"),
6911 }) as Box<dyn ParseResult>)
6912 };
6913 let verdict = fallback_edit_excludes_newer(
6914 &SemverFormatter,
6915 &reparse,
6916 "content",
6917 &dep("1.0"),
6918 &edit(),
6919 &ConcreteVersion::new("1.5.0"),
6920 &versions(&["1.5.0"]),
6921 );
6922 assert_eq!(
6923 verdict,
6924 FallbackEditVerdict::Rejected(FallbackEditRejection::OccurrenceNotUnique)
6925 );
6926 }
6927
6928 /// Issue #1580: R1 (the re-parsed EDITED requirement) exceeds `MAX_REQUIREMENT_LEN` —
6929 /// R0 ("1.0") is small and compiles fine, so phase 1 passes and phase 2 is reached; the
6930 /// a1-pre gate must then reject before `compile_bounded_requirement(R1)` is ever called.
6931 #[test]
6932 fn edited_oversized_a1_pre() {
6933 let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
6934 let verdict = fallback_edit_excludes_newer(
6935 &SemverBackedFormatter,
6936 &reparsed_to(&oversized),
6937 "content",
6938 &dep("1.0"),
6939 &edit(),
6940 &ConcreteVersion::new("1.5.0"),
6941 &versions(&["1.5.0"]),
6942 );
6943 assert_eq!(
6944 verdict,
6945 FallbackEditVerdict::Rejected(FallbackEditRejection::EditedOversized)
6946 );
6947 }
6948
6949 #[test]
6950 fn edited_uncompilable_a1() {
6951 let verdict = fallback_edit_excludes_newer(
6952 &SelectivelyUncompilableFormatter,
6953 &reparsed_to("uncompilable"),
6954 "content",
6955 &dep("1.0"),
6956 &edit(),
6957 &ConcreteVersion::new("1.5.0"),
6958 &versions(&["1.5.0"]),
6959 );
6960 assert_eq!(
6961 verdict,
6962 FallbackEditVerdict::Rejected(FallbackEditRejection::EditedUncompilable)
6963 );
6964 }
6965
6966 #[test]
6967 fn edited_excludes_fallback_b1() {
6968 // The re-parse stub yields an unsatisfiable-for-the-fallback `^5` — round-1 critic
6969 // M5: the written edit must actually express the fallback, not merely compile.
6970 let verdict = fallback_edit_excludes_newer(
6971 &SemverFormatter,
6972 &reparsed_to("^5"),
6973 "content",
6974 &dep("1.0"),
6975 &edit(),
6976 &ConcreteVersion::new("1.5.0"),
6977 &versions(&["1.5.0"]),
6978 );
6979 assert_eq!(
6980 verdict,
6981 FallbackEditVerdict::Rejected(FallbackEditRejection::EditedExcludesFallback)
6982 );
6983 }
6984
6985 #[test]
6986 fn edited_admits_newer_d1() {
6987 // R0 "2.0.0" is a caret [2.0.0,3.0.0), whose floor (2.0.1, the only listed match
6988 // at or above the fallback's own position) is at the fallback — phase 1 passes.
6989 // The re-parsed edit "2.0.1" is ALSO a caret ([2.0.1,3.0.0)), which still admits
6990 // the fresh 2.1.0 — d1 must reject that auto-follow regardless of d0's own verdict.
6991 let verdict = fallback_edit_excludes_newer(
6992 &SemverFormatter,
6993 &reparsed_to("2.0.1"),
6994 "content",
6995 &dep("2.0.0"),
6996 &edit(),
6997 &ConcreteVersion::new("2.0.1"),
6998 &versions(&["2.1.0", "2.0.1"]),
6999 );
7000 assert_eq!(
7001 verdict,
7002 FallbackEditVerdict::Rejected(FallbackEditRejection::EditedAdmitsNewer)
7003 );
7004 }
7005
7006 #[test]
7007 fn writable() {
7008 let verdict = fallback_edit_excludes_newer(
7009 &SemverFormatter,
7010 &reparsed_to("^2.5.0"),
7011 "content",
7012 &dep("2.0"),
7013 &edit(),
7014 &ConcreteVersion::new("2.5.0"),
7015 &versions(&["3.0.0", "2.5.0"]),
7016 );
7017 assert_eq!(verdict, FallbackEditVerdict::Writable);
7018 }
7019
7020 /// Spec 076 M2: the d0 `resolves_to` loosening for a NuGet `Located` floor. Spec 075
7021 /// rejected this (raw `matches` sees the floor as admitting the fresh version); this
7022 /// guard writes it, since a floor resolves only to its own lowest member.
7023 #[test]
7024 fn nuget_located_floor_loosening_is_writable() {
7025 // The floor's own value ("1.0.0") must be listed for the fix-cycle floor-comparison
7026 // scan to find it — realistic for production `available` (every published version,
7027 // unfiltered), since the floor is the user's own already-published declared pin.
7028 let verdict = fallback_edit_excludes_newer(
7029 &NugetFloorFormatter,
7030 &reparsed_to("1.1.0"),
7031 "content",
7032 &dep("1.0.0"),
7033 &edit(),
7034 &ConcreteVersion::new("1.1.0"),
7035 &versions(&["1.2.0", "1.1.0", "1.0.0"]),
7036 );
7037 assert_eq!(verdict, FallbackEditVerdict::Writable);
7038 }
7039
7040 /// Fix-cycle (impl-critic M2, D5 precondition): a `candidate` whose range does not
7041 /// equal this occurrence's own `version_range()` (e.g. an edit built for a different
7042 /// line) must be rejected before any check reads it — not silently treated as
7043 /// `Writable` just because R0/R1 happen to agree (a NuGet-floor R0 that already equals
7044 /// its own fallback resolves to `Writable` under every other check here).
7045 #[test]
7046 fn candidate_span_mismatch_is_rejected() {
7047 let mismatched_candidate = ManifestEdit {
7048 range: Range::new(Position::new(1, 0), Position::new(1, 0)),
7049 new_text: "1.1.0".to_string(),
7050 };
7051 let verdict = fallback_edit_excludes_newer(
7052 &NugetFloorFormatter,
7053 &reparsed_to("1.1.0"),
7054 "content",
7055 &dep("1.0.0"),
7056 &mismatched_candidate,
7057 &ConcreteVersion::new("1.1.0"),
7058 &versions(&["1.2.0", "1.1.0"]),
7059 );
7060 assert_eq!(
7061 verdict,
7062 FallbackEditVerdict::Rejected(FallbackEditRejection::CandidateSpanMismatch)
7063 );
7064 }
7065 }
7066}