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::VulnerabilityMap;
10use crate::position::{Position, Range};
11use crate::{
12 ConcreteVersion, Dependency, Deprecation, DepsDevClient, EcosystemId, FetchFailure,
13 LicenseSource, PackageName, RemovalStatus,
14};
15
16#[cfg(feature = "lsp-responses")]
17mod code_actions;
18#[cfg(feature = "lsp-responses")]
19mod code_lenses;
20mod diagnostics;
21mod formatter;
22mod git_ref;
23#[cfg(feature = "lsp-responses")]
24mod hover;
25mod in_use_version;
26#[cfg(feature = "lsp-responses")]
27mod inlay_hints;
28#[cfg(test)]
29pub(crate) mod test_support;
30
31#[cfg(feature = "lsp-responses")]
32pub use code_actions::generate_code_actions;
33#[cfg(feature = "lsp-responses")]
34pub use code_lenses::{
35 PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
36 dedup_overlapping_edits, generate_code_lenses,
37};
38pub use diagnostics::{
39 DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities, LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE,
40 UNSATISFIABLE_DIAGNOSTIC_CODE, compile_requirement_unless, generate_diagnostics_from_cache,
41 redact_name_for_diagnostic, requirement_is_unsatisfiable,
42 sanitize_advisory_text_for_diagnostic, sanitize_and_truncate_for_diagnostic,
43 truncate_for_diagnostic,
44};
45pub use formatter::{
46 DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
47 PackageRendering, RequirementResolution, SourcePolicy,
48};
49pub use git_ref::{
50 MAX_FALLBACK_SCAN_BYTES, MarkedScalar, byte_span_to_range, is_full_sha, is_null_tag,
51 is_partial_semver_shaped, is_plain_null, is_tag_shaped, locate_value_span, marker_byte_offset,
52 match_v_prefix_style,
53};
54#[cfg(feature = "lsp-responses")]
55pub use git_ref::{
56 ResolvedShaPin, ShaPinning, build_sha_pin_action, sha_pin_text_edit, splice_resolved_line,
57};
58#[cfg(feature = "lsp-responses")]
59pub use hover::{CMD_DOT_FOOTER, generate_hover};
60pub use in_use_version::{concrete_pin_version, is_full_semver_shape, resolve_in_use_version};
61#[cfg(feature = "lsp-responses")]
62pub use inlay_hints::generate_inlay_hints;
63
64/// Maximum number of recent versions hover's "Recent versions" section renders.
65///
66/// Also the walk target for registries (NuGet, npm) that must fetch publish times for
67/// only the versions actually rendered, rather than the entire version history.
68pub const HOVER_RECENT_VERSIONS: usize = 8;
69
70/// Registry version data for one package, fetched together in a single round trip.
71///
72/// `latest` and `available` are deliberately asymmetric — this is load-bearing, not an
73/// oversight:
74/// - `latest` comes from this ecosystem's own `Registry::select_latest_matching(.., "*")`
75/// pick, which excludes yanked (and, for semver/node-semver `*`, prerelease) versions —
76/// the same value `get_latest_matching` returned before this type existed.
77/// - `available` is the **unfiltered** `get_versions` output: every published version,
78/// newest-first, yanked and prerelease entries included.
79///
80/// The unsatisfiable-requirement check (see `crate::lsp_helpers::requirement_is_unsatisfiable`)
81/// scans `available` and deliberately does not filter it: a requirement that only matches a
82/// yanked or prerelease version is still satisfied, so filtering `available` the same way
83/// `latest` is filtered would produce false "no published version satisfies" warnings.
84///
85/// `yanked` is the subset of `available` (same version-string encoding) that the registry
86/// reported as yanked/deprecated, paired with each entry's [`RemovalStatus`]. It exists
87/// because `Registry::get_latest_matching` — the call that used to populate this cache —
88/// filters yanked entries out by contract on every current registry implementation, so a
89/// per-version yanked flag threaded through *that* call would always read `false` (see
90/// #233). `available` now comes from the unfiltered `get_versions` instead, which does
91/// observe yanked entries, so `yanked` is derived from that same fetch rather than
92/// discarded.
93///
94/// The status rides alongside each version (rather than a bare membership list) so
95/// [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s #247 "requirement satisfiable
96/// only by a yanked version" check can gate its own package-level-deprecation suppression
97/// on `AdvisoryDeprecated` specifically, never on a genuine `Yanked` finding — mirroring
98/// [`VersionData::outcomes`]'s D5 gate for the #263 in-use-version check (see #437).
99#[non_exhaustive]
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct PackageVersions {
102 /// Latest usable version for this package.
103 pub latest: ConcreteVersion,
104 /// Every published version, newest-first, unfiltered.
105 pub available: Arc<[ConcreteVersion]>,
106 /// Subset of `available` reported as yanked/deprecated by the registry, each paired
107 /// with its [`RemovalStatus`].
108 pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
109 /// When `latest` was published, if the registry exposes it. `None` when
110 /// the ecosystem doesn't wire [`crate::Version::published_at`] or the fetch
111 /// never ran.
112 ///
113 /// Deliberately a field on this single per-package struct rather than a
114 /// second parallel map keyed alongside `latest` — the earlier two-map
115 /// design (issue #227 critique C3) let `latest` and its age drift apart
116 /// silently whenever one map was updated (e.g. lockfile-resolved
117 /// overwrite) without the other. Bundling them here makes that
118 /// desync impossible: whoever sets `latest` sets `published_at` too.
119 pub published_at: Option<crate::freshness::PublishTime>,
120}
121
122impl PackageVersions {
123 /// Constructs a `PackageVersions` from its two required fields, with [`Self::yanked`] and
124 /// [`Self::published_at`] left empty/`None` — chain [`Self::with_yanked`] and/or
125 /// [`Self::with_published_at`] to attach them.
126 ///
127 /// Needed because [`Self`] is `#[non_exhaustive]`: a struct literal only works inside
128 /// this crate, so every other crate (including test code) must go through this
129 /// constructor instead.
130 ///
131 /// # Examples
132 ///
133 /// ```
134 /// use deps_core::{ConcreteVersion, PackageVersions};
135 /// use std::sync::Arc;
136 ///
137 /// let versions = PackageVersions::new(
138 /// ConcreteVersion::new("2.0.0"),
139 /// Arc::from(vec![ConcreteVersion::new("2.0.0"), ConcreteVersion::new("1.0.0")]),
140 /// );
141 /// assert_eq!(versions.latest, "2.0.0");
142 /// assert_eq!(versions.available.len(), 2);
143 /// assert!(versions.yanked.is_empty());
144 /// ```
145 #[must_use]
146 pub fn new(latest: ConcreteVersion, available: Arc<[ConcreteVersion]>) -> Self {
147 Self {
148 latest,
149 available,
150 yanked: Arc::from(Vec::new()),
151 published_at: None,
152 }
153 }
154
155 /// Attaches the registry's yanked/deprecated-version findings. See [`Self::yanked`].
156 #[must_use]
157 pub fn with_yanked(mut self, yanked: Arc<[(ConcreteVersion, RemovalStatus)]>) -> Self {
158 self.yanked = yanked;
159 self
160 }
161
162 /// Attaches when `latest` was published. See [`Self::published_at`].
163 #[must_use]
164 pub const fn with_published_at(mut self, published_at: crate::freshness::PublishTime) -> Self {
165 self.published_at = Some(published_at);
166 self
167 }
168
169 /// Builds a `PackageVersions` from only the "latest" version string, with `available`
170 /// populated as the single-element list `[latest]`.
171 ///
172 /// **Test-only in intent.** The one-element `available` this produces is a real, if
173 /// small, version list — it is not "empty/unknown", so `requirement_is_unsatisfiable`
174 /// will evaluate a requirement against it. Do **not** use this for a lock-file-only
175 /// population path that has no real version list to offer (use
176 /// [`latest_without_list`](Self::latest_without_list) there instead, which leaves
177 /// `available` genuinely empty) — that exact substitution is the false-positive N5 was
178 /// written to prevent: it would let the unsatisfiable-requirement check produce a
179 /// verdict against a fabricated one-entry list before any registry fetch has run. Real
180 /// registry fetches always populate `available` from the full `get_versions` result
181 /// instead of using either constructor.
182 ///
183 /// # Examples
184 ///
185 /// ```
186 /// use deps_core::{ConcreteVersion, PackageVersions};
187 ///
188 /// let versions = PackageVersions::latest_only("1.0.214");
189 /// assert_eq!(versions.latest, "1.0.214");
190 /// assert_eq!(&*versions.available, &[ConcreteVersion::new("1.0.214")]);
191 /// ```
192 pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
193 let latest = latest.into();
194 let available = Arc::from(vec![latest.clone()]);
195 Self {
196 latest,
197 available,
198 yanked: Arc::from(Vec::new()),
199 published_at: None,
200 }
201 }
202
203 /// Builds a `PackageVersions` with no version list — used where only the "latest" value
204 /// is known and probing further would be misleading, notably the lock-file population
205 /// path (`crates/deps-lsp/src/document/lifecycle.rs`), which must not populate a
206 /// plausible-looking one-element `available` list before any registry fetch has run: the
207 /// unsatisfiable-requirement check treats an empty `available` as "still loading, skip".
208 ///
209 /// # Examples
210 ///
211 /// ```
212 /// use deps_core::PackageVersions;
213 ///
214 /// let versions = PackageVersions::latest_without_list("1.0.195");
215 /// assert_eq!(versions.latest, "1.0.195");
216 /// assert!(versions.available.is_empty());
217 /// ```
218 pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
219 Self {
220 latest: latest.into(),
221 available: Arc::from(Vec::new()),
222 yanked: Arc::from(Vec::new()),
223 published_at: None,
224 }
225 }
226}
227
228/// Everything the lifecycle fetch learned about one package, keyed by its normalized name.
229///
230/// All three channels can hold simultaneously for the same package — this is load-bearing,
231/// not an incidental shape. The D5 status gate in
232/// [`generate_diagnostics_from_cache`] reads the [`Self::deprecation`] and [`Self::yanked`]
233/// entries for the same normalized name together and compares their [`RemovalStatus`], and on
234/// the didChange path a surviving deprecation can coexist with a later fetch failure. A single
235/// enum variant per package could not express that overlap.
236#[non_exhaustive]
237#[derive(Debug, Clone, Default, PartialEq)]
238pub struct DependencyOutcome {
239 /// The version found yanked (the in-use version, or `latest` when only `latest` itself is
240 /// yanked), paired with its [`RemovalStatus`]. See [`VersionData::outcomes`]'s prior
241 /// semantics (#233/#263).
242 pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
243 /// Package-level deprecation finding (#205). See [`Deprecation`].
244 pub deprecation: Option<Deprecation>,
245 /// Registry fetch errored, timed out, or was never attempted (#267). See [`FetchFailure`].
246 pub fetch_failure: Option<FetchFailure>,
247 /// The registry fetch succeeded (no [`Self::fetch_failure`]) but produced zero
248 /// comparable versions — e.g. a real GitHub repository whose only tags don't parse
249 /// as full `major.minor.patch` semver (`dtolnay/rust-toolchain`'s sole tag `v1`,
250 /// issue #550). Distinct from both an absent [`DependencyOutcome`] entry ("never
251 /// fetched") and [`Self::fetch_failure`] ("couldn't be asked"): this package
252 /// demonstrably exists, so [R5](crate::lsp_helpers::generate_diagnostics_from_cache)
253 /// must not claim it is unknown.
254 pub no_comparable_versions: bool,
255}
256
257impl DependencyOutcome {
258 /// True when none of the four channels are set.
259 #[must_use]
260 pub const fn is_empty(&self) -> bool {
261 self.yanked.is_none()
262 && self.deprecation.is_none()
263 && self.fetch_failure.is_none()
264 && !self.no_comparable_versions
265 }
266}
267
268/// Normalized-package-name -> [`DependencyOutcome`] map.
269///
270/// A newtype rather than a bare `HashMap` so the empty-entry pruning invariant (an entry is
271/// removed once all three of its channels are cleared) lives in one place, and so test
272/// fixtures get chainable `with_*` constructors instead of building three ad-hoc `HashMap`s.
273/// Mirrors the existing [`crate::osv::VulnerabilityMap`] convention of a `String`-keyed map by
274/// normalized name.
275#[derive(Debug, Clone, Default, PartialEq)]
276pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
277
278impl DependencyOutcomes {
279 /// Creates an empty map.
280 #[must_use]
281 pub fn new() -> Self {
282 Self::default()
283 }
284
285 /// Looks up the full outcome recorded for `name`.
286 #[must_use]
287 pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
288 self.0.get(name)
289 }
290
291 /// Looks up the yanked-version finding for `name`.
292 #[must_use]
293 pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
294 self.0.get(name)?.yanked.as_ref()
295 }
296
297 /// Looks up the package-level deprecation finding for `name`.
298 #[must_use]
299 pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
300 self.0.get(name)?.deprecation.as_ref()
301 }
302
303 /// Looks up the fetch-failure finding for `name`.
304 #[must_use]
305 pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
306 self.0.get(name)?.fetch_failure.as_ref()
307 }
308
309 /// True when the registry fetch for `name` succeeded but produced zero comparable
310 /// versions (#550). See [`DependencyOutcome::no_comparable_versions`].
311 #[must_use]
312 pub fn no_comparable_versions(&self, name: &str) -> bool {
313 self.0.get(name).is_some_and(|o| o.no_comparable_versions)
314 }
315
316 /// Records a yanked-version finding for `name`, creating the entry if absent.
317 pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
318 self.0.entry(name).or_default().yanked = Some(yanked);
319 }
320
321 /// Records a package-level deprecation finding for `name`, creating the entry if absent.
322 pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
323 self.0.entry(name).or_default().deprecation = Some(deprecation);
324 }
325
326 /// Records a fetch-failure finding for `name`, creating the entry if absent.
327 pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
328 self.0.entry(name).or_default().fetch_failure = Some(failure);
329 }
330
331 /// Records a fetch-failure finding for `name` only if one is not already recorded,
332 /// creating the entry if absent.
333 pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
334 self.0
335 .entry(name)
336 .or_default()
337 .fetch_failure
338 .get_or_insert(failure);
339 }
340
341 /// Records that `name`'s registry fetch succeeded but produced zero comparable
342 /// versions (#550), creating the entry if absent.
343 pub fn set_no_comparable_versions(&mut self, name: String) {
344 self.0.entry(name).or_default().no_comparable_versions = true;
345 }
346
347 /// Clears the no-comparable-versions channel for `name`, pruning the entry if it
348 /// becomes empty.
349 pub fn clear_no_comparable_versions(&mut self, name: &str) {
350 if let Some(entry) = self.0.get_mut(name) {
351 entry.no_comparable_versions = false;
352 self.prune(name);
353 }
354 }
355
356 /// Clears the yanked-version channel for `name`, pruning the entry if it becomes empty.
357 pub fn clear_yanked(&mut self, name: &str) {
358 if let Some(entry) = self.0.get_mut(name) {
359 entry.yanked = None;
360 self.prune(name);
361 }
362 }
363
364 /// Clears the deprecation channel for `name`, pruning the entry if it becomes empty.
365 pub fn clear_deprecation(&mut self, name: &str) {
366 if let Some(entry) = self.0.get_mut(name) {
367 entry.deprecation = None;
368 self.prune(name);
369 }
370 }
371
372 /// Clears the fetch-failure channel for `name`, pruning the entry if it becomes empty.
373 pub fn clear_fetch_failure(&mut self, name: &str) {
374 if let Some(entry) = self.0.get_mut(name) {
375 entry.fetch_failure = None;
376 self.prune(name);
377 }
378 }
379
380 /// Removes the whole entry for `name`, regardless of which channels are set.
381 pub fn remove(&mut self, name: &str) {
382 self.0.remove(name);
383 }
384
385 /// Clears the fetch-failure channel for every entry, pruning any that become empty.
386 ///
387 /// Used when a forced re-fetch (e.g. a live-reloaded registry-routing setting, deps-lsp
388 /// issue #592) makes every previously recorded fetch-failure finding untrustworthy: the
389 /// routing itself changed, so a failure recorded under the old routing must not survive
390 /// to be merged with results fetched under the new one.
391 pub fn clear_all_fetch_failures(&mut self) {
392 let names: Vec<String> = self.0.keys().cloned().collect();
393 for name in names {
394 self.clear_fetch_failure(&name);
395 }
396 }
397
398 fn prune(&mut self, name: &str) {
399 if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
400 self.0.remove(name);
401 }
402 }
403
404 /// Number of entries currently stored.
405 #[must_use]
406 pub fn len(&self) -> usize {
407 self.0.len()
408 }
409
410 /// True when no entries are stored.
411 #[must_use]
412 pub fn is_empty(&self) -> bool {
413 self.0.is_empty()
414 }
415
416 /// Number of entries with a yanked-version finding, for logging/`Debug`.
417 #[must_use]
418 pub fn yanked_count(&self) -> usize {
419 self.0.values().filter(|o| o.yanked.is_some()).count()
420 }
421
422 /// Number of entries with a deprecation finding, for logging/`Debug`.
423 #[must_use]
424 pub fn deprecation_count(&self) -> usize {
425 self.0.values().filter(|o| o.deprecation.is_some()).count()
426 }
427
428 /// Number of entries with a fetch-failure finding, for logging/`Debug`.
429 #[must_use]
430 pub fn fetch_failure_count(&self) -> usize {
431 self.0
432 .values()
433 .filter(|o| o.fetch_failure.is_some())
434 .count()
435 }
436
437 /// Chainable builder recording a yanked-version finding. Test/fixture ergonomics.
438 #[must_use]
439 pub fn with_yanked(
440 mut self,
441 name: impl Into<String>,
442 yanked: (ConcreteVersion, RemovalStatus),
443 ) -> Self {
444 self.set_yanked(name.into(), yanked);
445 self
446 }
447
448 /// Chainable builder recording a package-level deprecation finding. Test/fixture
449 /// ergonomics.
450 #[must_use]
451 pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
452 self.set_deprecation(name.into(), deprecation);
453 self
454 }
455
456 /// Chainable builder recording a fetch-failure finding. Test/fixture ergonomics.
457 #[must_use]
458 pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
459 self.set_fetch_failure(name.into(), failure);
460 self
461 }
462
463 /// Chainable builder recording a no-comparable-versions finding (#550). Test/fixture
464 /// ergonomics.
465 #[must_use]
466 pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
467 self.set_no_comparable_versions(name.into());
468 self
469 }
470}
471
472/// Bundles the two per-package version maps (`cached`, `resolved`) that LSP handlers pass
473/// together everywhere.
474///
475/// Grouping them prevents accidentally swapping the two map arguments at a call site, since
476/// the compiler can no longer typecheck them positionally.
477///
478/// # Examples
479///
480/// ```
481/// use deps_core::{ConcreteVersion, PackageName, PackageVersions, VersionData};
482/// use std::collections::HashMap;
483///
484/// let mut cached = HashMap::new();
485/// cached.insert(PackageName::new("serde"), PackageVersions::latest_only("1.0.214"));
486///
487/// let mut resolved = HashMap::new();
488/// resolved.insert(PackageName::new("serde"), ConcreteVersion::new("1.0.200"));
489///
490/// let versions = VersionData::new(&cached, &resolved);
491///
492/// assert_eq!(versions.cached.get("serde").map(|v| v.latest.as_str()), Some("1.0.214"));
493/// assert_eq!(versions.resolved.get("serde").map(ConcreteVersion::as_str), Some("1.0.200"));
494/// ```
495#[non_exhaustive]
496#[derive(Debug, Clone, Copy)]
497pub struct VersionData<'a> {
498 /// Latest known versions and full version lists from the registry, keyed by package name.
499 pub cached: &'a HashMap<PackageName, PackageVersions>,
500 /// Versions actually resolved in the lock file, keyed by package name.
501 pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
502 /// Every lock-file-resolved version for a package name, when more than one is retained
503 /// (issue #649) — additive alongside [`Self::resolved`], which stays the single
504 /// collapsed value for the common case. `None` by default (most call sites have no such
505 /// map); [`crate::lsp_helpers::resolve_in_use_version`] and [`crate::osv::vulnerability_keys`]
506 /// consult it to disambiguate two manifest occurrences of one resolved name (e.g. a
507 /// Cargo `package = "..."` rename pinning an older major) by each occurrence's own
508 /// `version_requirement()`, falling back to [`Self::resolved`] when a name has at most
509 /// one candidate.
510 pub resolved_version_candidates: Option<&'a HashMap<PackageName, Vec<ConcreteVersion>>>,
511 /// OSV scan results, keyed by normalized package name. `None` when no
512 /// scan has run yet (e.g. the feature is disabled) — distinct from an
513 /// empty map, which would mean "scanned, nothing found".
514 pub vulnerabilities: Option<&'a VulnerabilityMap>,
515 /// Yanked, deprecation, and fetch-failure findings from the most recent lifecycle fetch,
516 /// keyed by normalized package name — see [`DependencyOutcome`] for what each channel
517 /// means and why they must stay readable together off one lookup (D5 in
518 /// [`generate_diagnostics_from_cache`], #233/#263/#205/#267). `None` when no fetch has run
519 /// yet — distinct from an empty map, which would mean "checked, nothing found".
520 pub outcomes: Option<&'a DependencyOutcomes>,
521 /// This document's ecosystem, when the caller has one to give. `None` in
522 /// most test fixtures and a handful of ecosystem-crate self-tests that
523 /// predate this field.
524 ///
525 /// Enables two occurrence-aware refinements added for #394 (duplicate
526 /// dependency names no longer collapsing into one shared finding):
527 /// [`generate_diagnostics_from_cache`] only emits a yanked-version
528 /// diagnostic on the occurrence whose own in-use version actually
529 /// matches the recorded finding (S1), and the vulnerability lookups in
530 /// `generate_diagnostics_from_cache`, [`generate_hover`], and
531 /// `generate_code_actions` prefer a version-qualified
532 /// [`crate::osv::VulnerabilityMap`] key over the plain name when more
533 /// than one occurrence of a name has a distinct in-use version (S2).
534 /// When `None`, both fall back to their pre-#394 name-only behavior.
535 pub ecosystem: Option<EcosystemId>,
536 /// Whether `network.offline` is set (issue #483). When `true`, [`generate_hover`]
537 /// appends a footer stating that version *and vulnerability* data were not checked —
538 /// deliberately more specific than a bare "showing cached data" notice, since
539 /// `hover.rs`'s `Some(ScanOutcome::Skipped(_)) | None` arm renders nothing for an
540 /// offline OSV skip, which would otherwise look identical to a scanned-and-clean
541 /// dependency.
542 pub offline: bool,
543 /// The deps.dev client to fetch a supply-chain trust signal through
544 /// (spec 037), when the caller wants hover to attempt one. `None` by
545 /// default and left `None` by every surface but `handlers/hover.rs`
546 /// (deps-lsp) — diagnostics, code actions, inlay hints, and code lenses
547 /// never set this, which is what makes FR-010's hover-only scope
548 /// structural rather than convention: those surfaces cannot reach
549 /// deps.dev because they are never handed a client. `&'a Arc<..>`, not
550 /// `&'a DepsDevClient`, so [`generate_hover`] can clone the `Arc` into a
551 /// detached background task.
552 pub trust: Option<&'a Arc<DepsDevClient>>,
553 /// Background-pre-fetched license data for ecosystems `deps_dev_system` doesn't
554 /// cover and whose hot-path registry response carries no license field — Dart,
555 /// Swift, Gradle, Deno (issue #660, spec 010 plan §1 tier 3), keyed by raw
556 /// (unnormalized) package name. `None` by default. Populated from `DocumentState`'s
557 /// per-document pre-fetch cache (`document::osv_scan::run_license_prefetch` on
558 /// document open/change — mirrors [`Self::vulnerabilities`]'s "populated by a
559 /// background task, read synchronously here" shape) by `handlers/hover.rs` (deps-lsp)
560 /// for hover, and by `handlers/diagnostics.rs`'s `textDocument/diagnostic` pull path
561 /// for [`generate_diagnostics_from_cache`]'s license-policy rule (issue #661) — see
562 /// [`Self::license_policy`].
563 ///
564 /// **What version this actually reflects is per-ecosystem, not uniformly the
565 /// resolved version** (critic S1 — corrects a previous blanket claim here that it
566 /// "only ever covers the resolved version"): Gradle's Maven Central POM fetch and
567 /// Deno's JSR per-version API are genuinely fetched at the dependency's resolved/
568 /// in-use version. Dart's pub.dev `/score` endpoint is per-*package*, not
569 /// per-version — it reflects pana's detection on whatever pub.dev most recently
570 /// scored, unrelated to which version is resolved. Swift's GitHub
571 /// `GET /repos/{owner}/{repo}` reflects the repository's default branch, not the
572 /// resolved version's tag. Every source here is still a background pre-fetch gated
573 /// on a version having been resolved at all (no in-use version means nothing to
574 /// look up), but only Gradle/Deno are actually version-*specific* in what they
575 /// return.
576 pub license_prefetch: Option<&'a HashMap<PackageName, Vec<String>>>,
577 /// How [`Self::license_prefetch`]'s (and, for a resolved-version match, the
578 /// registry-declared) license strings are sourced (issue #688), consulted by
579 /// [`generate_hover`] for the "(detected)" qualifier and by
580 /// [`generate_diagnostics_from_cache`]'s license-policy rule to decide whether
581 /// [`crate::licenses::resolve_license_entries`] must normalize free text before
582 /// evaluation. `None` in most test fixtures and any handler that doesn't go through
583 /// [`crate::Ecosystem::generate_hover`]/[`crate::Ecosystem::generate_diagnostics`]'s
584 /// default implementation, which is what actually attaches this field via
585 /// `self.license_source()` — falls back to
586 /// [`LicenseSource::RegistryDeclaredSpdx`] (`Default`) wherever consumed.
587 pub license_source: Option<LicenseSource>,
588 /// SPDX allow-list/deny-list license policy (issue #661, spec 010 Phase 2), when the
589 /// caller wants diagnostics evaluated against one. `None` by default.
590 ///
591 /// Unlike [`Self::trust`], this is **not** hover-only-scoped: `deps-lsp`'s
592 /// `handlers/diagnostics.rs::generate_diagnostics_internal` attaches the currently
593 /// configured policy (cached on `ServerState`, kept live-updated by
594 /// `Backend::initialize`/`did_change_configuration`) unconditionally, so every
595 /// diagnostics-generation call site — the `textDocument/diagnostic` pull path *and*
596 /// every push-path background refresh (fetch-completion, watched-config reparse,
597 /// lock-file change) — evaluates the same policy. An earlier revision scoped this
598 /// field to the pull path only, following `Self::trust`'s hover-only precedent; that
599 /// was a false analogy (issue #660/#661 critic C1) — `trust` is safe hover-only
600 /// because hover has exactly one producer per request, but diagnostics has multiple
601 /// producers all replacing the same client-visible `publish_diagnostics` set, so a
602 /// caller-scoped policy meant the license diagnostic flickered in and out on every
603 /// edit and was invisible to push-only clients.
604 /// [`generate_diagnostics_from_cache`]'s license-policy rule reads this alongside
605 /// [`Self::license_prefetch`] — so today it only actually fires for tier-3 ecosystems
606 /// (Dart/Swift/Deno; Gradle is explicitly excluded, see that rule's doc comment), the
607 /// only ones `license_prefetch` covers; this field and the rule are otherwise
608 /// ecosystem-agnostic and need no change as `license_prefetch`'s coverage widens.
609 pub license_policy: Option<&'a LicensePolicy>,
610}
611
612impl<'a> VersionData<'a> {
613 /// Creates a new `VersionData` from the cached and resolved version maps.
614 ///
615 /// `vulnerabilities` starts `None`; chain [`Self::with_vulnerabilities`]
616 /// to attach a scan result.
617 ///
618 /// # Examples
619 ///
620 /// ```
621 /// use deps_core::VersionData;
622 /// use std::collections::HashMap;
623 ///
624 /// let cached = HashMap::new();
625 /// let resolved = HashMap::new();
626 /// let versions = VersionData::new(&cached, &resolved);
627 /// assert!(versions.cached.is_empty());
628 /// assert!(versions.vulnerabilities.is_none());
629 /// ```
630 pub fn new(
631 cached: &'a HashMap<PackageName, PackageVersions>,
632 resolved: &'a HashMap<PackageName, ConcreteVersion>,
633 ) -> Self {
634 Self {
635 cached,
636 resolved,
637 resolved_version_candidates: None,
638 vulnerabilities: None,
639 outcomes: None,
640 ecosystem: None,
641 offline: false,
642 trust: None,
643 license_prefetch: None,
644 license_source: None,
645 license_policy: None,
646 }
647 }
648
649 /// Attaches the per-name lock-file candidates map, enabling the per-occurrence
650 /// disambiguation described on [`Self::resolved_version_candidates`].
651 ///
652 /// # Examples
653 ///
654 /// ```
655 /// use deps_core::VersionData;
656 /// use std::collections::HashMap;
657 ///
658 /// let cached = HashMap::new();
659 /// let resolved = HashMap::new();
660 /// let candidates = HashMap::new();
661 /// let versions =
662 /// VersionData::new(&cached, &resolved).with_resolved_version_candidates(&candidates);
663 /// assert!(versions.resolved_version_candidates.is_some());
664 /// ```
665 #[must_use]
666 pub const fn with_resolved_version_candidates(
667 mut self,
668 candidates: &'a HashMap<PackageName, Vec<ConcreteVersion>>,
669 ) -> Self {
670 self.resolved_version_candidates = Some(candidates);
671 self
672 }
673
674 /// Attaches an OSV scan result to this `VersionData`.
675 ///
676 /// # Examples
677 ///
678 /// ```
679 /// use deps_core::VersionData;
680 /// use deps_core::osv::VulnerabilityMap;
681 /// use std::collections::HashMap;
682 ///
683 /// let cached = HashMap::new();
684 /// let resolved = HashMap::new();
685 /// let vulns = VulnerabilityMap::new();
686 /// let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns);
687 /// assert!(versions.vulnerabilities.is_some());
688 /// ```
689 #[must_use]
690 pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
691 self.vulnerabilities = Some(vulnerabilities);
692 self
693 }
694
695 /// Attaches yanked, deprecation, and fetch-failure findings to this `VersionData`. See
696 /// [`Self::outcomes`].
697 ///
698 /// # Examples
699 ///
700 /// ```
701 /// use deps_core::VersionData;
702 /// use deps_core::lsp_helpers::DependencyOutcomes;
703 /// use std::collections::HashMap;
704 ///
705 /// let cached = HashMap::new();
706 /// let resolved = HashMap::new();
707 /// let outcomes = DependencyOutcomes::new();
708 /// let versions = VersionData::new(&cached, &resolved).with_outcomes(&outcomes);
709 /// assert!(versions.outcomes.is_some());
710 /// ```
711 #[must_use]
712 pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
713 self.outcomes = Some(outcomes);
714 self
715 }
716
717 /// Attaches this document's ecosystem, enabling the occurrence-aware
718 /// refinements described on [`Self::ecosystem`].
719 ///
720 /// # Examples
721 ///
722 /// ```
723 /// use deps_core::{EcosystemId, VersionData};
724 /// use std::collections::HashMap;
725 ///
726 /// let cached = HashMap::new();
727 /// let resolved = HashMap::new();
728 /// let versions = VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::Cargo);
729 /// assert_eq!(versions.ecosystem, Some(EcosystemId::Cargo));
730 /// ```
731 #[must_use]
732 pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
733 self.ecosystem = Some(ecosystem);
734 self
735 }
736
737 /// Marks this `VersionData` as built while `network.offline` was set, so
738 /// [`generate_hover`] appends its offline footer.
739 ///
740 /// # Examples
741 ///
742 /// ```
743 /// use deps_core::VersionData;
744 /// use std::collections::HashMap;
745 ///
746 /// let cached = HashMap::new();
747 /// let resolved = HashMap::new();
748 /// let versions = VersionData::new(&cached, &resolved).with_offline(true);
749 /// assert!(versions.offline);
750 /// ```
751 #[must_use]
752 pub const fn with_offline(mut self, offline: bool) -> Self {
753 self.offline = offline;
754 self
755 }
756
757 /// Attaches a deps.dev client, enabling [`generate_hover`] to attempt a
758 /// supply-chain trust signal for the hovered dependency. See
759 /// [`Self::trust`].
760 ///
761 /// # Examples
762 ///
763 /// ```
764 /// use deps_core::{DepsDevClient, HttpCache, VersionData};
765 /// use std::collections::HashMap;
766 /// use std::sync::Arc;
767 ///
768 /// let cached = HashMap::new();
769 /// let resolved = HashMap::new();
770 /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
771 /// let versions = VersionData::new(&cached, &resolved).with_trust(&client);
772 /// assert!(versions.trust.is_some());
773 /// ```
774 #[must_use]
775 pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
776 self.trust = Some(client);
777 self
778 }
779
780 /// Attaches tier-3 pre-fetched license data. See [`Self::license_prefetch`].
781 ///
782 /// # Examples
783 ///
784 /// ```
785 /// use deps_core::VersionData;
786 /// use std::collections::HashMap;
787 ///
788 /// let cached = HashMap::new();
789 /// let resolved = HashMap::new();
790 /// let licenses = HashMap::new();
791 /// let versions = VersionData::new(&cached, &resolved).with_license_prefetch(&licenses);
792 /// assert!(versions.license_prefetch.is_some());
793 /// ```
794 #[must_use]
795 pub const fn with_license_prefetch(
796 mut self,
797 licenses: &'a HashMap<PackageName, Vec<String>>,
798 ) -> Self {
799 self.license_prefetch = Some(licenses);
800 self
801 }
802
803 /// Attaches this ecosystem's license source. See [`Self::license_source`].
804 ///
805 /// # Examples
806 ///
807 /// ```
808 /// use deps_core::{LicenseSource, VersionData};
809 /// use std::collections::HashMap;
810 ///
811 /// let cached = HashMap::new();
812 /// let resolved = HashMap::new();
813 /// let versions =
814 /// VersionData::new(&cached, &resolved).with_license_source(LicenseSource::PomFreeText);
815 /// assert_eq!(versions.license_source, Some(LicenseSource::PomFreeText));
816 /// ```
817 #[must_use]
818 pub const fn with_license_source(mut self, source: LicenseSource) -> Self {
819 self.license_source = Some(source);
820 self
821 }
822
823 /// Attaches a license policy for diagnostics evaluation. See [`Self::license_policy`].
824 ///
825 /// # Examples
826 ///
827 /// ```
828 /// use deps_core::{LicensePolicy, VersionData};
829 /// use std::collections::HashMap;
830 ///
831 /// let cached = HashMap::new();
832 /// let resolved = HashMap::new();
833 /// let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
834 /// let versions = VersionData::new(&cached, &resolved).with_license_policy(&policy);
835 /// assert!(versions.license_policy.is_some());
836 /// ```
837 #[must_use]
838 pub const fn with_license_policy(mut self, policy: &'a LicensePolicy) -> Self {
839 self.license_policy = Some(policy);
840 self
841 }
842}
843
844/// Wall-clock budget `deps-lsp`'s completion handler gives an ecosystem's
845/// `generate_completions` before treating it as a timeout.
846///
847/// Past this, the handler skips the fallback search rather than treating a
848/// fast-but-empty result as "genuinely no results"
849/// (`crates/deps-lsp/src/handlers/completion.rs`).
850///
851/// A registry-backed completion path that retries internally on failure (e.g.
852/// `deps-maven`'s `search`, #274) must size its own total retry budget to
853/// exceed this constant: finishing sooner with an empty/error result is
854/// indistinguishable, at the call site, from a query that legitimately has no
855/// matches, and triggers a wasted (and, for a struggling registry, likely to also
856/// fail) fallback search rather than the handler's existing skip-on-timeout path.
857///
858/// Lives here (ungated), not in [`crate::completion`] (`#[cfg(feature =
859/// "lsp-responses")]`), and is re-exported from there for that feature's consumers:
860/// a registry-backed completion path's own retry-budget constant (e.g. `deps-maven`'s
861/// `RECENT_FAILURE_TTL`) is genuine `Registry::search` behavior, not LSP-response-shaped,
862/// and must stay available under `--no-default-features` too (issue #1083 critic M1) —
863/// one definition, not a hand-copied duplicate per feature state.
864pub const COMPLETION_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
865
866/// Wall-clock budget hover and code-action generation give the primary
867/// `Registry::get_versions_from` fetch for the dependency under the cursor.
868///
869/// Unlike completion, code lens, inlay hints, and the background fetch task, hover
870/// and code actions previously awaited this call with no deadline at all — an
871/// ecosystem whose registry client retries across several candidate URLs
872/// sequentially (e.g. `deps-maven`'s Gradle Plugin Portal fallback) could multiply
873/// `HttpCache`'s own per-request timeout into tens of seconds of blocked, user-facing
874/// latency on a single hover or lightbulb request (issue #1204). This is the *only*
875/// deadline needed to fix that: it wraps the whole `get_versions_from` future,
876/// cancelling it — candidate-URL loop included — at the ecosystem-registry level, so
877/// no per-ecosystem inner budget is required on top of it. On elapse, hover falls back
878/// to its existing `.ok()`-based basic-card degradation (the same path a genuine fetch
879/// error takes); code actions instead drop the speculative fix/unsat actions outright
880/// (see `await_versions_fetch`'s `timed_out` flag) rather than reusing that same
881/// fail-open path, since an unverified yank check must not be treated as "not yanked".
882pub const REGISTRY_FETCH_BUDGET: Duration = Duration::from_secs(10);
883
884/// Awaits `fetch` bounded by [`REGISTRY_FETCH_BUDGET`], logging a `tracing::warn!` naming
885/// `package` and `context` if the budget elapses first.
886///
887/// Shared by hover and code-action generation to avoid duplicating this
888/// timeout/log/degrade shape at both call sites. Returns `(None, false)` for a
889/// fetch-level error exactly like the pre-#1204 `.ok()` degrade, and `(None, true)` on
890/// timeout — the `bool` lets a caller (code actions) tell the two apart when a fetch
891/// failure and a fetch timeout must not be treated the same way (impl-critic S1: a
892/// timeout is not a verified "not yanked" answer, so it must not fail open the way a
893/// genuine registry outage does).
894#[cfg(feature = "lsp-responses")]
895async fn await_versions_fetch<T, E>(
896 fetch: impl std::future::Future<Output = Result<T, E>>,
897 package: &PackageName,
898 context: &'static str,
899) -> (Option<T>, bool) {
900 match tokio::time::timeout(REGISTRY_FETCH_BUDGET, fetch).await {
901 Ok(result) => (result.ok(), false),
902 Err(_) => {
903 tracing::warn!(
904 package = %package.for_tracing(),
905 context,
906 timeout_secs = REGISTRY_FETCH_BUDGET.as_secs(),
907 "primary registry version fetch timed out"
908 );
909 (None, true)
910 }
911 }
912}
913
914/// Converts UTF-16 offset to byte offset in a string.
915///
916/// LSP uses UTF-16 code units for character positions (for compatibility with
917/// JavaScript and other languages). This function converts from UTF-16 offset
918/// to byte offset for Rust string indexing.
919///
920/// # Arguments
921///
922/// * `s` - The string to index into
923/// * `utf16_offset` - UTF-16 code unit offset (from LSP Position.character)
924///
925/// # Returns
926///
927/// Byte offset if valid, `None` if the UTF-16 offset is out of bounds.
928///
929/// # Examples
930///
931/// ```
932/// # use deps_core::lsp_helpers::utf16_to_byte_offset;
933/// // ASCII: UTF-16 offset equals byte offset
934/// assert_eq!(utf16_to_byte_offset("hello", 2), Some(2));
935///
936/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
937/// assert_eq!(utf16_to_byte_offset("日本語", 0), Some(0));
938/// assert_eq!(utf16_to_byte_offset("日本語", 1), Some(3));
939/// assert_eq!(utf16_to_byte_offset("日本語", 2), Some(6));
940///
941/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
942/// assert_eq!(utf16_to_byte_offset("😀test", 2), Some(4));
943/// ```
944pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
945 let mut utf16_count = 0u32;
946 for (byte_idx, ch) in s.char_indices() {
947 if utf16_count >= utf16_offset {
948 return Some(byte_idx);
949 }
950 #[expect(
951 clippy::cast_possible_truncation,
952 reason = "char::len_utf16 always returns 1 or 2, so this cast never truncates"
953 )]
954 {
955 utf16_count += ch.len_utf16() as u32;
956 }
957 }
958 if utf16_count == utf16_offset {
959 return Some(s.len());
960 }
961 None
962}
963
964/// Converts a byte offset within `s` to a UTF-16 code unit offset (LSP `Position.character`).
965///
966/// `byte_offset` may be an arbitrary caller-supplied value: it is clamped to `s.len()` and
967/// floored down to the nearest UTF-8 char boundary before use, so this never panics.
968///
969/// # Examples
970///
971/// ```
972/// # use deps_core::lsp_helpers::byte_to_utf16_offset;
973/// // ASCII: byte offset equals UTF-16 offset
974/// assert_eq!(byte_to_utf16_offset("hello", 2), 2);
975///
976/// // Unicode: "日本語" - each char is 3 bytes but 1 UTF-16 code unit
977/// assert_eq!(byte_to_utf16_offset("日本語", 0), 0);
978/// assert_eq!(byte_to_utf16_offset("日本語", 3), 1);
979/// assert_eq!(byte_to_utf16_offset("日本語", 6), 2);
980///
981/// // Emoji: "😀" is 4 bytes but 2 UTF-16 code units (surrogate pair)
982/// assert_eq!(byte_to_utf16_offset("😀test", 4), 2);
983///
984/// // Never panics: an offset landing mid-character floors down to the start of that
985/// // character, and an offset past the end saturates to the string's length.
986/// assert_eq!(byte_to_utf16_offset("日本語", 1), 0); // inside the first character
987/// assert_eq!(byte_to_utf16_offset("日本語", 999), 3); // past the end
988/// ```
989#[expect(
990 clippy::string_slice,
991 reason = "end is floor_char_boundary-clamped just above, mirroring the already-hardened \
992 LineOffsetTable::byte_offset_to_position (lsp_helpers/mod.rs)"
993)]
994pub fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
995 // Saturate rather than silently wrap: an LSP `Position.character` past `u32::MAX` UTF-16
996 // units is already meaningless, but a wrapped value would be a wrong-but-plausible one
997 // (#673 — the exact offset-math bug class #244 shipped).
998 //
999 // `byte_offset` is not guaranteed to be in bounds or on a char boundary (a caller-supplied
1000 // offset can be past `s.len()` or land mid-character); `floor_char_boundary` clamps both
1001 // — past-the-end saturates to `s.len()` and any other in-bounds index floors to the
1002 // nearest char boundary — mirroring `LineOffsetTable::byte_offset_to_position`
1003 // (lsp_helpers/mod.rs).
1004 let end = s.floor_char_boundary(byte_offset);
1005 u32::try_from(s[..end].encode_utf16().count()).unwrap_or(u32::MAX)
1006}
1007
1008/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
1009pub fn position_in_range(pos: Position, range: Range) -> bool {
1010 if pos.line < range.start.line || pos.line > range.end.line {
1011 return false;
1012 }
1013 if pos.line == range.start.line && pos.character < range.start.character {
1014 return false;
1015 }
1016 if pos.line == range.end.line && pos.character > range.end.character {
1017 return false;
1018 }
1019 true
1020}
1021
1022/// Whether `dep`'s `version_range()` is a degenerate, completion-only position with no
1023/// requirement behind it (e.g. Maven's `<version></version>`/self-closing `<version/>`) rather
1024/// than a real, non-empty range that happens to have no requirement (e.g. Gradle's
1025/// `version.ref` pointing at a dangling or rich-version alias, which spans the real
1026/// alias-reference text). Zero-width (`start == end`) is the discriminating signal —
1027/// `version_requirement().is_none()` alone is not, since both shapes share it.
1028pub(crate) fn version_range_is_synthetic_empty(dep: &dyn Dependency) -> bool {
1029 dep.version_requirement().is_none() && dep.version_range().is_some_and(|r| r.start == r.end)
1030}
1031
1032/// Converts byte offsets in source text to LSP `Position` values.
1033///
1034/// Precomputes line-start byte offsets once, then maps any byte offset to a
1035/// `(line, character)` position. Characters are counted as UTF-16 code units
1036/// as required by the LSP specification.
1037///
1038/// Also precomputes, per line, whether that line is pure ASCII (`line_is_ascii`): for an
1039/// ASCII-only line, byte offset and UTF-16 code-unit
1040/// offset are always equal, so [`byte_offset_to_position`](Self::byte_offset_to_position)
1041/// can skip the `chars().map(char::len_utf16).sum()` scan entirely and compute the
1042/// character offset in O(1). This matters because a single minified manifest line (e.g. a
1043/// `package.json` with hundreds of dependencies on one line) turns per-offset lookups into
1044/// an O(n) scan each, and O(n) lookups across the line's length make the whole document
1045/// O(n^2) (#742) — the common case of an ASCII-only line now stays O(1) per lookup; a
1046/// non-ASCII line still takes an O(1) lookup too, via a lazily-built per-line index (#882) —
1047/// see `with_non_ascii_line_index`.
1048///
1049/// Interior mutability: this type is `Send` but not `Sync` (the non-ASCII line index cache
1050/// uses a `RefCell`). Build one per document parse and never share it across threads.
1051pub struct LineOffsetTable {
1052 line_starts: Vec<usize>,
1053 line_is_ascii: Vec<bool>,
1054 // Lazily populated, keyed by line number. A `HashMap` rather than a `Vec` sized to the
1055 // line count so cost is proportional to lines actually queried non-ASCII (#882).
1056 non_ascii_line_index_cache:
1057 std::cell::RefCell<std::collections::HashMap<usize, NonAsciiLineIndex>>,
1058}
1059
1060/// A non-ASCII line's char-boundary index, lazily built and cached by
1061/// `LineOffsetTable::with_non_ascii_line_index`.
1062///
1063/// `entries[i]` is `(byte_offset, utf16_units_before)` for the line's `i`-th char boundary
1064/// (`byte_offset` relative to the line's own start, `utf16_units_before` the cumulative UTF-16
1065/// code-unit count of every char *before* it). A final sentinel entry for the end of the line
1066/// (`byte_offset == line.len()`, `utf16_units_before` = the line's total UTF-16 length) makes
1067/// end-of-line byte offsets resolvable the same way as any other char boundary.
1068///
1069/// Serves both directions #882 needs on a non-ASCII line, from one build pass: char index ->
1070/// byte offset (`entries.get(col)`, what [`marker_byte_offset`] needs) and byte offset ->
1071/// UTF-16 units (`entries.binary_search_by_key`, what
1072/// [`byte_offset_to_position`](LineOffsetTable::byte_offset_to_position) needs) — a second,
1073/// independent cache would duplicate the exact `char_indices()` walk this one pass already
1074/// performs.
1075struct NonAsciiLineIndex {
1076 entries: Vec<(u32, u32)>,
1077}
1078
1079impl NonAsciiLineIndex {
1080 /// Builds the index for one line's text via a single `char_indices()` walk.
1081 fn build(line_text: &str) -> Self {
1082 let mut entries = Vec::with_capacity(line_text.len() + 1);
1083 let mut utf16_units = 0u32;
1084 for (byte_offset, c) in line_text.char_indices() {
1085 entries.push((u32::try_from(byte_offset).unwrap_or(u32::MAX), utf16_units));
1086 utf16_units = utf16_units.saturating_add(u32::try_from(c.len_utf16()).unwrap_or(2));
1087 }
1088 entries.push((
1089 u32::try_from(line_text.len()).unwrap_or(u32::MAX),
1090 utf16_units,
1091 ));
1092 // The initial `line_text.len() + 1` capacity over-reserves 3-4x on a multi-byte-heavy
1093 // line (CJK, Devanagari, ...): it reserves per byte but pushes one entry per char.
1094 entries.shrink_to_fit();
1095 Self { entries }
1096 }
1097}
1098
1099impl LineOffsetTable {
1100 /// Builds the table for `content`.
1101 pub fn new(content: &str) -> Self {
1102 let mut line_starts = vec![0];
1103 let mut line_is_ascii = Vec::new();
1104 let mut current_ascii = true;
1105 for (i, c) in content.char_indices() {
1106 if !c.is_ascii() {
1107 current_ascii = false;
1108 }
1109 if c == '\n' {
1110 line_starts.push(i + 1);
1111 line_is_ascii.push(current_ascii);
1112 current_ascii = true;
1113 }
1114 }
1115 line_is_ascii.push(current_ascii);
1116 debug_assert_eq!(
1117 line_starts.len(),
1118 line_is_ascii.len(),
1119 "line_starts and line_is_ascii must stay in lockstep — one entry per line"
1120 );
1121 Self {
1122 line_starts,
1123 line_is_ascii,
1124 non_ascii_line_index_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
1125 }
1126 }
1127
1128 /// Runs `f` against the cached (building it on first call) [`NonAsciiLineIndex`] for the
1129 /// non-ASCII line starting at `line0` (0-indexed), given that line's own text.
1130 ///
1131 /// Callers must pass the exact text of line `line0` as sliced from the same `content` this
1132 /// table was built from — the cache trusts its first-seen `line_text` for every later
1133 /// lookup of the same `line0` and never re-validates it. Building a line's index the first
1134 /// time it is looked up is still O(line length) (one `char_indices()` walk); only repeat
1135 /// lookups on an already-cached line become O(1) — a huge non-ASCII line's *first* lookup
1136 /// is not free, it is the same one-time cost `LineOffsetTable::new` always paid.
1137 ///
1138 /// Takes a closure rather than returning a `Ref` so the `RefCell` borrow never outlives one
1139 /// call — a caller cannot accidentally hold it open across an unrelated later borrow.
1140 #[expect(
1141 clippy::expect_used,
1142 reason = "borrow_mut() is dropped before borrow() runs, and the inserted entry is \
1143 never removed, so the lookup below always succeeds"
1144 )]
1145 fn with_non_ascii_line_index<R>(
1146 &self,
1147 line0: usize,
1148 line_text: &str,
1149 f: impl FnOnce(&NonAsciiLineIndex) -> R,
1150 ) -> R {
1151 self.non_ascii_line_index_cache
1152 .borrow_mut()
1153 .entry(line0)
1154 .or_insert_with(|| NonAsciiLineIndex::build(line_text));
1155 let cache = self.non_ascii_line_index_cache.borrow();
1156 let index = cache
1157 .get(&line0)
1158 .expect("just inserted above if it was missing");
1159 f(index)
1160 }
1161
1162 /// Byte offset (relative to the line's own start) of the `col`-th char boundary within the
1163 /// non-ASCII line starting at `line0`, given that line's text. Clamps to `line_text.len()`
1164 /// if `col` is past the line's char count, matching a plain `char_indices().nth(col)` walk.
1165 fn non_ascii_char_byte_offset(&self, line0: usize, line_text: &str, col: usize) -> usize {
1166 self.with_non_ascii_line_index(line0, line_text, |index| {
1167 index
1168 .entries
1169 .get(col)
1170 .map_or(line_text.len(), |&(byte_offset, _)| byte_offset as usize)
1171 })
1172 }
1173
1174 /// UTF-16 code-unit count preceding `byte_offset` (relative to the line's own start) within
1175 /// the non-ASCII line starting at `line0`, given that line's text.
1176 ///
1177 /// `byte_offset` must be a char boundary within the line (guaranteed by
1178 /// [`byte_offset_to_position`](Self::byte_offset_to_position)'s `floor_char_boundary` clamp
1179 /// before calling this) — every char boundary, including end-of-line, has an exact entry in
1180 /// the index, so this never needs to interpolate between two boundaries.
1181 fn non_ascii_utf16_units_before(
1182 &self,
1183 line0: usize,
1184 line_text: &str,
1185 byte_offset: usize,
1186 ) -> u32 {
1187 self.with_non_ascii_line_index(line0, line_text, |index| {
1188 let byte_offset = u32::try_from(byte_offset).unwrap_or(u32::MAX);
1189 let found = index
1190 .entries
1191 .binary_search_by_key(&byte_offset, |&(b, _)| b);
1192 // `Err` shouldn't happen given the caller's `floor_char_boundary` guarantee, but
1193 // falls back to the nearest preceding entry rather than panicking.
1194 let i = found.unwrap_or_else(|i| i.saturating_sub(1));
1195 index.entries.get(i).map_or(0, |&(_, units)| units)
1196 })
1197 }
1198
1199 /// Absolute byte offset where `line` (0-indexed) starts, or `None` if
1200 /// `line` is out of range.
1201 ///
1202 /// Prefer this over re-deriving a line's start via cursor arithmetic
1203 /// (`cursor += line.len() + 1`): `str::lines()` strips a trailing `\r`,
1204 /// so that approach under-counts by one byte per CRLF line and corrupts
1205 /// every subsequent offset in the file. This table is built by scanning
1206 /// `char_indices()` for `\n` (see [`new`](Self::new)), which counts the
1207 /// `\r`, so it stays correct for LF, CRLF and mixed line endings alike.
1208 ///
1209 /// # Examples
1210 ///
1211 /// ```
1212 /// use deps_core::lsp_helpers::LineOffsetTable;
1213 ///
1214 /// let table = LineOffsetTable::new("a\r\nb\r\nc");
1215 /// assert_eq!(table.line_start(0), Some(0));
1216 /// assert_eq!(table.line_start(1), Some(3));
1217 /// assert_eq!(table.line_start(2), Some(6));
1218 /// assert_eq!(table.line_start(3), None);
1219 /// ```
1220 pub fn line_start(&self, line: usize) -> Option<usize> {
1221 self.line_starts.get(line).copied()
1222 }
1223
1224 /// Converts a byte offset into an LSP `Position`.
1225 pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
1226 let offset = offset.min(content.len());
1227 // The requirements.txt line parser derives offsets via hand-rolled byte arithmetic,
1228 // which can land inside a multi-byte character — clamp to the nearest char boundary
1229 // rather than panicking on the slice below.
1230 let offset = content.floor_char_boundary(offset);
1231 let line = self
1232 .line_starts
1233 .partition_point(|&start| start <= offset)
1234 .saturating_sub(1);
1235 #[expect(
1236 clippy::indexing_slicing,
1237 reason = "line_starts always has at least one element (vec![0] at construction), \
1238 so partition_point().saturating_sub(1) is always a valid index into it"
1239 )]
1240 let line_start = self.line_starts[line];
1241 // #673 M1: `line`/`character` aren't bounded by any size cap for editor-sent
1242 // full-document-sync text, so this saturates rather than assuming an upstream cap
1243 // (mirrors `completion::byte_to_utf16_offset`'s identical fix).
1244 //
1245 // #742: an ASCII-only line has 1 UTF-16 unit per byte, so no need to walk `chars()`.
1246 let character = if self.line_is_ascii.get(line).copied().unwrap_or(false) {
1247 u32::try_from(offset - line_start).unwrap_or(u32::MAX)
1248 } else {
1249 // #882: a naive `chars().map(len_utf16).sum()` per call is O(line length); N
1250 // lookups on the same wide line made this O(N x line length). The cached
1251 // per-line index (`with_non_ascii_line_index`) makes repeat lookups O(1).
1252 let line_end = self
1253 .line_starts
1254 .get(line + 1)
1255 .copied()
1256 .unwrap_or(content.len());
1257 let line_text = content.get(line_start..line_end).unwrap_or_default();
1258 self.non_ascii_utf16_units_before(line, line_text, offset - line_start)
1259 };
1260 Position::new(u32::try_from(line).unwrap_or(u32::MAX), character)
1261 }
1262
1263 /// Converts an LSP `Position` back into a byte offset — the inverse of
1264 /// [`byte_offset_to_position`](Self::byte_offset_to_position). Out-of-range lines or
1265 /// UTF-16 characters clamp to `content.len()` rather than panicking, matching the
1266 /// forward conversion's `.min(content.len())` guard.
1267 #[expect(
1268 clippy::string_slice,
1269 reason = "line_start/line_end come from line_starts (post-newline offsets, always \
1270 char boundaries) or content.len()"
1271 )]
1272 pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
1273 let Some(&line_start) = self.line_starts.get(position.line as usize) else {
1274 return content.len();
1275 };
1276 let line_end = self
1277 .line_starts
1278 .get(position.line as usize + 1)
1279 .copied()
1280 .unwrap_or(content.len());
1281 let line = &content[line_start..line_end];
1282 utf16_to_byte_offset(line, position.character)
1283 .map_or(line_end, |offset| line_start + offset)
1284 .min(content.len())
1285 }
1286}
1287
1288/// Escapes Markdown syntax characters so untrusted text cannot break out of the
1289/// Markdown structure it is embedded in.
1290///
1291/// Applied to manifest-controlled text (dependency names) before it is written into
1292/// hover markdown link labels, and to registry-controlled completion metadata
1293/// (package name/version, description, repository/documentation URLs) before it is
1294/// written into completion-item link labels and link destinations. Every ASCII
1295/// punctuation character is backslash-escaped (CommonMark's full escapable set — not
1296/// just brackets/parens, which would still leave e.g. `<https://evil.example>`
1297/// autolinks live), and control characters (including newlines) plus a narrow set of
1298/// invisible/bidi-override characters (see `is_markdown_unsafe`, #1248) are replaced
1299/// with a space so the text cannot terminate the single-line block it is embedded in,
1300/// splice in new content, or visually spoof the rendered name via a Trojan Source
1301/// (CVE-2021-42574) bidi override.
1302///
1303/// Backslash-escaping is valid in link destinations as well as regular text, so this
1304/// also neutralizes `)`/`]` breakout attempts in a `[label](destination)` URL. It does
1305/// *not* block dangerous URI schemes (e.g. `javascript:`) in a destination — that is a
1306/// separate concern from breaking out of the surrounding Markdown structure.
1307///
1308/// Backslash-escaping does *not* work inside inline code spans (CommonMark §6.1) —
1309/// use [`markdown_code_span`] for text embedded in `` `...` `` instead.
1310///
1311/// # Examples
1312///
1313/// ```
1314/// use deps_core::lsp_helpers::escape_markdown;
1315///
1316/// assert_eq!(escape_markdown("pkg](evil)[pkg"), r"pkg\]\(evil\)\[pkg");
1317/// assert_eq!(escape_markdown("a\nb"), "a b");
1318/// ```
1319pub fn escape_markdown(s: &str) -> String {
1320 let mut escaped = String::with_capacity(s.len());
1321 for c in s.chars() {
1322 if is_markdown_unsafe(c) {
1323 escaped.push(' ');
1324 continue;
1325 }
1326 if c.is_ascii_punctuation() {
1327 escaped.push('\\');
1328 }
1329 escaped.push(c);
1330 }
1331 escaped
1332}
1333
1334/// Narrow, explicit predicate for a character that must not survive [`escape_markdown`]
1335/// or [`markdown_code_span`] unescaped (#1248): ASCII/C1 control characters plus a
1336/// fixed set of invisible/bidi-override Unicode characters with a demonstrated Trojan
1337/// Source (CVE-2021-42574) or text-smuggling use, and no legitimate mid-string use in
1338/// registry-supplied free text.
1339///
1340/// Deliberately narrower than [`crate::net_policy::sanitize_invisible`]'s whole-category
1341/// (`Cf`/`Zl`/`Zp`) sweep: both `escape_markdown` and `markdown_code_span` also process
1342/// free text (registry descriptions) that can legitimately carry `Cf` marks such as
1343/// U+200F RIGHT-TO-LEFT MARK, U+061C ARABIC LETTER MARK, U+200E LEFT-TO-RIGHT MARK, or
1344/// U+200C/U+200D (ZWNJ/ZWJ, load-bearing in Persian/Arabic/Indic text and emoji
1345/// sequences) — a category-wide check would mangle genuine RTL text or emoji. This list
1346/// covers only the bidi-override/invisible characters with no such legitimate use:
1347/// explicit bidi overrides/isolates (U+202A-U+202E, U+2066-U+2069), the zero-width
1348/// space (U+200B) and word joiner (U+2060, functionally identical to — and Unicode's
1349/// recommended replacement for — U+FEFF used as a ZWNBSP rather than a byte-order mark),
1350/// the line/paragraph separators (U+2028/U+2029, `Zl`/`Zp` — not `Cf`, so a `Cf`-only
1351/// check would miss them), the byte-order mark (U+FEFF), interlinear annotation
1352/// characters (U+FFF9-U+FFFB), and the Unicode tag characters (U+E0000-U+E007F, the
1353/// canonical invisible "ASCII smuggling" vector).
1354fn is_markdown_unsafe(c: char) -> bool {
1355 c.is_control()
1356 || matches!(c,
1357 '\u{202A}'..='\u{202E}' // LRE RLE PDF LRO RLO
1358 | '\u{2066}'..='\u{2069}' // LRI RLI FSI PDI
1359 | '\u{200B}' // ZWSP
1360 | '\u{2060}' // WORD JOINER
1361 | '\u{2028}' | '\u{2029}' // LS, PS
1362 | '\u{FEFF}' // ZWNBSP / BOM
1363 | '\u{FFF9}'..='\u{FFFB}' // interlinear annotation anchor/separator/terminator
1364 | '\u{E0000}'..='\u{E007F}' // Unicode tag characters
1365 )
1366}
1367
1368/// Replaces every [`is_markdown_unsafe`] character in `s` with a single space.
1369///
1370/// Shared by [`markdown_code_span`] and `diagnostics::sanitize_advisory_text_for_diagnostic`
1371/// (#1262 code-review follow-up) so the narrow bidi/invisible-character filter has exactly
1372/// one loop to keep in sync with [`is_markdown_unsafe`]'s policy, instead of two copies that
1373/// could silently drift. Also called from `crate::diagnostic::Diagnostic::new` and
1374/// `crate::diagnostic::RelatedInformation::new` as a defense-in-depth backstop on their
1375/// constructor path (#1276) — `pub(crate)` rather than private for that, but still not
1376/// reachable from outside `deps-core`.
1377///
1378/// Idempotent: `' '` is not itself [`is_markdown_unsafe`], so re-applying this to
1379/// already-sanitized input is a no-op — safe to double-apply through both a producer-side
1380/// call and the constructor-path backstop.
1381///
1382/// Only correct for a text/label sink or an inline code span, where a stray space is
1383/// harmless — **not** for a Markdown link *destination*: per CommonMark, an unbracketed
1384/// `[label](destination)` destination cannot contain a literal space at all, so
1385/// substituting one would turn a fired hazard into a broken (non-)link instead of a
1386/// sanitized one. [`strip_markdown_unsafe_chars`] is the destination-safe sibling.
1387pub(crate) fn replace_markdown_unsafe_chars(s: &str) -> String {
1388 s.chars()
1389 .map(|c| if is_markdown_unsafe(c) { ' ' } else { c })
1390 .collect()
1391}
1392
1393/// Removes every `is_markdown_unsafe` character from `s` outright, rather than
1394/// substituting a space (`replace_markdown_unsafe_chars`'s behavior).
1395///
1396/// Used for a Markdown link *destination* (#1259 critic S3): CommonMark forbids a
1397/// literal, unescaped space inside an unbracketed `[label](destination)` destination,
1398/// so replacing a stripped character with a space there would break the link (render it
1399/// as non-link literal text) instead of sanitizing it in place. Dropping the character
1400/// keeps the surrounding URL syntactically intact — the removed character carried no
1401/// meaningful display information to begin with (it is invisible/bidi-control by
1402/// definition), so there is nothing worth preserving a placeholder for.
1403///
1404/// # Examples
1405///
1406/// ```
1407/// use deps_core::lsp_helpers::strip_markdown_unsafe_chars;
1408///
1409/// assert_eq!(strip_markdown_unsafe_chars("https://example.com/pkg"), "https://example.com/pkg");
1410/// assert_eq!(
1411/// strip_markdown_unsafe_chars("https://example.com/real\u{202E}gnp.sj"),
1412/// "https://example.com/realgnp.sj"
1413/// );
1414/// ```
1415pub fn strip_markdown_unsafe_chars(s: &str) -> String {
1416 s.chars().filter(|c| !is_markdown_unsafe(*c)).collect()
1417}
1418
1419/// Wraps `content` in a Markdown inline code span (backticks included) that safely
1420/// contains arbitrary untrusted text, regardless of embedded backticks.
1421///
1422/// Backslash-escaping does not work inside code spans (CommonMark §6.1), so instead
1423/// this fences with one more backtick than the longest run found in `content`, and
1424/// pads with a single space on each side when `content` starts or ends with a
1425/// backtick or space (required by CommonMark to keep the fence unambiguous). Control
1426/// characters (including newlines) plus the same narrow invisible/bidi-override subset
1427/// `escape_markdown` neutralizes (see `is_markdown_unsafe`, #1248) are replaced with a
1428/// space first, since the raw hover string is otherwise free to merge into an adjacent
1429/// Markdown block or carry a Trojan Source bidi override into the rendered code span.
1430///
1431/// # Examples
1432///
1433/// ```
1434/// use deps_core::lsp_helpers::markdown_code_span;
1435///
1436/// assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
1437/// assert_eq!(markdown_code_span("a`b"), "``a`b``");
1438/// ```
1439pub fn markdown_code_span(content: &str) -> String {
1440 let sanitized = replace_markdown_unsafe_chars(content);
1441
1442 let max_backtick_run = sanitized
1443 .split(|c| c != '`')
1444 .map(str::len)
1445 .max()
1446 .unwrap_or(0);
1447 let fence = "`".repeat(max_backtick_run + 1);
1448
1449 if sanitized.is_empty() {
1450 format!("{fence} {fence}")
1451 } else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
1452 format!("{fence} {sanitized} {fence}")
1453 } else {
1454 format!("{fence}{sanitized}{fence}")
1455 }
1456}
1457
1458/// Checks if two version strings have the same major and minor version.
1459pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
1460 if v1.is_empty() || v2.is_empty() {
1461 return false;
1462 }
1463
1464 let mut parts1 = v1.split('.');
1465 let mut parts2 = v2.split('.');
1466
1467 if parts1.next() != parts2.next() {
1468 return false;
1469 }
1470
1471 match (parts1.next(), parts2.next()) {
1472 (Some(m1), Some(m2)) => m1 == m2,
1473 _ => true,
1474 }
1475}
1476
1477/// Result of checking whether a dependency's declared requirement is already satisfied by
1478/// the latest known version.
1479///
1480/// Diagnostics and inlay hints read this result differently: diagnostics only need to know
1481/// whether it is safe to skip the "Newer version available" warning, so both `UpToDate` and
1482/// `Unresolved` suppress it. Inlay hints additionally need to distinguish `Unresolved` from
1483/// `UpToDate`, since an unresolved requirement (e.g. a dangling Gradle version-catalog
1484/// `version.ref` alias, or an unexpanded Maven `${property}`) must not render an "up to
1485/// date" badge that was never actually verified.
1486// Exhaustive: a wildcard arm at any consuming match site would silently render the wrong
1487// label for a new variant instead of failing to compile (#769).
1488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1489pub enum RequirementStatus {
1490 /// The latest version satisfies the declared requirement.
1491 UpToDate,
1492 /// The latest version does not satisfy the declared requirement — a newer version is
1493 /// available.
1494 Outdated,
1495 /// The requirement could not be resolved to a concrete constraint, so no comparison
1496 /// could be made.
1497 Unresolved,
1498}
1499
1500/// A `requirement` compiled by one ecosystem, ready to test candidate versions against.
1501///
1502/// Produced by [`formatter::RequirementResolution::compile_requirement`]. Kept as a separate object
1503/// (rather than a single "does any version match" function) so the requirement is parsed
1504/// once per dependency, and so the scanning loop — including the empty-list guard, the
1505/// early-exit on first match, and the "skip an unparseable candidate" rule — lives once in
1506/// [`requirement_is_unsatisfiable`] instead of being reimplemented by all eleven ecosystems.
1507///
1508/// # Examples
1509///
1510/// ```
1511/// use deps_core::lsp_helpers::RequirementMatcher;
1512/// use deps_core::ConcreteVersion;
1513///
1514/// struct ExactMatch(String);
1515///
1516/// impl RequirementMatcher for ExactMatch {
1517/// fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
1518/// Some(version.as_str() == self.0)
1519/// }
1520/// }
1521///
1522/// let matcher = ExactMatch("1.0.0".to_string());
1523/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
1524/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
1525/// ```
1526pub trait RequirementMatcher: Send + Sync {
1527 /// Tests one candidate version string against the compiled requirement.
1528 ///
1529 /// `Some(true)` / `Some(false)`: this candidate provably does / does not satisfy the
1530 /// requirement. `None`: this candidate *string* could not be parsed by this ecosystem's
1531 /// version format (e.g. a PyPI legacy release identifier, a Maven timestamped snapshot
1532 /// qualifier) — the caller skips it and keeps scanning the rest of the list. Never
1533 /// return `None` to mean "the requirement itself is unusable"; that is
1534 /// [`formatter::RequirementResolution::compile_requirement`]'s job, via returning `None` from that
1535 /// method instead of constructing a matcher at all.
1536 fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
1537}
1538
1539/// Whether `segment` is exactly `.` or `..`.
1540///
1541/// Unlike ordinary path characters, a literal `.`/`..` segment is not neutralized by
1542/// percent-encoding: `.` is an unreserved character (RFC 3986), so `urlencoding::encode`
1543/// leaves it untouched, and the URL parser's dot-segment removal (RFC 3986 §5.2.4) still
1544/// collapses it after encoding — `%2E` decodes back to `.` before that normalization runs.
1545/// A registry-fetch URL built as `{base}/{prefix}/{name}` (no fixed suffix after `name`)
1546/// must reject a `name`/path segment satisfying this predicate rather than encode it,
1547/// since encoding alone does not stop the collapse (#341, #349).
1548///
1549/// Shared by `deps-npm`'s scope/package segment guard and `deps-dart`'s package-name
1550/// guard — both ecosystems' registry APIs key a fetch on a bare, suffix-less path segment.
1551///
1552/// **Scope**: this predicate (and the `#365` regression sweep built around it) guards
1553/// registry-*fetch* URL builders — the sink is a request this process actually
1554/// dereferences, so a retargeted URL can make it fetch attacker-chosen data. It
1555/// deliberately does *not* extend to a "docs link"/`package_url`-style builder (the
1556/// per-ecosystem hover/display link, e.g. `deps_cargo::crate_url`, `deps_go::package_url`):
1557/// those interpolate the name into a link rendered in hover text and never fetched by
1558/// this process, so an unrejected `.`/`..` name there produces at worst a misleading
1559/// same-host link (the registry's package-listing root), not a traversal off-host (#379).
1560///
1561/// # Examples
1562///
1563/// ```
1564/// use deps_core::lsp_helpers::is_dot_segment;
1565///
1566/// assert!(is_dot_segment(".."));
1567/// assert!(is_dot_segment("."));
1568/// assert!(!is_dot_segment("left-pad"));
1569/// ```
1570pub fn is_dot_segment(segment: &str) -> bool {
1571 segment == "." || segment == ".."
1572}
1573
1574/// Whether `version` is safe to embed in a manifest `TextEdit` or completion item.
1575///
1576/// Guards every call into
1577/// [`formatter::PackageRendering::format_version_replacing`]/[`formatter::PackageRendering::format_version_for_text_edit`]
1578/// and every completion item's `insert_text`/`text_edit`.
1579///
1580/// Must be applied to the raw version string *before* formatting, never to a
1581/// formatter's output: some formatters legitimately produce structural
1582/// characters in their output from an already-validated version plus fixed,
1583/// trusted operators (e.g. PyPI's `>=1.2.3,<2`), so validating the output
1584/// would wrongly reject those.
1585///
1586/// An allowlist, not a denylist: `version` must be non-empty, at most 64
1587/// bytes, and contain only `[A-Za-z0-9.+_~:*^!-]` — the character set real
1588/// version strings use across every ecosystem this workspace supports
1589/// (SemVer, PEP 440 including epochs like `1!2.0`, Maven qualifiers, npm's
1590/// `^`/`~`/`*` range tokens, Go's `+incompatible` suffix). A denylist here
1591/// would need to anticipate every dangerous token a target manifest format
1592/// (or a build tool evaluating it, e.g. Gradle's Kotlin/Groovy DSL
1593/// interpolating `${...}` inside a version literal) could ever act on;
1594/// failing closed on an unrecognized character is cheaper and safer.
1595///
1596/// This is the single validation chokepoint shared by every producer of a
1597/// version-derived `TextEdit`/completion item in this workspace, including
1598/// OSV advisory data (an `Advisory.fixed_versions` entry is exactly as
1599/// untrusted as a registry-reported version).
1600///
1601/// # Examples
1602///
1603/// ```
1604/// use deps_core::is_safe_version_string;
1605///
1606/// assert!(is_safe_version_string("1.2.3-alpha.1+build"));
1607/// assert!(!is_safe_version_string("1.2.3\", git = \"https://evil"));
1608/// ```
1609pub fn is_safe_version_string(version: &str) -> bool {
1610 !version.is_empty()
1611 && version.len() <= 64
1612 && version.chars().all(|c| {
1613 c.is_ascii_alphanumeric()
1614 || matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
1615 })
1616}
1617
1618/// Whether `segment` is safe to embed as a Maven `groupId`/`artifactId` value in a
1619/// pom.xml `TextEdit` or completion item.
1620///
1621/// Guards Maven's group/artifact completion producer, which builds a completion item's
1622/// `insert_text`/`text_edit` from one field of a Maven Central search result — a value
1623/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
1624/// why version-derived and non-version-derived sinks each get their own allowlist).
1625///
1626/// An allowlist, not a denylist: `segment` must be non-empty, at most 128 bytes, not
1627/// exactly `.`/`..` (see [`is_dot_segment`]), and contain only `[A-Za-z0-9._-]` — the
1628/// character set real Maven Central group ids (reverse-DNS style, e.g.
1629/// `org.apache.commons`) and artifact ids (hyphen/underscore separated, e.g.
1630/// `commons-lang3`) use. Deliberately excludes `:` — the `groupId:artifactId` separator —
1631/// because this validates one already-split coordinate field at a time, never the joined
1632/// pair. Failing closed on an unrecognized character (e.g. `<`, `"`, a newline) keeps a
1633/// malicious/compromised search result from restructuring the pom.xml it's inserted into;
1634/// the dedicated `.`/`..` rejection closes the same dot-segment URL-normalization gap
1635/// [`is_dot_segment`] guards elsewhere (`artifactId` reaches a registry-fetch URL as a bare
1636/// path segment in `deps-maven::registry::metadata_urls`, unlike `groupId`, whose `.`→`/`
1637/// expansion can never itself produce a literal `..` component).
1638///
1639/// # Examples
1640///
1641/// ```
1642/// use deps_core::is_safe_maven_coordinate_segment;
1643///
1644/// assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
1645/// assert!(is_safe_maven_coordinate_segment("commons-lang3"));
1646/// assert!(!is_safe_maven_coordinate_segment("commons</artifactId><parent>"));
1647/// assert!(!is_safe_maven_coordinate_segment(".."));
1648/// ```
1649pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
1650 !segment.is_empty()
1651 && segment.len() <= 128
1652 && !is_dot_segment(segment)
1653 && segment
1654 .chars()
1655 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
1656}
1657
1658/// Builds the Maven repository URL path (`"group/path/segments/artifact"`) for a
1659/// `groupId`/`artifactId` coordinate pair, or `None` if either fails validation.
1660///
1661/// The single shared home for `groupId:artifactId` -> URL-path construction (#702):
1662/// validates `group_id` and `artifact_id` as whole strings via
1663/// [`is_safe_maven_coordinate_segment`] (preserving its 128-byte total-length cap per
1664/// segment) *and* validates every `.`-separated component of `group_id` individually,
1665/// rejecting an empty component outright. The whole-string check alone is not enough —
1666/// it permits internal `.` characters, so a naive `group_id.replace('.', "/")` on a
1667/// coordinate like `"com..evil"` would produce the empty path segment `"com//evil"`; the
1668/// per-component check closes that gap without dropping the whole-string length bound a
1669/// pure per-segment `split('.')` validation would lose (a 10-segment, 128-byte-each group
1670/// would otherwise pass at 1280 bytes total).
1671///
1672/// # Examples
1673///
1674/// ```
1675/// use deps_core::maven_coordinate_path;
1676///
1677/// assert_eq!(
1678/// maven_coordinate_path("com.example", "artifact").as_deref(),
1679/// Some("com/example/artifact")
1680/// );
1681/// assert_eq!(
1682/// maven_coordinate_path("junit", "junit").as_deref(),
1683/// Some("junit/junit")
1684/// );
1685/// assert_eq!(maven_coordinate_path("com..evil", "artifact"), None);
1686/// ```
1687pub fn maven_coordinate_path(group_id: &str, artifact_id: &str) -> Option<String> {
1688 if !is_safe_maven_coordinate_segment(group_id) || !is_safe_maven_coordinate_segment(artifact_id)
1689 {
1690 return None;
1691 }
1692 let group_segments: Vec<&str> = group_id.split('.').collect();
1693 if group_segments
1694 .iter()
1695 .any(|segment| !is_safe_maven_coordinate_segment(segment))
1696 {
1697 return None;
1698 }
1699 Some(format!("{}/{artifact_id}", group_segments.join("/")))
1700}
1701
1702/// Whether `url` is safe to embed as a Swift Package Manager repository URL in a
1703/// Package.swift `TextEdit` or completion item.
1704///
1705/// Guards Swift's URL-completion producer, which builds a `.package(url: "...")`
1706/// string-literal replacement from a package registry search result's URL — a value
1707/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
1708/// why version-derived and non-version-derived sinks each get their own allowlist).
1709///
1710/// An allowlist, not a denylist: `url` must be non-empty, at most 2048 bytes, start with
1711/// `https://` — every real Swift package registry response is HTTPS (GitHub's `html_url`
1712/// never downgrades), so accepting plain `http://` would only hand a
1713/// compromised/malicious registry a transport-downgrade lever for zero legitimate
1714/// benefit — and otherwise contain only RFC 3986 URL characters (`A-Za-z0-9` plus
1715/// `` -._~:/?#[]@!$&'()*+,;=% ``). Deliberately excludes `"`, `\`, control characters,
1716/// and whitespace — none of those are valid unencoded URL characters, and any of them
1717/// could close the surrounding Swift string literal or otherwise corrupt the manifest.
1718/// Failing closed on an unrecognized character keeps a malicious/compromised search
1719/// result from breaking out of the string it's inserted into.
1720///
1721/// # Examples
1722///
1723/// ```
1724/// use deps_core::is_safe_registry_url;
1725///
1726/// assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
1727/// assert!(!is_safe_registry_url("https://evil.example\", .exact(\"1\")) // "));
1728/// ```
1729pub fn is_safe_registry_url(url: &str) -> bool {
1730 !url.is_empty()
1731 && url.len() <= 2048
1732 && url.starts_with("https://")
1733 && url.chars().all(|c| {
1734 c.is_ascii_alphanumeric()
1735 || matches!(
1736 c,
1737 '-' | '.'
1738 | '_'
1739 | '~'
1740 | ':'
1741 | '/'
1742 | '?'
1743 | '#'
1744 | '['
1745 | ']'
1746 | '@'
1747 | '!'
1748 | '$'
1749 | '&'
1750 | '\''
1751 | '('
1752 | ')'
1753 | '*'
1754 | '+'
1755 | ','
1756 | ';'
1757 | '='
1758 | '%'
1759 )
1760 })
1761}
1762
1763/// Whether `name` is safe to embed as a package name in a manifest `TextEdit` or
1764/// completion item.
1765///
1766/// Guards every arm of `create_package_completion_item`
1767/// (`crates/deps-lsp/src/handlers/completion.rs`) as a single upfront check, applied
1768/// before the raw `name` reaches any ecosystem-specific snippet — including Maven and
1769/// Swift, which additionally validate a *derived* value on top of this gate
1770/// ([`is_safe_maven_coordinate_segment`] on each split coordinate segment,
1771/// [`is_safe_registry_url`] on the constructed URL) because a value type distinct from
1772/// the raw name needs its own allowlist — see [`is_safe_version_string`]'s doc comment
1773/// for why version-derived and non-version-derived sinks each get their own allowlist.
1774/// [`PackageName::new`](crate::PackageName::new) is documented as never validating or
1775/// modifying its input, so this predicate is the first gate a registry-reported name
1776/// passes through before reaching a manifest. Two sinks that key a bare TOML/YAML
1777/// entry by `name` (Cargo/PyPI, Dart) additionally quote that key in the snippet, since
1778/// `.` and `@` are legal here but would otherwise be read as TOML's dotted-key
1779/// separator or break a YAML plain scalar.
1780///
1781/// An allowlist, not a denylist: `name` must be non-empty, at most 256 bytes, and
1782/// contain only `[A-Za-z0-9._@:/~-]` — the character set real package names use across
1783/// every ecosystem this predicate guards: Cargo/PyPI/Dart/NuGet/Bundler
1784/// (alphanumeric, `-`, `_`, `.`), npm/Deno scoped names (`@scope/name`, adding `@` and
1785/// `/`), Composer (`vendor/package`, `/`), Go module paths (domain-qualified paths like
1786/// `github.com/org/repo`, `/`, `.`, and `~` — legal in a Go path element and already
1787/// allowed by [`is_safe_version_string`]/[`is_safe_registry_url`]), and Gradle's
1788/// colon-delimited `group:artifact` short form (`:`). A denylist here would need to
1789/// anticipate every dangerous token a target manifest format (TOML/JSON/YAML/XML
1790/// string literals, a live Kotlin/Groovy build-script DSL) could ever act on; failing
1791/// closed on an unrecognized character — notably `"`, `'`, `<`, `>`, `` ` ``, and all
1792/// control characters/newlines — is cheaper and safer.
1793///
1794/// # Examples
1795///
1796/// ```
1797/// use deps_core::is_safe_package_name;
1798///
1799/// assert!(is_safe_package_name("serde"));
1800/// assert!(is_safe_package_name("@scope/name"));
1801/// assert!(is_safe_package_name("org.apache.commons:commons-lang3"));
1802/// assert!(!is_safe_package_name("evil\"\nbackdoor = \"9.9.9"));
1803/// ```
1804pub fn is_safe_package_name(name: &str) -> bool {
1805 !name.is_empty()
1806 && name.len() <= 256
1807 && name.chars().all(|c| {
1808 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
1809 })
1810}
1811
1812/// Logs a `tracing::warn!` for a value rejected by an `is_safe_*` predicate (or an
1813/// equivalent value-rejecting gate) before it reaches a manifest edit or registry URL.
1814///
1815/// Deliberately logs only `value`'s byte length, never its content: `value` is
1816/// registry-controlled and, by construction, already failed an allowlist — logging it
1817/// verbatim at `warn` would let a malicious/compromised registry response inject arbitrary
1818/// content into this project's own log stream (a second-order log-injection concern),
1819/// mirroring why `deps-pypi`'s `truncate_for_log` bounds a logged excerpt instead of
1820/// logging a value verbatim. This is this helper's own contract, not a claim that every
1821/// `tracing` call site in the workspace avoids logging a raw value — e.g. `deps-lsp`'s
1822/// `deps-lsp.updateVersion` handler and an OSV malformed-`fixed`-version warning predate
1823/// this helper and log their rejected value directly; they are unrelated call sites, not a
1824/// place this helper is used.
1825///
1826/// `gate` names the predicate/guard that rejected `value` (e.g.
1827/// `"is_safe_maven_coordinate_segment"`); `context` is a short description of the call site
1828/// (e.g. `"maven groupId completion"`).
1829///
1830/// # Examples
1831///
1832/// ```
1833/// use deps_core::lsp_helpers::warn_rejected_value;
1834///
1835/// warn_rejected_value("is_safe_version_string", "code lens latest version", "1.0.0\"; evil");
1836/// ```
1837pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
1838 tracing::warn!(
1839 gate,
1840 context,
1841 len = value.len(),
1842 "rejected unsafe value before manifest/registry sink"
1843 );
1844}
1845
1846/// Logs a [`warn_rejected_value`] warning and builds the [`DepsError::PackageNotFound`] a
1847/// caller returns after its own `is_dot_segment`/`has_dot_segment` predicate rejects `name`.
1848///
1849/// Extracted from four equivalent `warn_rejected_value` + `PackageNotFound`
1850/// constructions in `deps-dart`, `deps-composer`, `deps-nuget`, and `deps-npm`
1851/// (deps-lsp#929 item 2) — `deps-npm`'s `gate` label was `"npm_dot_segment_guard"` rather
1852/// than the other three's `"is_dot_segment"` before this extraction normalized it. Each
1853/// crate keeps its own dot-segment *predicate* and the call to it exactly where it is
1854/// today; only this trailing boilerplate is shared. Deliberately does **not** fold in
1855/// `deps-go`'s guard, which returns `DepsError::InvalidVersionReq`, not `PackageNotFound`.
1856///
1857/// # Examples
1858///
1859/// ```
1860/// use deps_core::lsp_helpers::dot_segment_rejection_error;
1861/// use deps_core::DepsError;
1862///
1863/// let err = dot_segment_rejection_error(
1864/// "is_dot_segment",
1865/// "example package metadata request URL",
1866/// "..",
1867/// "example",
1868/// );
1869/// assert!(matches!(err, DepsError::PackageNotFound { .. }));
1870/// ```
1871#[must_use]
1872pub fn dot_segment_rejection_error(
1873 gate: &str,
1874 context: &str,
1875 name: &str,
1876 registry: &'static str,
1877) -> DepsError {
1878 warn_rejected_value(gate, context, name);
1879 DepsError::PackageNotFound {
1880 package: name.into(),
1881 registry,
1882 }
1883}
1884
1885/// Converts a domain [`url::Url`] back into a `tower_lsp_server::ls_types::Uri` for
1886/// embedding into an actual LSP response object (`WorkspaceEdit`, `Location`, a code-lens
1887/// command argument, ...).
1888///
1889/// This is the one blessed conversion path from `deps-core`'s internal `url::Url` domain
1890/// type to `tower_lsp_server`'s `Uri` — ecosystem crates and `deps-core` itself must route
1891/// through it rather than re-deriving the conversion (e.g. `uri.as_str().parse()`) locally,
1892/// so a future change to how URLs round-trip through the LSP wire format only needs fixing
1893/// in one place. It mirrors `deps-lsp`'s own `lsp_types_interop::to_lsp_uri` (issue #1071):
1894/// `deps-core` cannot reuse that function (the dependency direction points the other way)
1895/// or express this as a `From` impl (neither `url::Url` nor `ls_types::Uri` is local to
1896/// `deps-core` — the same orphan-rule constraint that makes the `deps-lsp` boundary a pair
1897/// of free functions instead of a trait impl), so this is `deps-core`'s independent
1898/// counterpart, needed because `lsp_helpers` still builds real `ls_types` response objects
1899/// directly even though `Dependency`/`ParseResult` no longer carry an `ls_types::Uri` (see
1900/// `lib.rs`'s "LSP type stability" doc section).
1901///
1902/// # Panics
1903///
1904/// Panics if `url`'s string form does not round-trip into an `Uri`. In practice this
1905/// never happens: every `url::Url` reaching `lsp_helpers` originates from a real
1906/// editor-opened document (an `ls_types::Uri` converted to `Url` at the `deps-lsp`
1907/// boundary), so failing to convert it back indicates upstream corruption, not
1908/// attacker-controlled manifest content.
1909///
1910/// # Examples
1911///
1912/// ```
1913/// use deps_core::to_ls_uri;
1914/// use url::Url;
1915///
1916/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
1917/// let uri = to_ls_uri(&url);
1918/// assert_eq!(uri.as_str(), "file:///tmp/Cargo.toml");
1919/// ```
1920#[cfg(feature = "lsp-responses")]
1921#[must_use]
1922pub fn to_ls_uri(url: &url::Url) -> tower_lsp_server::ls_types::Uri {
1923 url.as_str()
1924 .parse()
1925 .unwrap_or_else(|e| panic!("document URL {url} did not round-trip to an LSP Uri: {e}"))
1926}
1927
1928/// Builds a single-entry [`tower_lsp_server::ls_types::WorkspaceEdit::changes`] map replacing `range` in `uri`
1929/// with `new_text`.
1930///
1931/// Shared by every quickfix/refactor code action in `code_actions` that edits exactly
1932/// one span in the current document (`build_vulnerability_fix_action`,
1933/// `build_unsatisfiable_fix_action`, and the plain "update to `<version>`" loop in
1934/// [`generate_code_actions`]) — and by any ecosystem crate hand-building a
1935/// `WorkspaceEdit` for a single-span fix instead of reimplementing this map shape
1936/// locally.
1937///
1938/// # Panics
1939///
1940/// Panics if `uri` does not round-trip through [`to_ls_uri`] — see that function's own
1941/// `# Panics` section.
1942///
1943/// # Examples
1944///
1945/// ```
1946/// use deps_core::single_file_edit;
1947/// use deps_core::position::{Position, Range};
1948/// use url::Url;
1949///
1950/// let url = Url::parse("file:///tmp/Cargo.toml").unwrap();
1951/// let range = Range::new(Position::new(0, 0), Position::new(0, 5));
1952/// let edits = single_file_edit(&url, range, "1.2.3".to_string());
1953///
1954/// assert_eq!(edits.len(), 1);
1955/// ```
1956#[cfg(feature = "lsp-responses")]
1957#[must_use]
1958pub fn single_file_edit(
1959 uri: &url::Url,
1960 range: Range,
1961 new_text: String,
1962) -> HashMap<tower_lsp_server::ls_types::Uri, Vec<tower_lsp_server::ls_types::TextEdit>> {
1963 let mut edits = HashMap::new();
1964 edits.insert(
1965 to_ls_uri(uri),
1966 vec![tower_lsp_server::ls_types::TextEdit {
1967 range: range.into(),
1968 new_text,
1969 }],
1970 );
1971 edits
1972}
1973
1974/// Strips every whitespace character from `s`, so two textually-equivalent strings that
1975/// differ only in spacing compare equal.
1976///
1977/// Shared by every no-op/literal-match guard across `code_actions` and `code_lenses`
1978/// (`build_vulnerability_fix_action`'s N1 guard, `generate_code_actions`'s REFACTOR-loop
1979/// guard, `literal_span_matches`, and `collect_update_all_edits`'s no-op guard), all of
1980/// which compare a declared requirement
1981/// string against a differently-normalized counterpart — e.g. pep508's `>=1.7, <2.0` vs. a
1982/// formatter's `>=1.7,<2.0`.
1983fn strip_whitespace(s: &str) -> String {
1984 s.chars().filter(|c| !c.is_whitespace()).collect()
1985}
1986
1987/// Slices `content` over an LSP `Range` using a pre-built `LineOffsetTable`, returning
1988/// `""` for an inverted or out-of-bounds range instead of panicking.
1989///
1990/// `table` is document-invariant — callers iterating over multiple dependencies in the
1991/// same document must build it once and reuse it, rather than rebuilding it (an O(n)
1992/// scan of `content`) per dependency.
1993fn slice_for_range<'a>(content: &'a str, table: &LineOffsetTable, range: Range) -> &'a str {
1994 let start = table.position_to_byte_offset(content, range.start);
1995 let end = table.position_to_byte_offset(content, range.end);
1996 if start > end {
1997 return "";
1998 }
1999 content.get(start..end).unwrap_or("")
2000}
2001
2002/// Checks whether `slice` — `content` sliced over a dependency's `version_range` — still
2003/// holds the literal version text declared by `requirement`.
2004///
2005/// Whitespace is stripped from both sides before comparison, since pep508's normalized
2006/// requirement string can diverge from the original source spacing (PyPI's `>=1.7,<2.0`
2007/// renders as `>=1.7, <2.0`) while `version_range` still spans the un-normalized source.
2008///
2009/// The second branch accepts `slice` wrapped in brackets matching `requirement` — the
2010/// exact inverse of NuGet's parser wrapping a bare source version as `format!("[{v}]")`
2011/// (`crates/deps-nuget/src/parser.rs`). This is deliberately **not** a symmetric bracket
2012/// strip: NuGet's `Version="1.0.0"` produces requirement `[1.0.0]` over a bare-literal
2013/// span, so a *symmetric* strip (stripping one bracket pair from both operands) would
2014/// compare `1.0.0` against `1.0.0` — coincidentally correct there, but the same strip
2015/// applied to `Version="[1.0.0]"` (requirement `[[1.0.0]]`, a spelling
2016/// `crates/deps-nuget/src/formatter.rs` explicitly supports) leaves `[1.0.0]` vs
2017/// `1.0.0` and **falsely rejects** an editable dependency. Wrapping only the slice side
2018/// handles both spellings without that false reject.
2019fn literal_span_matches(slice: &str, requirement: &str) -> bool {
2020 let norm_slice = strip_whitespace(slice);
2021 let norm_req = strip_whitespace(requirement);
2022 norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
2023}
2024
2025/// Whether `version_range`'s slice of `content` still holds `dep`'s own declared literal
2026/// version text.
2027///
2028/// The same literal-span discipline [`generate_code_actions`] and
2029/// `collect_update_all_edits` already apply before writing a `TextEdit` there, generalized
2030/// (issue #919) for any caller about to treat `version_range` as an editable literal: a
2031/// completion context, or a raw-text-scanning ecosystem's own completion dispatch (Maven,
2032/// Gradle) that never goes through [`crate::completion::detect_completion_context`] at all.
2033///
2034/// Returns `false` — not editable — when the slice is syntactically reference-shaped (see
2035/// below), or the slice doesn't textually match the declared literal. When `dep` has no
2036/// [`Dependency::version_requirement`] at all, an empty (or whitespace-only) slice is admitted
2037/// — e.g. Maven's `<version></version>`, which has no text for its parser to capture and thus
2038/// nothing that could be misread as a reference/wildcard (#1161) — but a *non-empty* slice is
2039/// still rejected, since there is then no requirement to validate it against.
2040///
2041/// The reference-shape check runs **independently** of the text comparison (#919 C1): an
2042/// *unresolved* Maven `${property}` or Gradle `$var`/`${var}` interpolation is left by its
2043/// parser exactly as-is in both `version_range`'s slice and `version_requirement` (there is
2044/// nothing else to put there), so the two would otherwise textually agree and wrongly pass —
2045/// this is precisely the corruption #919 was filed over, and the dominant real-world Maven
2046/// case (a version inherited from a parent/BOM POM this crate cannot resolve) always lands
2047/// here. A slice containing `$` anywhere is rejected up front, before ever comparing text —
2048/// this single check subsumes both a whole-value `$var`/`${var}` interpolation and a Gradle
2049/// GString with the reference embedded mid-string (`1.0.$patch`), which `resolve_variable_ref`
2050/// only ever resolves in its whole-value form, leaving the mid-string case byte-identical
2051/// between slice and requirement and otherwise invisible to a `starts_with`-only check. A
2052/// slice that starts with `*` is rejected the same way *unless* [`crate::is_existence_wildcard_str`]
2053/// recognizes it as the project-wide existence-wildcard spelling (synthesized by `deps-npm`,
2054/// `deps-composer`, ...) — that case must keep offering a full version list at the exact
2055/// moment a user wants to replace the wildcard with a pin, while a `*anchor` YAML alias (any
2056/// other leading-`*` shape) still gets rejected.
2057///
2058/// An empty `version_requirement` is deliberately **not** special-cased here (unlike
2059/// `generate_code_actions`'s "nothing to update" early return, which does not apply to
2060/// completion): `serde = ""` / `"lodash": ""` — what an editor's auto-closing quotes produce
2061/// the instant the opening quote is typed — must still offer the full version list.
2062/// `literal_span_matches` alone already handles this correctly: `("", "")` matches (empty
2063/// slice, empty requirement — admitted), while a non-empty non-literal slice against an empty
2064/// requirement still fails to match (rejected).
2065///
2066/// Compares against [`Dependency::version_literal`] when the ecosystem provides one, falling
2067/// back to `version_requirement` otherwise (see that method's doc for why).
2068///
2069/// Builds its own [`LineOffsetTable`] for `content` on every call rather than accepting a
2070/// caller-built one — unlike `slice_for_range`'s multi-dependency callers (`generate_code_actions`,
2071/// `code_lenses`), every current call site invokes this at most once per request (a single
2072/// matched dependency), so a table-accepting overload would add API surface with no caller to
2073/// exercise it.
2074///
2075/// # Examples
2076///
2077/// ```
2078/// use deps_core::lsp_helpers::dependency_version_range_is_literal;
2079/// use deps_core::{Dependency, PackageName, VersionReq};
2080/// use std::any::Any;
2081/// use tower_lsp_server::ls_types::{Position, Range};
2082///
2083/// struct MockDep {
2084/// name: PackageName,
2085/// version_req: VersionReq,
2086/// version_range: deps_core::position::Range,
2087/// }
2088/// impl Dependency for MockDep {
2089/// fn name(&self) -> &PackageName {
2090/// &self.name
2091/// }
2092/// fn name_range(&self) -> deps_core::position::Range {
2093/// deps_core::position::Range::default()
2094/// }
2095/// fn version_requirement(&self) -> Option<&VersionReq> {
2096/// Some(&self.version_req)
2097/// }
2098/// fn version_range(&self) -> Option<deps_core::position::Range> {
2099/// Some(self.version_range)
2100/// }
2101/// fn source(&self) -> deps_core::parser::DependencySource {
2102/// deps_core::parser::DependencySource::Registry
2103/// }
2104/// fn as_any(&self) -> &dyn Any {
2105/// self
2106/// }
2107/// }
2108///
2109/// // A plain literal version is admitted.
2110/// let content = r#"serde = "1.0.0""#;
2111/// let dep = MockDep {
2112/// name: PackageName::new("serde"),
2113/// version_req: VersionReq::new("1.0.0"),
2114/// version_range: Range::new(Position::new(0, 9), Position::new(0, 14)).into(),
2115/// };
2116/// assert!(dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
2117///
2118/// // A Maven `${property}` interpolation is rejected even when unresolved — slice and
2119/// // requirement are byte-identical raw reference text, which the shape check catches
2120/// // independently of the (otherwise-matching) text comparison.
2121/// let content = r"<version>${slf4j.version}</version>";
2122/// let dep = MockDep {
2123/// name: PackageName::new("slf4j-api"),
2124/// version_req: VersionReq::new("${slf4j.version}"),
2125/// version_range: Range::new(Position::new(0, 9), Position::new(0, 25)).into(),
2126/// };
2127/// assert!(!dependency_version_range_is_literal(&dep, content, dep.version_range.into()));
2128/// ```
2129#[must_use]
2130pub fn dependency_version_range_is_literal(
2131 dep: &dyn Dependency,
2132 content: &str,
2133 version_range: Range,
2134) -> bool {
2135 if version_range == Range::default() {
2136 // `Range::default()` ((0,0)-(0,0)) is a purely syntactic "not a real position"
2137 // sentinel, never a genuine in-document location — `deps-gradle`'s catalog context
2138 // deliberately returns it for a `version.ref = "alias"` reference (#931), including a
2139 // dangling or still-being-typed one, so this function must always reject it
2140 // unconditionally, before the `version_requirement()` check below ever runs.
2141 return false;
2142 }
2143
2144 let table = LineOffsetTable::new(content);
2145 let slice = slice_for_range(content, &table, version_range);
2146 let trimmed = slice.trim();
2147
2148 let Some(version_req) = dep.version_requirement() else {
2149 // No parsed requirement at all — e.g. Maven's `<version></version>`, which has no
2150 // text for its parser to capture. An empty span here has no existing text that could
2151 // be misread as a reference/wildcard, so it is always safe to offer completion
2152 // (#1161) — now that the `Range::default()` sentinel above is rejected first, this can
2153 // only be a genuine in-document position.
2154 return trimmed.is_empty();
2155 };
2156 if trimmed.contains('$')
2157 || (trimmed.starts_with('*') && !crate::is_existence_wildcard_str(trimmed))
2158 {
2159 return false;
2160 }
2161 let literal_target = dep
2162 .version_literal()
2163 .unwrap_or_else(|| version_req.as_str());
2164 literal_span_matches(slice, literal_target)
2165}
2166
2167#[cfg(test)]
2168#[expect(
2169 clippy::string_slice,
2170 reason = "fixtures are single-line ASCII literals with hand-computed byte offsets"
2171)]
2172mod tests {
2173 use super::*;
2174 use crate::lsp_helpers::test_support::*;
2175 use crate::{PackageName, VersionReq};
2176
2177 /// #919: a plain literal version — `version_range`'s slice equals the declared
2178 /// requirement exactly — must be admitted as editable.
2179 #[test]
2180 fn test_dependency_version_range_is_literal_plain_literal_admitted() {
2181 let content = r#"serde = "1.0.0""#;
2182 let dep = MockDep {
2183 name: PackageName::new("serde"),
2184 version_req: VersionReq::new("1.0.0"),
2185 version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
2186 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2187 };
2188
2189 assert!(dependency_version_range_is_literal(
2190 &dep,
2191 content,
2192 dep.version_range,
2193 ));
2194 }
2195
2196 /// #919: a Maven-style `${property}` interpolation — `version_range` slices to the raw
2197 /// placeholder text while the parser resolves `version_requirement` to the property's
2198 /// value (a different string) — must be rejected, since a completion/edit there would
2199 /// splice text into the interpolation instead of updating a version.
2200 #[test]
2201 fn test_dependency_version_range_is_literal_rejects_property_interpolation() {
2202 let content = r"<version>${slf4j.version}</version>";
2203 let dep = MockDep {
2204 name: PackageName::new("slf4j-api"),
2205 version_req: VersionReq::new("2.0.16"), // resolved property value
2206 version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
2207 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2208 };
2209
2210 assert!(!dependency_version_range_is_literal(
2211 &dep,
2212 content,
2213 dep.version_range,
2214 ));
2215 }
2216
2217 /// #919: a YAML alias-shaped value (`*anchor`, e.g. GitLab CI/GitHub Actions `ref: *pin`
2218 /// job reuse) never textually equals a declared literal requirement — rejected the same
2219 /// way as the property-interpolation case.
2220 #[test]
2221 fn test_dependency_version_range_is_literal_rejects_yaml_alias() {
2222 let content = "ref: *pin";
2223 let dep = MockDep {
2224 name: PackageName::new("job"),
2225 version_req: VersionReq::new("1.0.0"),
2226 version_range: Range::new(Position::new(0, 5), Position::new(0, 9)), // "*pin"
2227 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2228 };
2229
2230 assert!(!dependency_version_range_is_literal(
2231 &dep,
2232 content,
2233 dep.version_range,
2234 ));
2235 }
2236
2237 /// S1 (critic follow-up): an empty `version_requirement` over an equally-empty slice —
2238 /// exactly what an editor's auto-closing quotes produce the instant `serde = "` is typed
2239 /// — must be **admitted**, not rejected. `generate_code_actions`'s "nothing to update"
2240 /// semantics for an empty requirement do not apply to completion, where an empty value is
2241 /// the single most common moment to offer the full version list.
2242 #[test]
2243 fn test_dependency_version_range_is_literal_admits_empty_requirement_with_empty_slice() {
2244 let content = r#"serde = """#;
2245 let dep = MockDep {
2246 name: PackageName::new("serde"),
2247 version_req: VersionReq::new(""),
2248 version_range: Range::new(Position::new(0, 9), Position::new(0, 9)),
2249 name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
2250 };
2251
2252 assert!(dependency_version_range_is_literal(
2253 &dep,
2254 content,
2255 dep.version_range,
2256 ));
2257 }
2258
2259 /// S1 (critic follow-up): an empty `version_requirement` paired with a *non-empty*
2260 /// non-literal slice must still be rejected — dropping the old `is_empty()` early return
2261 /// must not also drop this case, which the plain text comparison already handles on its
2262 /// own (`"1.0" != ""`).
2263 #[test]
2264 fn test_dependency_version_range_is_literal_rejects_empty_requirement_with_mismatched_slice() {
2265 let content = r#"x = "1.0""#;
2266 let dep = MockDep {
2267 name: PackageName::new("x"),
2268 version_req: VersionReq::new(""),
2269 version_range: Range::new(Position::new(0, 5), Position::new(0, 8)), // "1.0"
2270 name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
2271 };
2272
2273 assert!(!dependency_version_range_is_literal(
2274 &dep,
2275 content,
2276 dep.version_range,
2277 ));
2278 }
2279
2280 /// C1 (critic finding): the headline #919 case — an *unresolved* Maven `${property}`
2281 /// reference. The parser leaves an unresolved property as-is in both `version_range`'s
2282 /// slice and `version_requirement` (there is nothing else to put there), so the two
2283 /// textually agree — `literal_span_matches` alone would wrongly admit this. The
2284 /// reference-shape check must reject it independently of that text comparison.
2285 #[test]
2286 fn test_dependency_version_range_is_literal_rejects_unresolved_maven_property() {
2287 let content = r"<version>${slf4j.version}</version>";
2288 let dep = MockDep {
2289 name: PackageName::new("slf4j-api"),
2290 version_req: VersionReq::new("${slf4j.version}"), // left unresolved by the parser
2291 version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), // "${slf4j.version}"
2292 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2293 };
2294
2295 assert!(!dependency_version_range_is_literal(
2296 &dep,
2297 content,
2298 dep.version_range,
2299 ));
2300 }
2301
2302 /// C1 (critic finding): the Gradle sibling of the unresolved-property case — a `$var`
2303 /// reference `resolve_variable_ref` could not resolve, left as raw text in both the slice
2304 /// and `version_requirement`.
2305 #[test]
2306 fn test_dependency_version_range_is_literal_rejects_unresolved_gradle_variable() {
2307 let content = "implementation 'com.example:lib:$libVersion'";
2308 let dep = MockDep {
2309 name: PackageName::new("com.example:lib"),
2310 version_req: VersionReq::new("$libVersion"), // left unresolved by the parser
2311 version_range: Range::new(Position::new(0, 32), Position::new(0, 43)), // "$libVersion"
2312 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2313 };
2314
2315 assert!(!dependency_version_range_is_literal(
2316 &dep,
2317 content,
2318 dep.version_range,
2319 ));
2320 }
2321
2322 /// C1 (critic finding): a partial interpolation embedded mid-string (`1.0-${suffix}`)
2323 /// does not start with `$`, but must still be rejected — the `contains("${")` arm of the
2324 /// reference-shape check, not just the `starts_with` arms.
2325 #[test]
2326 fn test_dependency_version_range_is_literal_rejects_embedded_interpolation() {
2327 let content = r#"x = "1.0-${suffix}""#;
2328 let dep = MockDep {
2329 name: PackageName::new("x"),
2330 version_req: VersionReq::new("1.0-${suffix}"),
2331 version_range: Range::new(Position::new(0, 5), Position::new(0, 18)), // "1.0-${suffix}"
2332 name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
2333 };
2334
2335 assert!(!dependency_version_range_is_literal(
2336 &dep,
2337 content,
2338 dep.version_range,
2339 ));
2340 }
2341
2342 /// M2 (critic follow-up): `"*"` — the project-wide existence-wildcard spelling
2343 /// (`deps_core::registry::is_existence_wildcard`, synthesized by `deps-npm`,
2344 /// `deps-composer`, ...) — must be **admitted**, not caught by the `*`-prefix arm meant
2345 /// for a `*anchor` YAML alias. Rejecting it would silently disable version completion at
2346 /// the exact moment a user wants to replace `"lodash": "*"` with a pinned version.
2347 #[test]
2348 fn test_dependency_version_range_is_literal_admits_existence_wildcard() {
2349 let content = r#"x = "*""#;
2350 let dep = MockDep {
2351 name: PackageName::new("x"),
2352 version_req: VersionReq::new("*"),
2353 version_range: Range::new(Position::new(0, 5), Position::new(0, 6)), // "*"
2354 name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
2355 };
2356
2357 assert!(dependency_version_range_is_literal(
2358 &dep,
2359 content,
2360 dep.version_range,
2361 ));
2362 }
2363
2364 /// M3 (critic follow-up): a `*anchor`-shaped slice that is *not exactly* `"*"` — e.g. a
2365 /// multi-char alias name — must still be rejected. Distinguishes the wildcard exemption
2366 /// above from a genuine leading-`*` reference.
2367 #[test]
2368 fn test_dependency_version_range_is_literal_rejects_multi_char_alias_despite_wildcard_exemption()
2369 {
2370 let content = "ref: *pinned-anchor";
2371 let dep = MockDep {
2372 name: PackageName::new("job"),
2373 version_req: VersionReq::new("1.0.0"),
2374 version_range: Range::new(Position::new(0, 5), Position::new(0, 19)), // "*pinned-anchor"
2375 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2376 };
2377
2378 assert!(!dependency_version_range_is_literal(
2379 &dep,
2380 content,
2381 dep.version_range,
2382 ));
2383 }
2384
2385 /// M3 (critic follow-up): a Gradle GString with a `$var` reference embedded mid-string
2386 /// (`1.0.$patch`, no braces, not at the start) is not resolved by
2387 /// `resolve_variable_ref` (whole-value only) — the slice stays byte-identical to the raw
2388 /// `version_requirement`, so only the shape check (not the text comparison) can reject
2389 /// it. This is #919 C1 in a narrower spelling than the whole-value `$var`/`${var}` case.
2390 #[test]
2391 fn test_dependency_version_range_is_literal_rejects_gradle_mid_string_variable() {
2392 let content = r#"implementation "com.example:lib:1.0.$patch""#;
2393 let dep = MockDep {
2394 name: PackageName::new("com.example:lib"),
2395 version_req: VersionReq::new("1.0.$patch"), // left unresolved by the parser
2396 version_range: Range::new(Position::new(0, 32), Position::new(0, 42)), // "1.0.$patch"
2397 name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2398 };
2399
2400 assert!(!dependency_version_range_is_literal(
2401 &dep,
2402 content,
2403 dep.version_range,
2404 ));
2405 }
2406
2407 /// #1161: a dependency with no `version_requirement()` at all — Maven's parser never
2408 /// sets one for a genuinely empty `<version></version>` tag, since there is no text to
2409 /// capture — must still be admitted when `version_range`'s slice is empty. There is no
2410 /// existing text at that position that could be misread as a reference/wildcard, so
2411 /// offering completion is always safe regardless of whether a requirement was parsed.
2412 #[test]
2413 fn test_dependency_version_range_is_literal_admits_empty_slice_with_no_requirement_at_all() {
2414 let content = "<version></version>";
2415 let dep = MockSyntheticRangeDep {
2416 name: PackageName::new("com.example:foo"),
2417 };
2418 let empty_range = Range::new(Position::new(0, 9), Position::new(0, 9));
2419
2420 assert!(dep.version_requirement().is_none());
2421 assert!(dependency_version_range_is_literal(
2422 &dep,
2423 content,
2424 empty_range
2425 ));
2426 }
2427
2428 /// Critic follow-up (C1, second round) to #1161: a DANGLING or still-being-typed
2429 /// `version.ref` alias (e.g. `version.ref = "gu"` while typing "guava") has
2430 /// `version_requirement() == None` — the exact same shape as Maven's genuinely empty
2431 /// `<version></version>` tag — but its range is `deps-gradle`'s `Range::default()`
2432 /// sentinel, not a real in-document position. This is the *normal* interactive state on
2433 /// every keystroke while typing an alias, not an edge case: admitting it here would offer
2434 /// the full unfiltered version list and splice a version literal into the alias name.
2435 /// Must be rejected regardless of `version_requirement()` being absent.
2436 #[test]
2437 fn test_dependency_version_range_is_literal_rejects_default_range_with_no_requirement_at_all() {
2438 let content = r#"version.ref = "gu""#;
2439 let dep = MockSyntheticRangeDep {
2440 name: PackageName::new("com.example:guava"),
2441 };
2442
2443 assert!(dep.version_requirement().is_none());
2444 assert!(!dependency_version_range_is_literal(
2445 &dep,
2446 content,
2447 Range::default(),
2448 ));
2449 }
2450
2451 /// Critic follow-up (C1) to #1161: an empty slice must NOT be admitted merely because it
2452 /// is empty — only when `version_requirement()` is also absent. `deps-gradle`'s catalog
2453 /// context deliberately returns `Range::default()` (which slices to `""`) for a
2454 /// `version.ref = "alias"` reference while `version_requirement()` still holds the
2455 /// alias's real, non-empty resolved value (#931) — the "always reject" guarantee that
2456 /// sentinel relies on comes from comparing that non-empty value against the empty slice,
2457 /// not from any special-casing of the empty slice itself. A version that checks
2458 /// `trimmed.is_empty()` before/independent of `version_requirement()` would invert this
2459 /// into an always-accept and let a full completion list splice a version literal into
2460 /// the alias name.
2461 #[test]
2462 fn test_dependency_version_range_is_literal_rejects_default_range_with_nonempty_requirement() {
2463 let content = r#"version.ref = "guavaVersion""#;
2464 let dep = MockDep {
2465 name: PackageName::new("com.example:guava"),
2466 version_req: VersionReq::new("32.0.1"), // the alias's resolved value
2467 version_range: Range::default(), // deps-gradle's deliberate always-reject sentinel
2468 name_range: Range::default(),
2469 };
2470
2471 assert!(!dependency_version_range_is_literal(
2472 &dep,
2473 content,
2474 dep.version_range,
2475 ));
2476 }
2477
2478 /// The empty-entry pruning invariant: an entry is removed once its last set channel
2479 /// is cleared, one channel at a time, in every order — never left behind as a
2480 /// vacuous `Some(DependencyOutcome::default())` that would inflate `len()`.
2481 #[test]
2482 fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
2483 let mut outcomes = DependencyOutcomes::new()
2484 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
2485 .with_deprecation(
2486 "pkg",
2487 Deprecation {
2488 reason: None,
2489 replacement: None,
2490 },
2491 )
2492 .with_fetch_failure("pkg", FetchFailure::Transient);
2493 assert_eq!(outcomes.len(), 1);
2494
2495 outcomes.clear_yanked("pkg");
2496 assert!(
2497 outcomes.get("pkg").is_some(),
2498 "entry must survive while other channels are still set"
2499 );
2500
2501 outcomes.clear_deprecation("pkg");
2502 assert!(
2503 outcomes.get("pkg").is_some(),
2504 "entry must survive while the fetch-failure channel is still set"
2505 );
2506
2507 outcomes.clear_fetch_failure("pkg");
2508 assert!(
2509 outcomes.is_empty(),
2510 "entry must be pruned once its last channel is cleared, not left as a vacuous Some"
2511 );
2512 assert_eq!(outcomes.len(), 0);
2513 }
2514
2515 /// Clearing a channel that was never set on an existing entry must not spuriously
2516 /// prune channels that ARE still set (each `clear_*` only nulls its own field).
2517 #[test]
2518 fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
2519 let mut outcomes = DependencyOutcomes::new()
2520 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
2521
2522 outcomes.clear_deprecation("pkg");
2523 outcomes.clear_fetch_failure("pkg");
2524
2525 assert!(
2526 outcomes.yanked("pkg").is_some(),
2527 "clearing unset channels must not touch the still-set yanked channel"
2528 );
2529 assert_eq!(outcomes.len(), 1);
2530 }
2531
2532 /// `remove` drops the whole entry regardless of which channels are set, unlike the
2533 /// per-channel `clear_*` methods.
2534 #[test]
2535 fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
2536 let mut outcomes = DependencyOutcomes::new()
2537 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
2538 .with_fetch_failure("pkg", FetchFailure::Transient);
2539
2540 outcomes.remove("pkg");
2541
2542 assert!(outcomes.get("pkg").is_none());
2543 assert!(outcomes.is_empty());
2544 }
2545
2546 /// `clear_all_fetch_failures` (deps-lsp issue #592) drops the fetch-failure channel for
2547 /// every entry, pruning an entry that becomes empty, while leaving other channels
2548 /// (yanked/deprecation/no-comparable-versions) on a still-mixed entry untouched.
2549 #[test]
2550 fn test_dependency_outcomes_clear_all_fetch_failures() {
2551 let mut outcomes = DependencyOutcomes::new()
2552 .with_fetch_failure("only-failure", FetchFailure::Transient)
2553 .with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
2554 .with_fetch_failure("mixed", FetchFailure::Transient);
2555
2556 outcomes.clear_all_fetch_failures();
2557
2558 assert!(
2559 outcomes.get("only-failure").is_none(),
2560 "an entry whose only channel was fetch-failure must be pruned"
2561 );
2562 assert!(outcomes.fetch_failure("mixed").is_none());
2563 assert!(
2564 outcomes.yanked("mixed").is_some(),
2565 "clearing fetch-failure must not touch a mixed entry's other channels"
2566 );
2567 }
2568
2569 /// `clear_all_fetch_failures` on an empty map is a no-op, not a panic.
2570 #[test]
2571 fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
2572 let mut outcomes = DependencyOutcomes::new();
2573 outcomes.clear_all_fetch_failures();
2574 assert!(outcomes.is_empty());
2575 }
2576
2577 /// `set_fetch_failure_if_absent` (impl-critic M2) must never clobber a genuine
2578 /// `Actionable`/`Transient` failure already recorded for the name — only `set_fetch_failure`
2579 /// unconditionally overwrites.
2580 #[test]
2581 fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
2582 let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
2583 "pkg",
2584 FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
2585 );
2586
2587 outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
2588
2589 assert_eq!(
2590 outcomes.fetch_failure("pkg"),
2591 Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
2592 "an existing genuine failure must survive a collided-name NotAttempted marker"
2593 );
2594 }
2595
2596 /// The mirror case: when no failure is recorded yet, `set_fetch_failure_if_absent` does
2597 /// populate the entry.
2598 #[test]
2599 fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
2600 let mut outcomes = DependencyOutcomes::new();
2601
2602 outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
2603
2604 assert_eq!(
2605 outcomes.fetch_failure("pkg"),
2606 Some(&FetchFailure::NotAttempted)
2607 );
2608 }
2609
2610 #[test]
2611 fn test_line_offset_table_line_start_crlf() {
2612 let table = LineOffsetTable::new("a\r\nbb\r\nc");
2613 assert_eq!(table.line_start(0), Some(0));
2614 assert_eq!(table.line_start(1), Some(3));
2615 assert_eq!(table.line_start(2), Some(7));
2616 assert_eq!(table.line_start(3), None);
2617 }
2618
2619 #[test]
2620 fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
2621 // "é" is a 2-byte UTF-8 sequence; offset 1 lands inside it.
2622 let content = "é";
2623 let table = LineOffsetTable::new(content);
2624 // Must not panic; clamps down to the nearest boundary (offset 0).
2625 let pos = table.byte_offset_to_position(content, 1);
2626 assert_eq!(pos, Position::new(0, 0));
2627 }
2628
2629 #[test]
2630 fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
2631 let content = "ab é cd";
2632 let table = LineOffsetTable::new(content);
2633 // Byte 3 is 'é's leading byte (boundary); byte 4 is its continuation
2634 // byte (not a boundary) and must clamp back to 3 rather than panic.
2635 assert!(content.is_char_boundary(3));
2636 assert!(!content.is_char_boundary(4));
2637 let pos = table.byte_offset_to_position(content, 4);
2638 assert_eq!(pos, table.byte_offset_to_position(content, 3));
2639 }
2640
2641 /// Covers the ASCII/non-ASCII fast-path split introduced for #742: an ASCII-only line
2642 /// (line 0), a non-ASCII line mixing a BMP accented character with a surrogate-pair
2643 /// emoji (line 1), and a mixed line (line 2) must all still resolve to the same
2644 /// `Position`s as the original `chars().map(char::len_utf16).sum()` scan would produce.
2645 #[test]
2646 fn test_byte_offset_to_position_ascii_and_non_ascii_lines_agree() {
2647 let content = "abcde\nhéllo 😀\nab café end";
2648 let table = LineOffsetTable::new(content);
2649
2650 // Line 0 ("abcde"): ASCII fast path, byte offset == UTF-16 character offset.
2651 assert_eq!(
2652 table.byte_offset_to_position(content, 3),
2653 Position::new(0, 3)
2654 );
2655 assert_eq!(
2656 table.byte_offset_to_position(content, 5),
2657 Position::new(0, 5)
2658 );
2659
2660 // Line 1 ("héllo 😀"): non-ASCII scan path.
2661 // Offset after "h\u{e9}" (1 + 2 bytes into the line): 'h' + 'é' = 2 UTF-16 units.
2662 assert_eq!(
2663 table.byte_offset_to_position(content, 6 + 3),
2664 Position::new(1, 2)
2665 );
2666 // Offset at the end of the line: "héllo 😀" = 8 UTF-16 units (emoji is a surrogate pair).
2667 assert_eq!(
2668 table.byte_offset_to_position(content, 6 + 11),
2669 Position::new(1, 8)
2670 );
2671
2672 // Line 2 ("ab café end"): mixed line, still takes the non-ASCII scan path.
2673 // Offset after "ab café" (8 bytes into the line): 7 UTF-16 units ('é' is 1 BMP unit).
2674 assert_eq!(
2675 table.byte_offset_to_position(content, 18 + 8),
2676 Position::new(2, 7)
2677 );
2678 }
2679
2680 /// Regression guard for #742: `byte_offset_to_position` on a large single-line
2681 /// (minified) manifest must stay near-instant. The pre-fix implementation rescanned
2682 /// the line from its start on every call, making repeated lookups over such a line
2683 /// O(n^2) in the line's length (~350ms for 8000 calls on an ~180KB line on the
2684 /// reporter's machine); the ASCII fast path makes each call O(1), so this generous
2685 /// wall-clock bound leaves wide margin without being flaky.
2686 #[test]
2687 fn test_byte_offset_to_position_minified_line_stays_fast() {
2688 let mut content = String::from("{\"dependencies\":{");
2689 for i in 0..8000 {
2690 content.push_str(&format!("\"dep{i}\":\"1.0.{i}\","));
2691 }
2692 content.push_str("}}");
2693
2694 let table = LineOffsetTable::new(&content);
2695 let step = (content.len() / 8000).max(1);
2696 let start = std::time::Instant::now();
2697 for offset in (0..content.len()).step_by(step) {
2698 std::hint::black_box(table.byte_offset_to_position(&content, offset));
2699 }
2700 let elapsed = start.elapsed();
2701
2702 assert!(
2703 elapsed < std::time::Duration::from_millis(100),
2704 "8000 byte_offset_to_position calls on a minified line took {elapsed:?}, expected < 100ms"
2705 );
2706 }
2707
2708 /// Regression guard for #882 (widened scope): `byte_offset_to_position`'s non-ASCII branch
2709 /// used to walk `chars().map(len_utf16).sum()` from the line's start on every call, so N
2710 /// lookups on the same wide non-ASCII line cost O(N x line length) — the same shape #882
2711 /// reported for `marker_byte_offset`, and, per the #882 review, ~300x its cost since
2712 /// `make_range` calls this twice per dependency. The shared per-line index makes every
2713 /// lookup after the first on a given line O(1).
2714 #[test]
2715 fn test_byte_offset_to_position_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
2716 let filler = "x".repeat(200 * 1024);
2717 let content = format!("{filler}\u{2014}{filler}");
2718 let table = LineOffsetTable::new(&content);
2719 let step = (content.len() / 5000).max(1);
2720 let start = std::time::Instant::now();
2721 for offset in (0..content.len()).step_by(step) {
2722 std::hint::black_box(table.byte_offset_to_position(&content, offset));
2723 }
2724 assert!(
2725 start.elapsed() < std::time::Duration::from_secs(1),
2726 "byte_offset_to_position took {:?} for ~5000 lookups on a huge non-ASCII line, \
2727 expected well under 1s",
2728 start.elapsed()
2729 );
2730 }
2731
2732 /// Differential test: the cached non-ASCII path must agree with a plain reimplemented
2733 /// `chars().map(len_utf16).sum()` walk for every char-boundary offset on a non-ASCII line,
2734 /// including offsets on more than one non-ASCII line in the same document (cache isolation
2735 /// across lines, sharing the same underlying index `marker_byte_offset` populates).
2736 #[test]
2737 fn test_byte_offset_to_position_non_ascii_cache_matches_char_sum_result_across_lines() {
2738 fn slow_path(content: &str, line_start: usize, offset: usize) -> u32 {
2739 u32::try_from(
2740 content[line_start..offset]
2741 .chars()
2742 .map(char::len_utf16)
2743 .sum::<usize>(),
2744 )
2745 .unwrap_or(u32::MAX)
2746 }
2747
2748 let content = "\u{1f680}rocket \u{2014} dash\nascii line\n\u{3000}ideographic \u{e9}nd";
2749 let table = LineOffsetTable::new(content);
2750 let line_starts = [
2751 table.line_start(0).unwrap(),
2752 table.line_start(1).unwrap(),
2753 table.line_start(2).unwrap(),
2754 ];
2755 let line_ends = [line_starts[1] - 1, line_starts[2] - 1, content.len()];
2756
2757 for (line, (&line_start, &line_end)) in line_starts.iter().zip(&line_ends).enumerate() {
2758 let line_text = &content[line_start..line_end];
2759 for (byte_offset, _) in line_text
2760 .char_indices()
2761 .chain(std::iter::once((line_text.len(), '\0')))
2762 {
2763 let offset = line_start + byte_offset;
2764 assert_eq!(
2765 table.byte_offset_to_position(content, offset).character,
2766 slow_path(content, line_start, offset),
2767 "line {line} byte offset {byte_offset} desynced from the char-sum walk"
2768 );
2769 }
2770 }
2771 }
2772
2773 #[test]
2774 fn test_position_in_range_inside() {
2775 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2776 let position = Position::new(5, 15);
2777 assert!(position_in_range(position, range));
2778 }
2779
2780 #[test]
2781 fn test_position_in_range_at_start() {
2782 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2783 let position = Position::new(5, 10);
2784 assert!(position_in_range(position, range));
2785 }
2786
2787 #[test]
2788 fn test_position_in_range_at_end() {
2789 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2790 let position = Position::new(5, 20);
2791 assert!(position_in_range(position, range));
2792 }
2793
2794 #[test]
2795 fn test_position_in_range_before() {
2796 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2797 let position = Position::new(5, 5);
2798 assert!(!position_in_range(position, range));
2799 }
2800
2801 #[test]
2802 fn test_position_in_range_after() {
2803 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2804 let position = Position::new(5, 25);
2805 assert!(!position_in_range(position, range));
2806 }
2807
2808 #[test]
2809 fn test_position_in_range_different_line_before() {
2810 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2811 let position = Position::new(4, 15);
2812 assert!(!position_in_range(position, range));
2813 }
2814
2815 #[test]
2816 fn test_position_in_range_different_line_after() {
2817 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
2818 let position = Position::new(6, 15);
2819 assert!(!position_in_range(position, range));
2820 }
2821
2822 #[test]
2823 fn test_position_in_range_multiline() {
2824 let range = Range::new(Position::new(5, 10), Position::new(7, 5));
2825 let position = Position::new(6, 0);
2826 assert!(position_in_range(position, range));
2827 }
2828
2829 #[test]
2830 fn test_escape_markdown_link_breakout_payload() {
2831 let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
2832 let escaped = escape_markdown(payload);
2833 assert_eq!(
2834 escaped,
2835 r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
2836 );
2837 assert!(!escaped.contains("]("));
2838 }
2839
2840 #[test]
2841 fn test_escape_markdown_backslash_and_backtick() {
2842 assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
2843 }
2844
2845 #[test]
2846 fn test_escape_markdown_autolink_angle_brackets() {
2847 // `<...>` around a bare URL is a CommonMark autolink; `<`/`>` must be escaped
2848 // so it cannot render as a live link independent of the `[]`/`()` escaping.
2849 let escaped = escape_markdown("pkg <https://evil.example>");
2850 assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
2851 assert!(!escaped.contains('<') || escaped.contains(r"\<"));
2852 }
2853
2854 #[test]
2855 fn test_escape_markdown_control_chars_become_spaces() {
2856 assert_eq!(escape_markdown("a\nb"), "a b");
2857 assert_eq!(escape_markdown("a\r\nb"), "a b");
2858 assert_eq!(escape_markdown("a\tb"), "a b");
2859 assert_eq!(escape_markdown("a\0b"), "a b");
2860 }
2861
2862 #[test]
2863 fn test_escape_markdown_newline_cannot_break_out_of_heading() {
2864 // A raw newline used to terminate the ATX heading line early, letting the
2865 // rest of the name (potentially another "# [...](...)" sequence) render as
2866 // separate, unescaped Markdown blocks.
2867 let escaped = escape_markdown("react\n# [fake](https://evil.example)");
2868 assert!(!escaped.contains('\n'));
2869 }
2870
2871 #[test]
2872 fn test_escape_markdown_hyphenated_name_round_trips_visually() {
2873 // Escaping a hyphen (ASCII punctuation) is visually inert on render — CommonMark
2874 // renders `\-` as a literal `-` — so common package names are unaffected in
2875 // practice even though the raw Markdown source now escapes them.
2876 assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
2877 }
2878
2879 #[test]
2880 fn test_markdown_code_span_plain_content() {
2881 assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
2882 }
2883
2884 #[test]
2885 fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
2886 assert_eq!(markdown_code_span("a`b"), "``a`b``");
2887 assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
2888 }
2889
2890 #[test]
2891 fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
2892 let span = markdown_code_span("`leading");
2893 assert!(span.starts_with("`` `"));
2894 }
2895
2896 #[test]
2897 fn test_markdown_code_span_replaces_control_chars() {
2898 let span = markdown_code_span("1.0\n[evil](https://evil.example)");
2899 assert!(!span.contains('\n'));
2900 }
2901
2902 #[test]
2903 fn test_markdown_code_span_empty_content() {
2904 assert_eq!(markdown_code_span(""), "` `");
2905 }
2906
2907 #[test]
2908 fn test_markdown_code_span_backtick_payload_cannot_break_span() {
2909 // A payload closing the code span early to splice in a live link must not succeed.
2910 let payload = "1.0` <https://evil.example>` more";
2911 let span = markdown_code_span(payload);
2912 // The fence must be strictly longer than any backtick run in the content, so no
2913 // substring after the opening fence can act as a closing fence before the real one.
2914 let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
2915 let inner = &span[opening_fence_len..span.len() - opening_fence_len];
2916 assert!(
2917 !inner.contains(&"`".repeat(opening_fence_len)),
2918 "content contains a run of backticks as long as the fence: {span}"
2919 );
2920 }
2921
2922 #[test]
2923 fn test_escape_markdown_replaces_bidi_and_invisible_characters() {
2924 for c in [
2925 '\u{202E}', // RLO — the Trojan Source vector
2926 '\u{2066}', // LRI
2927 '\u{200B}', // ZWSP
2928 '\u{2060}', // WORD JOINER
2929 '\u{2028}', // LS
2930 '\u{FEFF}', // BOM
2931 '\u{FFFA}', // interlinear annotation separator
2932 '\u{E0041}', // Unicode tag character ("A")
2933 ] {
2934 let escaped = escape_markdown(&format!("a{c}b"));
2935 assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
2936 }
2937 }
2938
2939 /// #1276: `Diagnostic::new`/`RelatedInformation::new` apply `replace_markdown_unsafe_chars`
2940 /// on top of producer-side sanitization that may have already called it —
2941 /// double-application must be a no-op, since `' '` (the substitution) is not itself
2942 /// `is_markdown_unsafe`.
2943 #[test]
2944 fn test_replace_markdown_unsafe_chars_is_idempotent() {
2945 for c in [
2946 '\u{202E}',
2947 '\u{2066}',
2948 '\u{200B}',
2949 '\u{2060}',
2950 '\u{2028}',
2951 '\u{2029}',
2952 '\u{FEFF}',
2953 '\u{FFFA}',
2954 '\u{E0041}',
2955 '\n',
2956 '\t',
2957 ] {
2958 let input = format!("a{c}b");
2959 let once = replace_markdown_unsafe_chars(&input);
2960 assert_eq!(once, "a b", "{c:?} must be replaced with a single space");
2961 let twice = replace_markdown_unsafe_chars(&once);
2962 assert_eq!(twice, once, "{c:?} must not change on re-application");
2963 }
2964 assert!(
2965 !is_markdown_unsafe(' '),
2966 "substituted space must be stable under re-application"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_escape_markdown_preserves_legitimate_bidi_marks() {
2972 for c in [
2973 '\u{200F}', // RLM
2974 '\u{061C}', // ALM
2975 '\u{200E}', // LRM
2976 '\u{200D}', // ZWJ
2977 ] {
2978 let escaped = escape_markdown(&format!("a{c}b"));
2979 assert_eq!(
2980 escaped,
2981 format!("a{c}b"),
2982 "{c:?} must survive verbatim (legitimate RTL/emoji use)"
2983 );
2984 }
2985 }
2986
2987 #[test]
2988 fn test_markdown_code_span_replaces_bidi_and_invisible_characters() {
2989 for c in [
2990 '\u{202E}',
2991 '\u{2066}',
2992 '\u{200B}',
2993 '\u{2060}',
2994 '\u{2029}',
2995 '\u{FEFF}',
2996 '\u{FFFA}',
2997 '\u{E0041}',
2998 ] {
2999 let span = markdown_code_span(&format!("a{c}b"));
3000 assert_eq!(span, "`a b`", "{c:?} must be replaced with a space");
3001 }
3002 }
3003
3004 #[test]
3005 fn test_markdown_code_span_preserves_legitimate_bidi_marks() {
3006 for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200D}'] {
3007 let span = markdown_code_span(&format!("a{c}b"));
3008 assert_eq!(
3009 span,
3010 format!("`a{c}b`"),
3011 "{c:?} must survive verbatim (legitimate RTL/emoji use)"
3012 );
3013 }
3014 }
3015
3016 #[test]
3017 fn test_is_same_major_minor_full_match() {
3018 assert!(is_same_major_minor("1.2.3", "1.2.9"));
3019 }
3020
3021 #[test]
3022 fn test_is_same_major_minor_exact_match() {
3023 assert!(is_same_major_minor("1.2.3", "1.2.3"));
3024 }
3025
3026 #[test]
3027 fn test_is_same_major_minor_major_only_match() {
3028 assert!(is_same_major_minor("1", "1.2.3"));
3029 assert!(is_same_major_minor("1.2.3", "1"));
3030 }
3031
3032 #[test]
3033 fn test_is_same_major_minor_no_match_different_minor() {
3034 assert!(!is_same_major_minor("1.2.3", "1.3.0"));
3035 }
3036
3037 #[test]
3038 fn test_is_same_major_minor_no_match_different_major() {
3039 assert!(!is_same_major_minor("1.2.3", "2.2.3"));
3040 }
3041
3042 #[test]
3043 fn test_is_same_major_minor_empty_strings() {
3044 assert!(!is_same_major_minor("", ""));
3045 assert!(!is_same_major_minor("1.2.3", ""));
3046 assert!(!is_same_major_minor("", "1.2.3"));
3047 }
3048
3049 #[test]
3050 fn test_is_safe_version_string_accepts_ordinary_versions() {
3051 assert!(is_safe_version_string("1.2.3"));
3052 assert!(is_safe_version_string("1.2.3-beta.1+build"));
3053 assert!(is_safe_version_string("v1.2.3"));
3054 }
3055
3056 #[test]
3057 fn test_is_safe_version_string_rejects_empty_or_whitespace() {
3058 assert!(!is_safe_version_string(""));
3059 assert!(!is_safe_version_string(" "));
3060 assert!(!is_safe_version_string("\t\n"));
3061 }
3062
3063 #[test]
3064 fn test_is_safe_version_string_rejects_control_and_structural_characters() {
3065 for bad in [
3066 "1.2.3\n",
3067 "1.2.3\t",
3068 "1.2.3\"",
3069 "1.2.3'",
3070 "1.2.3<",
3071 "1.2.3>",
3072 "1.2.3&",
3073 "1.2.3\\",
3074 "1.0.0\", \"malicious\": \"true",
3075 ] {
3076 assert!(
3077 !is_safe_version_string(bad),
3078 "expected {bad:?} to be rejected"
3079 );
3080 }
3081 }
3082
3083 #[test]
3084 fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
3085 // Regression (critic S2): `$`/`{`/`}` are outside the allowlist, so a Gradle
3086 // `${...}` interpolation payload can never reach a version literal via this gate.
3087 for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
3088 assert!(
3089 !is_safe_version_string(bad),
3090 "expected {bad:?} to be rejected"
3091 );
3092 }
3093 }
3094
3095 #[test]
3096 fn test_is_safe_version_string_rejects_invisible_unicode() {
3097 // Regression (critic M1): `char::is_control()` alone only covers category Cc — the
3098 // bidi override U+202E, zero-width space U+200B, and JS/JSON5 line terminators
3099 // U+2028/U+2029 must also be rejected.
3100 for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
3101 assert!(
3102 !is_safe_version_string(bad),
3103 "expected {bad:?} to be rejected"
3104 );
3105 }
3106 }
3107
3108 #[test]
3109 fn test_is_safe_version_string_accepts_pep440_epoch() {
3110 // PEP 440 epochs (`1!2.0`) are legitimate PyPI versions.
3111 assert!(is_safe_version_string("1!2.0"));
3112 }
3113
3114 #[test]
3115 fn test_is_safe_version_string_length_cap() {
3116 assert!(is_safe_version_string(&"1".repeat(64)));
3117 assert!(!is_safe_version_string(&"1".repeat(65)));
3118 }
3119
3120 #[test]
3121 fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
3122 assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
3123 assert!(is_safe_maven_coordinate_segment("commons-lang3"));
3124 assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
3125 }
3126
3127 #[test]
3128 fn test_is_safe_maven_coordinate_segment_rejects_empty() {
3129 assert!(!is_safe_maven_coordinate_segment(""));
3130 }
3131
3132 #[test]
3133 fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
3134 for bad in [
3135 "commons</artifactId><parent>",
3136 "commons\"",
3137 "commons'",
3138 "commons&",
3139 "commons\nlang3",
3140 "commons\tlang3",
3141 ] {
3142 assert!(
3143 !is_safe_maven_coordinate_segment(bad),
3144 "expected {bad:?} to be rejected"
3145 );
3146 }
3147 }
3148
3149 #[test]
3150 fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
3151 assert!(!is_safe_maven_coordinate_segment(
3152 "org.apache.commons:commons-lang3"
3153 ));
3154 }
3155
3156 #[test]
3157 fn test_is_safe_maven_coordinate_segment_length_cap() {
3158 assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
3159 assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
3160 }
3161
3162 #[test]
3163 fn test_is_safe_registry_url_accepts_real_urls() {
3164 assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
3165 assert!(is_safe_registry_url(
3166 "https://github.com/apple/swift-nio.git"
3167 ));
3168 assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
3169 }
3170
3171 #[test]
3172 fn test_is_safe_registry_url_rejects_non_https_scheme() {
3173 // Every real Swift package registry response is HTTPS; accepting `http://` would
3174 // only hand a compromised registry a transport-downgrade lever.
3175 assert!(!is_safe_registry_url("http://example.com/repo"));
3176 assert!(!is_safe_registry_url("file:///etc/passwd"));
3177 assert!(!is_safe_registry_url("javascript:alert(1)"));
3178 assert!(!is_safe_registry_url("ftp://example.com/repo"));
3179 assert!(!is_safe_registry_url(""));
3180 }
3181
3182 #[test]
3183 fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
3184 for bad in [
3185 "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
3186 "https://evil.example\\",
3187 "https://evil.example\nlet x = 1",
3188 "https://evil.example`echo`",
3189 "https://evil.example<script>",
3190 ] {
3191 assert!(
3192 !is_safe_registry_url(bad),
3193 "expected {bad:?} to be rejected"
3194 );
3195 }
3196 }
3197
3198 #[test]
3199 fn test_is_safe_registry_url_length_cap() {
3200 let prefix = "https://example.com/";
3201 let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
3202 assert_eq!(at_cap.len(), 2048);
3203 assert!(is_safe_registry_url(&at_cap));
3204
3205 let over_cap = format!("{at_cap}a");
3206 assert_eq!(over_cap.len(), 2049);
3207 assert!(!is_safe_registry_url(&over_cap));
3208 }
3209
3210 #[test]
3211 fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
3212 for good in [
3213 "serde", // Cargo
3214 "requests", // PyPI
3215 "@scope/name", // npm/Deno scoped
3216 "monolog/monolog", // Composer vendor/package
3217 "github.com/org/repo", // Go module path
3218 "path", // Dart
3219 "org.apache.commons:commons-lang3", // Gradle group:artifact
3220 "Newtonsoft.Json", // NuGet
3221 "rails", // Bundler
3222 "npm:react", // Deno npm-scheme specifier
3223 "jsr:@std/fs", // Deno jsr-scheme specifier
3224 "github.com/foo/bar~compat", // Go path element with `~`
3225 ] {
3226 assert!(
3227 is_safe_package_name(good),
3228 "expected {good:?} to be accepted"
3229 );
3230 }
3231 }
3232
3233 #[test]
3234 fn test_is_safe_package_name_rejects_empty() {
3235 assert!(!is_safe_package_name(""));
3236 }
3237
3238 #[test]
3239 fn test_is_safe_package_name_rejects_non_ascii() {
3240 // Deliberately excluded: the allowlist is ASCII-only, so a legacy non-ASCII
3241 // npm package name (a handful exist, e.g. Unicode-normalized scopes) is
3242 // rejected rather than risking homograph/normalization tricks in a manifest.
3243 assert!(!is_safe_package_name("café"));
3244 assert!(!is_safe_package_name("пакет"));
3245 }
3246
3247 #[test]
3248 fn test_is_safe_package_name_accepts_dot_dot_shapes() {
3249 // `.`/`/` are individually legal (PyPI dotted names, npm/Composer scopes), so
3250 // `..`/`../..` pass the charset too. This is not a path-traversal risk: every
3251 // sink treats `name` as manifest text (a TOML/JSON/YAML/XML value or a
3252 // string-literal argument), never as a filesystem path.
3253 assert!(is_safe_package_name(".."));
3254 assert!(is_safe_package_name("../.."));
3255 }
3256
3257 #[test]
3258 fn test_is_safe_package_name_rejects_structural_breakout_characters() {
3259 for bad in [
3260 "evil\"\nbackdoor = \"9.9.9",
3261 "evil\", git = \"https://evil",
3262 "evil\\",
3263 "evil'",
3264 "evil<script>",
3265 "evil`echo`",
3266 "evil\ninjected = true",
3267 "evil\tname",
3268 ] {
3269 assert!(
3270 !is_safe_package_name(bad),
3271 "expected {bad:?} to be rejected"
3272 );
3273 }
3274 }
3275
3276 #[test]
3277 fn test_is_safe_package_name_length_cap() {
3278 assert!(is_safe_package_name(&"a".repeat(256)));
3279 assert!(!is_safe_package_name(&"a".repeat(257)));
3280 }
3281
3282 #[test]
3283 fn test_is_same_major_minor_partial_versions() {
3284 assert!(is_same_major_minor("1.2", "1.2.3"));
3285 assert!(is_same_major_minor("1.2.3", "1.2"));
3286 }
3287
3288 #[test]
3289 fn test_ecosystem_formatter_defaults() {
3290 let formatter = MockFormatter;
3291 assert_eq!(
3292 formatter.normalize_package_name(&pkg("test-pkg")),
3293 "test-pkg"
3294 );
3295 assert_eq!(formatter.yanked_message(), "This version has been yanked");
3296 assert_eq!(formatter.yanked_label(), "*(yanked)*");
3297 }
3298
3299 #[test]
3300 fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
3301 let formatter = MockFormatter;
3302 let dep = MockDep {
3303 name: pkg("test-pkg"),
3304 version_req: VersionReq::new("1.0.0"),
3305 version_range: Range::default(),
3306 name_range: Range::default(),
3307 };
3308 assert_eq!(
3309 formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
3310 formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
3311 );
3312 }
3313
3314 #[test]
3315 fn test_ecosystem_formatter_version_satisfies() {
3316 let formatter = MockFormatter;
3317
3318 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
3319
3320 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
3321 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
3322
3323 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
3324 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
3325
3326 assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
3327 assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.3"));
3328 }
3329
3330 #[test]
3331 fn test_ecosystem_formatter_custom_normalize() {
3332 struct PyPIFormatter;
3333
3334 impl PackageNaming for PyPIFormatter {
3335 fn normalize_package_name(&self, name: &PackageName) -> String {
3336 name.as_str().to_lowercase().replace('-', "_")
3337 }
3338 }
3339
3340 impl PackageRendering for PyPIFormatter {
3341 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
3342 format!(
3343 ">={},<{}",
3344 version,
3345 version.as_str().split('.').next().unwrap_or("0")
3346 )
3347 }
3348
3349 fn package_url(&self, name: &PackageName) -> String {
3350 format!("https://pypi.org/project/{}", name.as_str())
3351 }
3352 }
3353
3354 impl RequirementResolution for PyPIFormatter {}
3355
3356 impl DiagnosticMessages for PyPIFormatter {}
3357
3358 impl DiagnosticPolicy for PyPIFormatter {}
3359
3360 impl SourcePolicy for PyPIFormatter {}
3361
3362 impl OsvNaming for PyPIFormatter {}
3363
3364 let formatter = PyPIFormatter;
3365 assert_eq!(
3366 formatter.normalize_package_name(&pkg("Test-Package")),
3367 "test_package"
3368 );
3369 assert_eq!(
3370 formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
3371 ">=1.2.3,<1"
3372 );
3373 assert_eq!(
3374 formatter.package_url(&pkg("requests")),
3375 "https://pypi.org/project/requests"
3376 );
3377 }
3378}