deps_core/lsp_helpers/mod.rs
1//! Shared LSP response builders.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use tower_lsp_server::ls_types::{Position, Range, TextEdit, Uri};
6
7use crate::osv::VulnerabilityMap;
8use crate::{
9 ConcreteVersion, Deprecation, DepsDevClient, EcosystemId, FetchFailure, PackageName,
10 RemovalStatus,
11};
12
13mod code_actions;
14mod code_lenses;
15mod diagnostics;
16mod formatter;
17mod git_ref;
18mod hover;
19mod in_use_version;
20mod inlay_hints;
21#[cfg(test)]
22mod test_support;
23
24pub use code_actions::generate_code_actions;
25pub use code_lenses::{collect_update_all_edits, generate_code_lenses};
26pub use diagnostics::{
27 DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities, UNSATISFIABLE_DIAGNOSTIC_CODE,
28 compile_requirement_unless, generate_diagnostics_from_cache, requirement_is_unsatisfiable,
29 truncate_for_diagnostic,
30};
31pub use formatter::{
32 DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
33 PackageRendering, RequirementResolution, SourcePolicy,
34};
35pub use git_ref::{
36 CharOffsets, MAX_FALLBACK_SCAN_BYTES, is_full_sha, is_tag_shaped, locate_value_span,
37 match_v_prefix_style,
38};
39pub use hover::{CMD_DOT_FOOTER, generate_hover};
40pub use in_use_version::{concrete_pin_version, in_use_version, is_full_semver_shape};
41pub use inlay_hints::generate_inlay_hints;
42
43/// Maximum number of recent versions hover's "Recent versions" section renders.
44///
45/// Also the walk target for registries (NuGet, npm) that must fetch publish times for
46/// only the versions actually rendered, rather than the entire version history.
47pub const HOVER_RECENT_VERSIONS: usize = 8;
48
49/// Registry version data for one package, fetched together in a single round trip.
50///
51/// `latest` and `available` are deliberately asymmetric — this is load-bearing, not an
52/// oversight:
53/// - `latest` comes from this ecosystem's own `Registry::select_latest_matching(.., "*")`
54/// pick, which excludes yanked (and, for semver/node-semver `*`, prerelease) versions —
55/// the same value `get_latest_matching` returned before this type existed.
56/// - `available` is the **unfiltered** `get_versions` output: every published version,
57/// newest-first, yanked and prerelease entries included.
58///
59/// The unsatisfiable-requirement check (see `crate::lsp_helpers::requirement_is_unsatisfiable`)
60/// scans `available` and deliberately does not filter it: a requirement that only matches a
61/// yanked or prerelease version is still satisfied, so filtering `available` the same way
62/// `latest` is filtered would produce false "no published version satisfies" warnings.
63///
64/// `yanked` is the subset of `available` (same version-string encoding) that the registry
65/// reported as yanked/deprecated, paired with each entry's [`RemovalStatus`]. It exists
66/// because `Registry::get_latest_matching` — the call that used to populate this cache —
67/// filters yanked entries out by contract on every current registry implementation, so a
68/// per-version yanked flag threaded through *that* call would always read `false` (see
69/// #233). `available` now comes from the unfiltered `get_versions` instead, which does
70/// observe yanked entries, so `yanked` is derived from that same fetch rather than
71/// discarded.
72///
73/// The status rides alongside each version (rather than a bare membership list) so
74/// [`crate::lsp_helpers::generate_diagnostics_from_cache`]'s #247 "requirement satisfiable
75/// only by a yanked version" check can gate its own package-level-deprecation suppression
76/// on `AdvisoryDeprecated` specifically, never on a genuine `Yanked` finding — mirroring
77/// [`VersionData::outcomes`]'s D5 gate for the #263 in-use-version check (see #437).
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct PackageVersions {
80 /// Latest usable version for this package.
81 pub latest: ConcreteVersion,
82 /// Every published version, newest-first, unfiltered.
83 pub available: Arc<[ConcreteVersion]>,
84 /// Subset of `available` reported as yanked/deprecated by the registry, each paired
85 /// with its [`RemovalStatus`].
86 pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
87 /// When `latest` was published, if the registry exposes it. `None` when
88 /// the ecosystem doesn't wire [`crate::Version::published_at`] or the fetch
89 /// never ran.
90 ///
91 /// Deliberately a field on this single per-package struct rather than a
92 /// second parallel map keyed alongside `latest` — the earlier two-map
93 /// design (issue #227 critique C3) let `latest` and its age drift apart
94 /// silently whenever one map was updated (e.g. lockfile-resolved
95 /// overwrite) without the other. Bundling them here makes that
96 /// desync impossible: whoever sets `latest` sets `published_at` too.
97 pub published_at: Option<crate::freshness::PublishTime>,
98}
99
100impl PackageVersions {
101 /// Builds a `PackageVersions` from only the "latest" version string, with `available`
102 /// populated as the single-element list `[latest]`.
103 ///
104 /// **Test-only in intent.** The one-element `available` this produces is a real, if
105 /// small, version list — it is not "empty/unknown", so `requirement_is_unsatisfiable`
106 /// will evaluate a requirement against it. Do **not** use this for a lock-file-only
107 /// population path that has no real version list to offer (use
108 /// [`latest_without_list`](Self::latest_without_list) there instead, which leaves
109 /// `available` genuinely empty) — that exact substitution is the false-positive N5 was
110 /// written to prevent: it would let the unsatisfiable-requirement check produce a
111 /// verdict against a fabricated one-entry list before any registry fetch has run. Real
112 /// registry fetches always populate `available` from the full `get_versions` result
113 /// instead of using either constructor.
114 ///
115 /// # Examples
116 ///
117 /// ```
118 /// use deps_core::{ConcreteVersion, PackageVersions};
119 ///
120 /// let versions = PackageVersions::latest_only("1.0.214");
121 /// assert_eq!(versions.latest, "1.0.214");
122 /// assert_eq!(&*versions.available, &[ConcreteVersion::new("1.0.214")]);
123 /// ```
124 pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
125 let latest = latest.into();
126 let available = Arc::from(vec![latest.clone()]);
127 Self {
128 latest,
129 available,
130 yanked: Arc::from(Vec::new()),
131 published_at: None,
132 }
133 }
134
135 /// Builds a `PackageVersions` with no version list — used where only the "latest" value
136 /// is known and probing further would be misleading, notably the lock-file population
137 /// path (`crates/deps-lsp/src/document/lifecycle.rs`), which must not populate a
138 /// plausible-looking one-element `available` list before any registry fetch has run: the
139 /// unsatisfiable-requirement check treats an empty `available` as "still loading, skip".
140 ///
141 /// # Examples
142 ///
143 /// ```
144 /// use deps_core::PackageVersions;
145 ///
146 /// let versions = PackageVersions::latest_without_list("1.0.195");
147 /// assert_eq!(versions.latest, "1.0.195");
148 /// assert!(versions.available.is_empty());
149 /// ```
150 pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
151 Self {
152 latest: latest.into(),
153 available: Arc::from(Vec::new()),
154 yanked: Arc::from(Vec::new()),
155 published_at: None,
156 }
157 }
158}
159
160/// Everything the lifecycle fetch learned about one package, keyed by its normalized name.
161///
162/// All three channels can hold simultaneously for the same package — this is load-bearing,
163/// not an incidental shape. The D5 status gate in
164/// [`generate_diagnostics_from_cache`] reads the [`Self::deprecation`] and [`Self::yanked`]
165/// entries for the same normalized name together and compares their [`RemovalStatus`], and on
166/// the didChange path a surviving deprecation can coexist with a later fetch failure. A single
167/// enum variant per package could not express that overlap.
168#[derive(Debug, Clone, Default, PartialEq)]
169pub struct DependencyOutcome {
170 /// The version found yanked (the in-use version, or `latest` when only `latest` itself is
171 /// yanked), paired with its [`RemovalStatus`]. See [`VersionData::outcomes`]'s prior
172 /// semantics (#233/#263).
173 pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
174 /// Package-level deprecation finding (#205). See [`Deprecation`].
175 pub deprecation: Option<Deprecation>,
176 /// Registry fetch errored, timed out, or was never attempted (#267). See [`FetchFailure`].
177 pub fetch_failure: Option<FetchFailure>,
178 /// The registry fetch succeeded (no [`Self::fetch_failure`]) but produced zero
179 /// comparable versions — e.g. a real GitHub repository whose only tags don't parse
180 /// as full `major.minor.patch` semver (`dtolnay/rust-toolchain`'s sole tag `v1`,
181 /// issue #550). Distinct from both an absent [`DependencyOutcome`] entry ("never
182 /// fetched") and [`Self::fetch_failure`] ("couldn't be asked"): this package
183 /// demonstrably exists, so [R5](crate::lsp_helpers::generate_diagnostics_from_cache)
184 /// must not claim it is unknown.
185 pub no_comparable_versions: bool,
186}
187
188impl DependencyOutcome {
189 /// True when none of the four channels are set.
190 #[must_use]
191 pub const fn is_empty(&self) -> bool {
192 self.yanked.is_none()
193 && self.deprecation.is_none()
194 && self.fetch_failure.is_none()
195 && !self.no_comparable_versions
196 }
197}
198
199/// Normalized-package-name -> [`DependencyOutcome`] map.
200///
201/// A newtype rather than a bare `HashMap` so the empty-entry pruning invariant (an entry is
202/// removed once all three of its channels are cleared) lives in one place, and so test
203/// fixtures get chainable `with_*` constructors instead of building three ad-hoc `HashMap`s.
204/// Mirrors the existing [`crate::osv::VulnerabilityMap`] convention of a `String`-keyed map by
205/// normalized name.
206#[derive(Debug, Clone, Default, PartialEq)]
207pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
208
209impl DependencyOutcomes {
210 /// Creates an empty map.
211 #[must_use]
212 pub fn new() -> Self {
213 Self::default()
214 }
215
216 /// Looks up the full outcome recorded for `name`.
217 #[must_use]
218 pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
219 self.0.get(name)
220 }
221
222 /// Looks up the yanked-version finding for `name`.
223 #[must_use]
224 pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
225 self.0.get(name)?.yanked.as_ref()
226 }
227
228 /// Looks up the package-level deprecation finding for `name`.
229 #[must_use]
230 pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
231 self.0.get(name)?.deprecation.as_ref()
232 }
233
234 /// Looks up the fetch-failure finding for `name`.
235 #[must_use]
236 pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
237 self.0.get(name)?.fetch_failure.as_ref()
238 }
239
240 /// True when the registry fetch for `name` succeeded but produced zero comparable
241 /// versions (#550). See [`DependencyOutcome::no_comparable_versions`].
242 #[must_use]
243 pub fn no_comparable_versions(&self, name: &str) -> bool {
244 self.0.get(name).is_some_and(|o| o.no_comparable_versions)
245 }
246
247 /// Records a yanked-version finding for `name`, creating the entry if absent.
248 pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
249 self.0.entry(name).or_default().yanked = Some(yanked);
250 }
251
252 /// Records a package-level deprecation finding for `name`, creating the entry if absent.
253 pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
254 self.0.entry(name).or_default().deprecation = Some(deprecation);
255 }
256
257 /// Records a fetch-failure finding for `name`, creating the entry if absent.
258 pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
259 self.0.entry(name).or_default().fetch_failure = Some(failure);
260 }
261
262 /// Records a fetch-failure finding for `name` only if one is not already recorded,
263 /// creating the entry if absent.
264 pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
265 self.0
266 .entry(name)
267 .or_default()
268 .fetch_failure
269 .get_or_insert(failure);
270 }
271
272 /// Records that `name`'s registry fetch succeeded but produced zero comparable
273 /// versions (#550), creating the entry if absent.
274 pub fn set_no_comparable_versions(&mut self, name: String) {
275 self.0.entry(name).or_default().no_comparable_versions = true;
276 }
277
278 /// Clears the no-comparable-versions channel for `name`, pruning the entry if it
279 /// becomes empty.
280 pub fn clear_no_comparable_versions(&mut self, name: &str) {
281 if let Some(entry) = self.0.get_mut(name) {
282 entry.no_comparable_versions = false;
283 self.prune(name);
284 }
285 }
286
287 /// Clears the yanked-version channel for `name`, pruning the entry if it becomes empty.
288 pub fn clear_yanked(&mut self, name: &str) {
289 if let Some(entry) = self.0.get_mut(name) {
290 entry.yanked = None;
291 self.prune(name);
292 }
293 }
294
295 /// Clears the deprecation channel for `name`, pruning the entry if it becomes empty.
296 pub fn clear_deprecation(&mut self, name: &str) {
297 if let Some(entry) = self.0.get_mut(name) {
298 entry.deprecation = None;
299 self.prune(name);
300 }
301 }
302
303 /// Clears the fetch-failure channel for `name`, pruning the entry if it becomes empty.
304 pub fn clear_fetch_failure(&mut self, name: &str) {
305 if let Some(entry) = self.0.get_mut(name) {
306 entry.fetch_failure = None;
307 self.prune(name);
308 }
309 }
310
311 /// Removes the whole entry for `name`, regardless of which channels are set.
312 pub fn remove(&mut self, name: &str) {
313 self.0.remove(name);
314 }
315
316 /// Clears the fetch-failure channel for every entry, pruning any that become empty.
317 ///
318 /// Used when a forced re-fetch (e.g. a live-reloaded registry-routing setting, deps-lsp
319 /// issue #592) makes every previously recorded fetch-failure finding untrustworthy: the
320 /// routing itself changed, so a failure recorded under the old routing must not survive
321 /// to be merged with results fetched under the new one.
322 pub fn clear_all_fetch_failures(&mut self) {
323 let names: Vec<String> = self.0.keys().cloned().collect();
324 for name in names {
325 self.clear_fetch_failure(&name);
326 }
327 }
328
329 fn prune(&mut self, name: &str) {
330 if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
331 self.0.remove(name);
332 }
333 }
334
335 /// Number of entries currently stored.
336 #[must_use]
337 pub fn len(&self) -> usize {
338 self.0.len()
339 }
340
341 /// True when no entries are stored.
342 #[must_use]
343 pub fn is_empty(&self) -> bool {
344 self.0.is_empty()
345 }
346
347 /// Number of entries with a yanked-version finding, for logging/`Debug`.
348 #[must_use]
349 pub fn yanked_count(&self) -> usize {
350 self.0.values().filter(|o| o.yanked.is_some()).count()
351 }
352
353 /// Number of entries with a deprecation finding, for logging/`Debug`.
354 #[must_use]
355 pub fn deprecation_count(&self) -> usize {
356 self.0.values().filter(|o| o.deprecation.is_some()).count()
357 }
358
359 /// Number of entries with a fetch-failure finding, for logging/`Debug`.
360 #[must_use]
361 pub fn fetch_failure_count(&self) -> usize {
362 self.0
363 .values()
364 .filter(|o| o.fetch_failure.is_some())
365 .count()
366 }
367
368 /// Chainable builder recording a yanked-version finding. Test/fixture ergonomics.
369 #[must_use]
370 pub fn with_yanked(
371 mut self,
372 name: impl Into<String>,
373 yanked: (ConcreteVersion, RemovalStatus),
374 ) -> Self {
375 self.set_yanked(name.into(), yanked);
376 self
377 }
378
379 /// Chainable builder recording a package-level deprecation finding. Test/fixture
380 /// ergonomics.
381 #[must_use]
382 pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
383 self.set_deprecation(name.into(), deprecation);
384 self
385 }
386
387 /// Chainable builder recording a fetch-failure finding. Test/fixture ergonomics.
388 #[must_use]
389 pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
390 self.set_fetch_failure(name.into(), failure);
391 self
392 }
393
394 /// Chainable builder recording a no-comparable-versions finding (#550). Test/fixture
395 /// ergonomics.
396 #[must_use]
397 pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
398 self.set_no_comparable_versions(name.into());
399 self
400 }
401}
402
403/// Bundles the two per-package version maps (`cached`, `resolved`) that LSP handlers pass
404/// together everywhere.
405///
406/// Grouping them prevents accidentally swapping the two map arguments at a call site, since
407/// the compiler can no longer typecheck them positionally.
408///
409/// # Examples
410///
411/// ```
412/// use deps_core::{ConcreteVersion, PackageName, PackageVersions, VersionData};
413/// use std::collections::HashMap;
414///
415/// let mut cached = HashMap::new();
416/// cached.insert(PackageName::new("serde"), PackageVersions::latest_only("1.0.214"));
417///
418/// let mut resolved = HashMap::new();
419/// resolved.insert(PackageName::new("serde"), ConcreteVersion::new("1.0.200"));
420///
421/// let versions = VersionData::new(&cached, &resolved);
422///
423/// assert_eq!(versions.cached.get("serde").map(|v| v.latest.as_str()), Some("1.0.214"));
424/// assert_eq!(versions.resolved.get("serde").map(ConcreteVersion::as_str), Some("1.0.200"));
425/// ```
426#[derive(Debug, Clone, Copy)]
427pub struct VersionData<'a> {
428 /// Latest known versions and full version lists from the registry, keyed by package name.
429 pub cached: &'a HashMap<PackageName, PackageVersions>,
430 /// Versions actually resolved in the lock file, keyed by package name.
431 pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
432 /// OSV scan results, keyed by normalized package name. `None` when no
433 /// scan has run yet (e.g. the feature is disabled) — distinct from an
434 /// empty map, which would mean "scanned, nothing found".
435 pub vulnerabilities: Option<&'a VulnerabilityMap>,
436 /// Yanked, deprecation, and fetch-failure findings from the most recent lifecycle fetch,
437 /// keyed by normalized package name — see [`DependencyOutcome`] for what each channel
438 /// means and why they must stay readable together off one lookup (D5 in
439 /// [`generate_diagnostics_from_cache`], #233/#263/#205/#267). `None` when no fetch has run
440 /// yet — distinct from an empty map, which would mean "checked, nothing found".
441 pub outcomes: Option<&'a DependencyOutcomes>,
442 /// This document's ecosystem, when the caller has one to give. `None` in
443 /// most test fixtures and a handful of ecosystem-crate self-tests that
444 /// predate this field.
445 ///
446 /// Enables two occurrence-aware refinements added for #394 (duplicate
447 /// dependency names no longer collapsing into one shared finding):
448 /// [`generate_diagnostics_from_cache`] only emits a yanked-version
449 /// diagnostic on the occurrence whose own in-use version actually
450 /// matches the recorded finding (S1), and the vulnerability lookups in
451 /// `generate_diagnostics_from_cache`, [`generate_hover`], and
452 /// `generate_code_actions` prefer a version-qualified
453 /// [`crate::osv::VulnerabilityMap`] key over the plain name when more
454 /// than one occurrence of a name has a distinct in-use version (S2).
455 /// When `None`, both fall back to their pre-#394 name-only behavior.
456 pub ecosystem: Option<EcosystemId>,
457 /// Whether `network.offline` is set (issue #483). When `true`, [`generate_hover`]
458 /// appends a footer stating that version *and vulnerability* data were not checked —
459 /// deliberately more specific than a bare "showing cached data" notice, since
460 /// `hover.rs`'s `Some(ScanOutcome::Skipped(_)) | None` arm renders nothing for an
461 /// offline OSV skip, which would otherwise look identical to a scanned-and-clean
462 /// dependency.
463 pub offline: bool,
464 /// The deps.dev client to fetch a supply-chain trust signal through
465 /// (spec 037), when the caller wants hover to attempt one. `None` by
466 /// default and left `None` by every surface but `handlers/hover.rs`
467 /// (deps-lsp) — diagnostics, code actions, inlay hints, and code lenses
468 /// never set this, which is what makes FR-010's hover-only scope
469 /// structural rather than convention: those surfaces cannot reach
470 /// deps.dev because they are never handed a client. `&'a Arc<..>`, not
471 /// `&'a DepsDevClient`, so [`generate_hover`] can clone the `Arc` into a
472 /// detached background task.
473 pub trust: Option<&'a Arc<DepsDevClient>>,
474}
475
476impl<'a> VersionData<'a> {
477 /// Creates a new `VersionData` from the cached and resolved version maps.
478 ///
479 /// `vulnerabilities` starts `None`; chain [`Self::with_vulnerabilities`]
480 /// to attach a scan result.
481 ///
482 /// # Examples
483 ///
484 /// ```
485 /// use deps_core::VersionData;
486 /// use std::collections::HashMap;
487 ///
488 /// let cached = HashMap::new();
489 /// let resolved = HashMap::new();
490 /// let versions = VersionData::new(&cached, &resolved);
491 /// assert!(versions.cached.is_empty());
492 /// assert!(versions.vulnerabilities.is_none());
493 /// ```
494 pub fn new(
495 cached: &'a HashMap<PackageName, PackageVersions>,
496 resolved: &'a HashMap<PackageName, ConcreteVersion>,
497 ) -> Self {
498 Self {
499 cached,
500 resolved,
501 vulnerabilities: None,
502 outcomes: None,
503 ecosystem: None,
504 offline: false,
505 trust: None,
506 }
507 }
508
509 /// Attaches an OSV scan result to this `VersionData`.
510 ///
511 /// # Examples
512 ///
513 /// ```
514 /// use deps_core::VersionData;
515 /// use deps_core::osv::VulnerabilityMap;
516 /// use std::collections::HashMap;
517 ///
518 /// let cached = HashMap::new();
519 /// let resolved = HashMap::new();
520 /// let vulns = VulnerabilityMap::new();
521 /// let versions = VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns);
522 /// assert!(versions.vulnerabilities.is_some());
523 /// ```
524 #[must_use]
525 pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
526 self.vulnerabilities = Some(vulnerabilities);
527 self
528 }
529
530 /// Attaches yanked, deprecation, and fetch-failure findings to this `VersionData`. See
531 /// [`Self::outcomes`].
532 ///
533 /// # Examples
534 ///
535 /// ```
536 /// use deps_core::VersionData;
537 /// use deps_core::lsp_helpers::DependencyOutcomes;
538 /// use std::collections::HashMap;
539 ///
540 /// let cached = HashMap::new();
541 /// let resolved = HashMap::new();
542 /// let outcomes = DependencyOutcomes::new();
543 /// let versions = VersionData::new(&cached, &resolved).with_outcomes(&outcomes);
544 /// assert!(versions.outcomes.is_some());
545 /// ```
546 #[must_use]
547 pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
548 self.outcomes = Some(outcomes);
549 self
550 }
551
552 /// Attaches this document's ecosystem, enabling the occurrence-aware
553 /// refinements described on [`Self::ecosystem`].
554 ///
555 /// # Examples
556 ///
557 /// ```
558 /// use deps_core::{EcosystemId, VersionData};
559 /// use std::collections::HashMap;
560 ///
561 /// let cached = HashMap::new();
562 /// let resolved = HashMap::new();
563 /// let versions = VersionData::new(&cached, &resolved).with_ecosystem(EcosystemId::Cargo);
564 /// assert_eq!(versions.ecosystem, Some(EcosystemId::Cargo));
565 /// ```
566 #[must_use]
567 pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
568 self.ecosystem = Some(ecosystem);
569 self
570 }
571
572 /// Marks this `VersionData` as built while `network.offline` was set, so
573 /// [`generate_hover`] appends its offline footer.
574 ///
575 /// # Examples
576 ///
577 /// ```
578 /// use deps_core::VersionData;
579 /// use std::collections::HashMap;
580 ///
581 /// let cached = HashMap::new();
582 /// let resolved = HashMap::new();
583 /// let versions = VersionData::new(&cached, &resolved).with_offline(true);
584 /// assert!(versions.offline);
585 /// ```
586 #[must_use]
587 pub const fn with_offline(mut self, offline: bool) -> Self {
588 self.offline = offline;
589 self
590 }
591
592 /// Attaches a deps.dev client, enabling [`generate_hover`] to attempt a
593 /// supply-chain trust signal for the hovered dependency. See
594 /// [`Self::trust`].
595 ///
596 /// # Examples
597 ///
598 /// ```
599 /// use deps_core::{DepsDevClient, HttpCache, VersionData};
600 /// use std::collections::HashMap;
601 /// use std::sync::Arc;
602 ///
603 /// let cached = HashMap::new();
604 /// let resolved = HashMap::new();
605 /// let client = Arc::new(DepsDevClient::new(Arc::new(HttpCache::new())));
606 /// let versions = VersionData::new(&cached, &resolved).with_trust(&client);
607 /// assert!(versions.trust.is_some());
608 /// ```
609 #[must_use]
610 pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
611 self.trust = Some(client);
612 self
613 }
614}
615
616/// Checks whether a cursor position falls within an LSP range (inclusive on both ends).
617pub fn position_in_range(pos: Position, range: Range) -> bool {
618 if pos.line < range.start.line || pos.line > range.end.line {
619 return false;
620 }
621 if pos.line == range.start.line && pos.character < range.start.character {
622 return false;
623 }
624 if pos.line == range.end.line && pos.character > range.end.character {
625 return false;
626 }
627 true
628}
629
630/// Converts byte offsets in source text to LSP `Position` values.
631///
632/// Precomputes line-start byte offsets once, then maps any byte offset to a
633/// `(line, character)` position. Characters are counted as UTF-16 code units
634/// as required by the LSP specification.
635pub struct LineOffsetTable {
636 line_starts: Vec<usize>,
637}
638
639impl LineOffsetTable {
640 /// Builds the table for `content`.
641 pub fn new(content: &str) -> Self {
642 let mut line_starts = vec![0];
643 for (i, c) in content.char_indices() {
644 if c == '\n' {
645 line_starts.push(i + 1);
646 }
647 }
648 Self { line_starts }
649 }
650
651 /// Absolute byte offset where `line` (0-indexed) starts, or `None` if
652 /// `line` is out of range.
653 ///
654 /// Prefer this over re-deriving a line's start via cursor arithmetic
655 /// (`cursor += line.len() + 1`): `str::lines()` strips a trailing `\r`,
656 /// so that approach under-counts by one byte per CRLF line and corrupts
657 /// every subsequent offset in the file. This table is built by scanning
658 /// `char_indices()` for `\n` (see [`new`](Self::new)), which counts the
659 /// `\r`, so it stays correct for LF, CRLF and mixed line endings alike.
660 ///
661 /// # Examples
662 ///
663 /// ```
664 /// use deps_core::lsp_helpers::LineOffsetTable;
665 ///
666 /// let table = LineOffsetTable::new("a\r\nb\r\nc");
667 /// assert_eq!(table.line_start(0), Some(0));
668 /// assert_eq!(table.line_start(1), Some(3));
669 /// assert_eq!(table.line_start(2), Some(6));
670 /// assert_eq!(table.line_start(3), None);
671 /// ```
672 pub fn line_start(&self, line: usize) -> Option<usize> {
673 self.line_starts.get(line).copied()
674 }
675
676 /// Converts a byte offset into an LSP `Position`.
677 pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
678 let offset = offset.min(content.len());
679 // `offset` is not always a toml-span offset (boundary-safe by
680 // construction) — the requirements.txt line parser derives offsets
681 // via hand-rolled byte arithmetic, which can land inside a
682 // multi-byte character (e.g. a non-ASCII comment or marker string
683 // combined with an off-by-a-byte cut). Clamp down to the nearest
684 // char boundary rather than panicking on the slice below.
685 let offset = content.floor_char_boundary(offset);
686 let line = self
687 .line_starts
688 .partition_point(|&start| start <= offset)
689 .saturating_sub(1);
690 let line_start = self.line_starts[line];
691 let character = content[line_start..offset]
692 .chars()
693 .map(|c| c.len_utf16() as u32)
694 .sum();
695 Position::new(line as u32, character)
696 }
697
698 /// Converts an LSP `Position` back into a byte offset — the inverse of
699 /// [`byte_offset_to_position`](Self::byte_offset_to_position). Out-of-range lines or
700 /// UTF-16 characters clamp to `content.len()` rather than panicking, matching the
701 /// forward conversion's `.min(content.len())` guard.
702 pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
703 let Some(&line_start) = self.line_starts.get(position.line as usize) else {
704 return content.len();
705 };
706 let line_end = self
707 .line_starts
708 .get(position.line as usize + 1)
709 .copied()
710 .unwrap_or(content.len());
711 let line = &content[line_start..line_end];
712 crate::completion::utf16_to_byte_offset(line, position.character)
713 .map_or(line_end, |offset| line_start + offset)
714 .min(content.len())
715 }
716}
717
718/// Escapes Markdown syntax characters so untrusted text cannot break out of the
719/// Markdown structure it is embedded in.
720///
721/// Applied to manifest-controlled text (dependency names) before it is written into
722/// hover markdown link labels, and to registry-controlled completion metadata
723/// (package name/version, description, repository/documentation URLs) before it is
724/// written into completion-item link labels and link destinations. Every ASCII
725/// punctuation character is backslash-escaped (CommonMark's full escapable set — not
726/// just brackets/parens, which would still leave e.g. `<https://evil.example>`
727/// autolinks live), and control characters (including newlines) are replaced with a
728/// space so the text cannot terminate the single-line block it is embedded in and
729/// splice in new content.
730///
731/// Backslash-escaping is valid in link destinations as well as regular text, so this
732/// also neutralizes `)`/`]` breakout attempts in a `[label](destination)` URL. It does
733/// *not* block dangerous URI schemes (e.g. `javascript:`) in a destination — that is a
734/// separate concern from breaking out of the surrounding Markdown structure.
735///
736/// Backslash-escaping does *not* work inside inline code spans (CommonMark §6.1) —
737/// use [`markdown_code_span`] for text embedded in `` `...` `` instead.
738///
739/// # Examples
740///
741/// ```
742/// use deps_core::lsp_helpers::escape_markdown;
743///
744/// assert_eq!(escape_markdown("pkg](evil)[pkg"), r"pkg\]\(evil\)\[pkg");
745/// assert_eq!(escape_markdown("a\nb"), "a b");
746/// ```
747pub fn escape_markdown(s: &str) -> String {
748 let mut escaped = String::with_capacity(s.len());
749 for c in s.chars() {
750 if c.is_control() {
751 escaped.push(' ');
752 continue;
753 }
754 if c.is_ascii_punctuation() {
755 escaped.push('\\');
756 }
757 escaped.push(c);
758 }
759 escaped
760}
761
762/// Wraps `content` in a Markdown inline code span (backticks included) that safely
763/// contains arbitrary untrusted text, regardless of embedded backticks.
764///
765/// Backslash-escaping does not work inside code spans (CommonMark §6.1), so instead
766/// this fences with one more backtick than the longest run found in `content`, and
767/// pads with a single space on each side when `content` starts or ends with a
768/// backtick or space (required by CommonMark to keep the fence unambiguous). Control
769/// characters (including newlines) are replaced with a space first, since the raw
770/// hover string is otherwise free to merge into an adjacent Markdown block.
771///
772/// # Examples
773///
774/// ```
775/// use deps_core::lsp_helpers::markdown_code_span;
776///
777/// assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
778/// assert_eq!(markdown_code_span("a`b"), "``a`b``");
779/// ```
780pub fn markdown_code_span(content: &str) -> String {
781 let sanitized: String = content
782 .chars()
783 .map(|c| if c.is_control() { ' ' } else { c })
784 .collect();
785
786 let max_backtick_run = sanitized
787 .split(|c| c != '`')
788 .map(str::len)
789 .max()
790 .unwrap_or(0);
791 let fence = "`".repeat(max_backtick_run + 1);
792
793 if sanitized.is_empty() {
794 format!("{fence} {fence}")
795 } else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
796 format!("{fence} {sanitized} {fence}")
797 } else {
798 format!("{fence}{sanitized}{fence}")
799 }
800}
801
802/// Checks if two version strings have the same major and minor version.
803pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
804 if v1.is_empty() || v2.is_empty() {
805 return false;
806 }
807
808 let mut parts1 = v1.split('.');
809 let mut parts2 = v2.split('.');
810
811 if parts1.next() != parts2.next() {
812 return false;
813 }
814
815 match (parts1.next(), parts2.next()) {
816 (Some(m1), Some(m2)) => m1 == m2,
817 _ => true,
818 }
819}
820
821/// Result of checking whether a dependency's declared requirement is already satisfied by
822/// the latest known version.
823///
824/// Diagnostics and inlay hints read this result differently: diagnostics only need to know
825/// whether it is safe to skip the "Newer version available" warning, so both `UpToDate` and
826/// `Unresolved` suppress it. Inlay hints additionally need to distinguish `Unresolved` from
827/// `UpToDate`, since an unresolved requirement (e.g. a dangling Gradle version-catalog
828/// `version.ref` alias, or an unexpanded Maven `${property}`) must not render an "up to
829/// date" badge that was never actually verified.
830#[derive(Debug, Clone, Copy, PartialEq, Eq)]
831pub enum RequirementStatus {
832 /// The latest version satisfies the declared requirement.
833 UpToDate,
834 /// The latest version does not satisfy the declared requirement — a newer version is
835 /// available.
836 Outdated,
837 /// The requirement could not be resolved to a concrete constraint, so no comparison
838 /// could be made.
839 Unresolved,
840}
841
842/// A `requirement` compiled by one ecosystem, ready to test candidate versions against.
843///
844/// Produced by [`formatter::RequirementResolution::compile_requirement`]. Kept as a separate object
845/// (rather than a single "does any version match" function) so the requirement is parsed
846/// once per dependency, and so the scanning loop — including the empty-list guard, the
847/// early-exit on first match, and the "skip an unparseable candidate" rule — lives once in
848/// [`requirement_is_unsatisfiable`] instead of being reimplemented by all eleven ecosystems.
849///
850/// # Examples
851///
852/// ```
853/// use deps_core::lsp_helpers::RequirementMatcher;
854/// use deps_core::ConcreteVersion;
855///
856/// struct ExactMatch(String);
857///
858/// impl RequirementMatcher for ExactMatch {
859/// fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
860/// Some(version.as_str() == self.0)
861/// }
862/// }
863///
864/// let matcher = ExactMatch("1.0.0".to_string());
865/// assert_eq!(matcher.matches(&ConcreteVersion::new("1.0.0")), Some(true));
866/// assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
867/// ```
868pub trait RequirementMatcher: Send + Sync {
869 /// Tests one candidate version string against the compiled requirement.
870 ///
871 /// `Some(true)` / `Some(false)`: this candidate provably does / does not satisfy the
872 /// requirement. `None`: this candidate *string* could not be parsed by this ecosystem's
873 /// version format (e.g. a PyPI legacy release identifier, a Maven timestamped snapshot
874 /// qualifier) — the caller skips it and keeps scanning the rest of the list. Never
875 /// return `None` to mean "the requirement itself is unusable"; that is
876 /// [`formatter::RequirementResolution::compile_requirement`]'s job, via returning `None` from that
877 /// method instead of constructing a matcher at all.
878 fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
879}
880
881/// Whether `segment` is exactly `.` or `..`.
882///
883/// Unlike ordinary path characters, a literal `.`/`..` segment is not neutralized by
884/// percent-encoding: `.` is an unreserved character (RFC 3986), so `urlencoding::encode`
885/// leaves it untouched, and the URL parser's dot-segment removal (RFC 3986 §5.2.4) still
886/// collapses it after encoding — `%2E` decodes back to `.` before that normalization runs.
887/// A registry-fetch URL built as `{base}/{prefix}/{name}` (no fixed suffix after `name`)
888/// must reject a `name`/path segment satisfying this predicate rather than encode it,
889/// since encoding alone does not stop the collapse (#341, #349).
890///
891/// Shared by `deps-npm`'s scope/package segment guard and `deps-dart`'s package-name
892/// guard — both ecosystems' registry APIs key a fetch on a bare, suffix-less path segment.
893///
894/// **Scope**: this predicate (and the `#365` regression sweep built around it) guards
895/// registry-*fetch* URL builders — the sink is a request this process actually
896/// dereferences, so a retargeted URL can make it fetch attacker-chosen data. It
897/// deliberately does *not* extend to a "docs link"/`package_url`-style builder (the
898/// per-ecosystem hover/display link, e.g. `deps_cargo::crate_url`, `deps_go::package_url`):
899/// those interpolate the name into a link rendered in hover text and never fetched by
900/// this process, so an unrejected `.`/`..` name there produces at worst a misleading
901/// same-host link (the registry's package-listing root), not a traversal off-host (#379).
902///
903/// # Examples
904///
905/// ```
906/// use deps_core::lsp_helpers::is_dot_segment;
907///
908/// assert!(is_dot_segment(".."));
909/// assert!(is_dot_segment("."));
910/// assert!(!is_dot_segment("left-pad"));
911/// ```
912pub fn is_dot_segment(segment: &str) -> bool {
913 segment == "." || segment == ".."
914}
915
916/// Whether `version` is safe to embed in a manifest [`TextEdit`] or completion item.
917///
918/// Guards every call into
919/// [`formatter::PackageRendering::format_version_replacing`]/[`formatter::PackageRendering::format_version_for_text_edit`]
920/// and every completion item's `insert_text`/`text_edit`.
921///
922/// Must be applied to the raw version string *before* formatting, never to a
923/// formatter's output: some formatters legitimately produce structural
924/// characters in their output from an already-validated version plus fixed,
925/// trusted operators (e.g. PyPI's `>=1.2.3,<2`), so validating the output
926/// would wrongly reject those.
927///
928/// An allowlist, not a denylist: `version` must be non-empty, at most 64
929/// bytes, and contain only `[A-Za-z0-9.+_~:*^!-]` — the character set real
930/// version strings use across every ecosystem this workspace supports
931/// (SemVer, PEP 440 including epochs like `1!2.0`, Maven qualifiers, npm's
932/// `^`/`~`/`*` range tokens, Go's `+incompatible` suffix). A denylist here
933/// would need to anticipate every dangerous token a target manifest format
934/// (or a build tool evaluating it, e.g. Gradle's Kotlin/Groovy DSL
935/// interpolating `${...}` inside a version literal) could ever act on;
936/// failing closed on an unrecognized character is cheaper and safer.
937///
938/// This is the single validation chokepoint shared by every producer of a
939/// version-derived `TextEdit`/completion item in this workspace, including
940/// OSV advisory data (an `Advisory.fixed_versions` entry is exactly as
941/// untrusted as a registry-reported version).
942///
943/// # Examples
944///
945/// ```
946/// use deps_core::is_safe_version_string;
947///
948/// assert!(is_safe_version_string("1.2.3-alpha.1+build"));
949/// assert!(!is_safe_version_string("1.2.3\", git = \"https://evil"));
950/// ```
951pub fn is_safe_version_string(version: &str) -> bool {
952 !version.is_empty()
953 && version.len() <= 64
954 && version.chars().all(|c| {
955 c.is_ascii_alphanumeric()
956 || matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
957 })
958}
959
960/// Whether `segment` is safe to embed as a Maven `groupId`/`artifactId` value in a
961/// pom.xml [`TextEdit`] or completion item.
962///
963/// Guards Maven's group/artifact completion producer, which builds a completion item's
964/// `insert_text`/`text_edit` from one field of a Maven Central search result — a value
965/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
966/// why version-derived and non-version-derived sinks each get their own allowlist).
967///
968/// An allowlist, not a denylist: `segment` must be non-empty, at most 128 bytes, not
969/// exactly `.`/`..` (see [`is_dot_segment`]), and contain only `[A-Za-z0-9._-]` — the
970/// character set real Maven Central group ids (reverse-DNS style, e.g.
971/// `org.apache.commons`) and artifact ids (hyphen/underscore separated, e.g.
972/// `commons-lang3`) use. Deliberately excludes `:` — the `groupId:artifactId` separator —
973/// because this validates one already-split coordinate field at a time, never the joined
974/// pair. Failing closed on an unrecognized character (e.g. `<`, `"`, a newline) keeps a
975/// malicious/compromised search result from restructuring the pom.xml it's inserted into;
976/// the dedicated `.`/`..` rejection closes the same dot-segment URL-normalization gap
977/// [`is_dot_segment`] guards elsewhere (`artifactId` reaches a registry-fetch URL as a bare
978/// path segment in `deps-maven::registry::metadata_urls`, unlike `groupId`, whose `.`→`/`
979/// expansion can never itself produce a literal `..` component).
980///
981/// # Examples
982///
983/// ```
984/// use deps_core::is_safe_maven_coordinate_segment;
985///
986/// assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
987/// assert!(is_safe_maven_coordinate_segment("commons-lang3"));
988/// assert!(!is_safe_maven_coordinate_segment("commons</artifactId><parent>"));
989/// assert!(!is_safe_maven_coordinate_segment(".."));
990/// ```
991pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
992 !segment.is_empty()
993 && segment.len() <= 128
994 && !is_dot_segment(segment)
995 && segment
996 .chars()
997 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
998}
999
1000/// Whether `url` is safe to embed as a Swift Package Manager repository URL in a
1001/// Package.swift [`TextEdit`] or completion item.
1002///
1003/// Guards Swift's URL-completion producer, which builds a `.package(url: "...")`
1004/// string-literal replacement from a package registry search result's URL — a value
1005/// type distinct from a version string (see [`is_safe_version_string`]'s doc comment for
1006/// why version-derived and non-version-derived sinks each get their own allowlist).
1007///
1008/// An allowlist, not a denylist: `url` must be non-empty, at most 2048 bytes, start with
1009/// `https://` — every real Swift package registry response is HTTPS (GitHub's `html_url`
1010/// never downgrades), so accepting plain `http://` would only hand a
1011/// compromised/malicious registry a transport-downgrade lever for zero legitimate
1012/// benefit — and otherwise contain only RFC 3986 URL characters (`A-Za-z0-9` plus
1013/// `` -._~:/?#[]@!$&'()*+,;=% ``). Deliberately excludes `"`, `\`, control characters,
1014/// and whitespace — none of those are valid unencoded URL characters, and any of them
1015/// could close the surrounding Swift string literal or otherwise corrupt the manifest.
1016/// Failing closed on an unrecognized character keeps a malicious/compromised search
1017/// result from breaking out of the string it's inserted into.
1018///
1019/// # Examples
1020///
1021/// ```
1022/// use deps_core::is_safe_registry_url;
1023///
1024/// assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
1025/// assert!(!is_safe_registry_url("https://evil.example\", .exact(\"1\")) // "));
1026/// ```
1027pub fn is_safe_registry_url(url: &str) -> bool {
1028 !url.is_empty()
1029 && url.len() <= 2048
1030 && url.starts_with("https://")
1031 && url.chars().all(|c| {
1032 c.is_ascii_alphanumeric()
1033 || matches!(
1034 c,
1035 '-' | '.'
1036 | '_'
1037 | '~'
1038 | ':'
1039 | '/'
1040 | '?'
1041 | '#'
1042 | '['
1043 | ']'
1044 | '@'
1045 | '!'
1046 | '$'
1047 | '&'
1048 | '\''
1049 | '('
1050 | ')'
1051 | '*'
1052 | '+'
1053 | ','
1054 | ';'
1055 | '='
1056 | '%'
1057 )
1058 })
1059}
1060
1061/// Whether `name` is safe to embed as a package name in a manifest [`TextEdit`] or
1062/// completion item.
1063///
1064/// Guards every arm of `create_package_completion_item`
1065/// (`crates/deps-lsp/src/handlers/completion.rs`) as a single upfront check, applied
1066/// before the raw `name` reaches any ecosystem-specific snippet — including Maven and
1067/// Swift, which additionally validate a *derived* value on top of this gate
1068/// ([`is_safe_maven_coordinate_segment`] on each split coordinate segment,
1069/// [`is_safe_registry_url`] on the constructed URL) because a value type distinct from
1070/// the raw name needs its own allowlist — see [`is_safe_version_string`]'s doc comment
1071/// for why version-derived and non-version-derived sinks each get their own allowlist.
1072/// [`PackageName::new`](crate::PackageName::new) is documented as never validating or
1073/// modifying its input, so this predicate is the first gate a registry-reported name
1074/// passes through before reaching a manifest. Two sinks that key a bare TOML/YAML
1075/// entry by `name` (Cargo/PyPI, Dart) additionally quote that key in the snippet, since
1076/// `.` and `@` are legal here but would otherwise be read as TOML's dotted-key
1077/// separator or break a YAML plain scalar.
1078///
1079/// An allowlist, not a denylist: `name` must be non-empty, at most 256 bytes, and
1080/// contain only `[A-Za-z0-9._@:/~-]` — the character set real package names use across
1081/// every ecosystem this predicate guards: Cargo/PyPI/Dart/NuGet/Bundler
1082/// (alphanumeric, `-`, `_`, `.`), npm/Deno scoped names (`@scope/name`, adding `@` and
1083/// `/`), Composer (`vendor/package`, `/`), Go module paths (domain-qualified paths like
1084/// `github.com/org/repo`, `/`, `.`, and `~` — legal in a Go path element and already
1085/// allowed by [`is_safe_version_string`]/[`is_safe_registry_url`]), and Gradle's
1086/// colon-delimited `group:artifact` short form (`:`). A denylist here would need to
1087/// anticipate every dangerous token a target manifest format (TOML/JSON/YAML/XML
1088/// string literals, a live Kotlin/Groovy build-script DSL) could ever act on; failing
1089/// closed on an unrecognized character — notably `"`, `'`, `<`, `>`, `` ` ``, and all
1090/// control characters/newlines — is cheaper and safer.
1091///
1092/// # Examples
1093///
1094/// ```
1095/// use deps_core::is_safe_package_name;
1096///
1097/// assert!(is_safe_package_name("serde"));
1098/// assert!(is_safe_package_name("@scope/name"));
1099/// assert!(is_safe_package_name("org.apache.commons:commons-lang3"));
1100/// assert!(!is_safe_package_name("evil\"\nbackdoor = \"9.9.9"));
1101/// ```
1102pub fn is_safe_package_name(name: &str) -> bool {
1103 !name.is_empty()
1104 && name.len() <= 256
1105 && name.chars().all(|c| {
1106 c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
1107 })
1108}
1109
1110/// Logs a `tracing::warn!` for a value rejected by an `is_safe_*` predicate (or an
1111/// equivalent value-rejecting gate) before it reaches a manifest edit or registry URL.
1112///
1113/// Deliberately logs only `value`'s byte length, never its content: `value` is
1114/// registry-controlled and, by construction, already failed an allowlist — logging it
1115/// verbatim at `warn` would let a malicious/compromised registry response inject arbitrary
1116/// content into this project's own log stream (a second-order log-injection concern),
1117/// mirroring why `deps-pypi`'s `truncate_for_log` bounds a logged excerpt instead of
1118/// logging a value verbatim. This is this helper's own contract, not a claim that every
1119/// `tracing` call site in the workspace avoids logging a raw value — e.g. `deps-lsp`'s
1120/// `deps-lsp.updateVersion` handler and an OSV malformed-`fixed`-version warning predate
1121/// this helper and log their rejected value directly; they are unrelated call sites, not a
1122/// place this helper is used.
1123///
1124/// `gate` names the predicate/guard that rejected `value` (e.g.
1125/// `"is_safe_maven_coordinate_segment"`); `context` is a short description of the call site
1126/// (e.g. `"maven groupId completion"`).
1127///
1128/// # Examples
1129///
1130/// ```
1131/// use deps_core::lsp_helpers::warn_rejected_value;
1132///
1133/// warn_rejected_value("is_safe_version_string", "code lens latest version", "1.0.0\"; evil");
1134/// ```
1135pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
1136 tracing::warn!(
1137 gate,
1138 context,
1139 len = value.len(),
1140 "rejected unsafe value before manifest/registry sink"
1141 );
1142}
1143
1144/// Builds a single-entry [`WorkspaceEdit::changes`] map replacing `range` in `uri`
1145/// with `new_text`.
1146///
1147/// Shared by every quickfix/refactor code action in `code_actions` that edits exactly
1148/// one span in the current document (`build_vulnerability_fix_action`,
1149/// `build_unsatisfiable_fix_action`, and the plain "update to `<version>`" loop in
1150/// [`generate_code_actions`]).
1151fn single_file_edit(uri: &Uri, range: Range, new_text: String) -> HashMap<Uri, Vec<TextEdit>> {
1152 let mut edits = HashMap::new();
1153 edits.insert(uri.clone(), vec![TextEdit { range, new_text }]);
1154 edits
1155}
1156
1157/// Strips every whitespace character from `s`, so two textually-equivalent strings that
1158/// differ only in spacing compare equal.
1159///
1160/// Shared by every no-op/literal-match guard across `code_actions` and `code_lenses`
1161/// (`build_vulnerability_fix_action`'s N1 guard, `generate_code_actions`'s REFACTOR-loop
1162/// guard, `literal_span_matches`, and `collect_update_all_edits`'s no-op guard), all of
1163/// which compare a declared requirement
1164/// string against a differently-normalized counterpart — e.g. pep508's `>=1.7, <2.0` vs. a
1165/// formatter's `>=1.7,<2.0`.
1166fn strip_whitespace(s: &str) -> String {
1167 s.chars().filter(|c| !c.is_whitespace()).collect()
1168}
1169
1170/// Slices `content` over an LSP `Range` using a pre-built `LineOffsetTable`, returning
1171/// `""` for an inverted or out-of-bounds range instead of panicking.
1172///
1173/// `table` is document-invariant — callers iterating over multiple dependencies in the
1174/// same document must build it once and reuse it, rather than rebuilding it (an O(n)
1175/// scan of `content`) per dependency.
1176fn slice_for_range<'a>(content: &'a str, table: &LineOffsetTable, range: Range) -> &'a str {
1177 let start = table.position_to_byte_offset(content, range.start);
1178 let end = table.position_to_byte_offset(content, range.end);
1179 if start > end {
1180 return "";
1181 }
1182 content.get(start..end).unwrap_or("")
1183}
1184
1185/// Checks whether `slice` — `content` sliced over a dependency's `version_range` — still
1186/// holds the literal version text declared by `requirement`.
1187///
1188/// Whitespace is stripped from both sides before comparison, since pep508's normalized
1189/// requirement string can diverge from the original source spacing (PyPI's `>=1.7,<2.0`
1190/// renders as `>=1.7, <2.0`) while `version_range` still spans the un-normalized source.
1191///
1192/// The second branch accepts `slice` wrapped in brackets matching `requirement` — the
1193/// exact inverse of NuGet's parser wrapping a bare source version as `format!("[{v}]")`
1194/// (`crates/deps-nuget/src/parser.rs`). This is deliberately **not** a symmetric bracket
1195/// strip: NuGet's `Version="1.0.0"` produces requirement `[1.0.0]` over a bare-literal
1196/// span, so a *symmetric* strip (stripping one bracket pair from both operands) would
1197/// compare `1.0.0` against `1.0.0` — coincidentally correct there, but the same strip
1198/// applied to `Version="[1.0.0]"` (requirement `[[1.0.0]]`, a spelling
1199/// `crates/deps-nuget/src/formatter.rs` explicitly supports) leaves `[1.0.0]` vs
1200/// `1.0.0` and **falsely rejects** an editable dependency. Wrapping only the slice side
1201/// handles both spellings without that false reject.
1202fn literal_span_matches(slice: &str, requirement: &str) -> bool {
1203 let norm_slice = strip_whitespace(slice);
1204 let norm_req = strip_whitespace(requirement);
1205 norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210 use super::*;
1211 use crate::lsp_helpers::test_support::*;
1212 use crate::{PackageName, VersionReq};
1213
1214 /// The empty-entry pruning invariant: an entry is removed once its last set channel
1215 /// is cleared, one channel at a time, in every order — never left behind as a
1216 /// vacuous `Some(DependencyOutcome::default())` that would inflate `len()`.
1217 #[test]
1218 fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
1219 let mut outcomes = DependencyOutcomes::new()
1220 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
1221 .with_deprecation(
1222 "pkg",
1223 Deprecation {
1224 reason: None,
1225 replacement: None,
1226 },
1227 )
1228 .with_fetch_failure("pkg", FetchFailure::Transient);
1229 assert_eq!(outcomes.len(), 1);
1230
1231 outcomes.clear_yanked("pkg");
1232 assert!(
1233 outcomes.get("pkg").is_some(),
1234 "entry must survive while other channels are still set"
1235 );
1236
1237 outcomes.clear_deprecation("pkg");
1238 assert!(
1239 outcomes.get("pkg").is_some(),
1240 "entry must survive while the fetch-failure channel is still set"
1241 );
1242
1243 outcomes.clear_fetch_failure("pkg");
1244 assert!(
1245 outcomes.is_empty(),
1246 "entry must be pruned once its last channel is cleared, not left as a vacuous Some"
1247 );
1248 assert_eq!(outcomes.len(), 0);
1249 }
1250
1251 /// Clearing a channel that was never set on an existing entry must not spuriously
1252 /// prune channels that ARE still set (each `clear_*` only nulls its own field).
1253 #[test]
1254 fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
1255 let mut outcomes = DependencyOutcomes::new()
1256 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
1257
1258 outcomes.clear_deprecation("pkg");
1259 outcomes.clear_fetch_failure("pkg");
1260
1261 assert!(
1262 outcomes.yanked("pkg").is_some(),
1263 "clearing unset channels must not touch the still-set yanked channel"
1264 );
1265 assert_eq!(outcomes.len(), 1);
1266 }
1267
1268 /// `remove` drops the whole entry regardless of which channels are set, unlike the
1269 /// per-channel `clear_*` methods.
1270 #[test]
1271 fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
1272 let mut outcomes = DependencyOutcomes::new()
1273 .with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
1274 .with_fetch_failure("pkg", FetchFailure::Transient);
1275
1276 outcomes.remove("pkg");
1277
1278 assert!(outcomes.get("pkg").is_none());
1279 assert!(outcomes.is_empty());
1280 }
1281
1282 /// `clear_all_fetch_failures` (deps-lsp issue #592) drops the fetch-failure channel for
1283 /// every entry, pruning an entry that becomes empty, while leaving other channels
1284 /// (yanked/deprecation/no-comparable-versions) on a still-mixed entry untouched.
1285 #[test]
1286 fn test_dependency_outcomes_clear_all_fetch_failures() {
1287 let mut outcomes = DependencyOutcomes::new()
1288 .with_fetch_failure("only-failure", FetchFailure::Transient)
1289 .with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
1290 .with_fetch_failure("mixed", FetchFailure::Transient);
1291
1292 outcomes.clear_all_fetch_failures();
1293
1294 assert!(
1295 outcomes.get("only-failure").is_none(),
1296 "an entry whose only channel was fetch-failure must be pruned"
1297 );
1298 assert!(outcomes.fetch_failure("mixed").is_none());
1299 assert!(
1300 outcomes.yanked("mixed").is_some(),
1301 "clearing fetch-failure must not touch a mixed entry's other channels"
1302 );
1303 }
1304
1305 /// `clear_all_fetch_failures` on an empty map is a no-op, not a panic.
1306 #[test]
1307 fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
1308 let mut outcomes = DependencyOutcomes::new();
1309 outcomes.clear_all_fetch_failures();
1310 assert!(outcomes.is_empty());
1311 }
1312
1313 /// `set_fetch_failure_if_absent` (impl-critic M2) must never clobber a genuine
1314 /// `Actionable`/`Transient` failure already recorded for the name — only `set_fetch_failure`
1315 /// unconditionally overwrites.
1316 #[test]
1317 fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
1318 let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
1319 "pkg",
1320 FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
1321 );
1322
1323 outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
1324
1325 assert_eq!(
1326 outcomes.fetch_failure("pkg"),
1327 Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
1328 "an existing genuine failure must survive a collided-name NotAttempted marker"
1329 );
1330 }
1331
1332 /// The mirror case: when no failure is recorded yet, `set_fetch_failure_if_absent` does
1333 /// populate the entry.
1334 #[test]
1335 fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
1336 let mut outcomes = DependencyOutcomes::new();
1337
1338 outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
1339
1340 assert_eq!(
1341 outcomes.fetch_failure("pkg"),
1342 Some(&FetchFailure::NotAttempted)
1343 );
1344 }
1345
1346 #[test]
1347 fn test_line_offset_table_line_start_crlf() {
1348 let table = LineOffsetTable::new("a\r\nbb\r\nc");
1349 assert_eq!(table.line_start(0), Some(0));
1350 assert_eq!(table.line_start(1), Some(3));
1351 assert_eq!(table.line_start(2), Some(7));
1352 assert_eq!(table.line_start(3), None);
1353 }
1354
1355 #[test]
1356 fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
1357 // "é" is a 2-byte UTF-8 sequence; offset 1 lands inside it.
1358 let content = "é";
1359 let table = LineOffsetTable::new(content);
1360 // Must not panic; clamps down to the nearest boundary (offset 0).
1361 let pos = table.byte_offset_to_position(content, 1);
1362 assert_eq!(pos, Position::new(0, 0));
1363 }
1364
1365 #[test]
1366 fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
1367 let content = "ab é cd";
1368 let table = LineOffsetTable::new(content);
1369 // Byte 3 is 'é's leading byte (boundary); byte 4 is its continuation
1370 // byte (not a boundary) and must clamp back to 3 rather than panic.
1371 assert!(content.is_char_boundary(3));
1372 assert!(!content.is_char_boundary(4));
1373 let pos = table.byte_offset_to_position(content, 4);
1374 assert_eq!(pos, table.byte_offset_to_position(content, 3));
1375 }
1376
1377 #[test]
1378 fn test_position_in_range_inside() {
1379 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1380 let position = Position::new(5, 15);
1381 assert!(position_in_range(position, range));
1382 }
1383
1384 #[test]
1385 fn test_position_in_range_at_start() {
1386 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1387 let position = Position::new(5, 10);
1388 assert!(position_in_range(position, range));
1389 }
1390
1391 #[test]
1392 fn test_position_in_range_at_end() {
1393 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1394 let position = Position::new(5, 20);
1395 assert!(position_in_range(position, range));
1396 }
1397
1398 #[test]
1399 fn test_position_in_range_before() {
1400 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1401 let position = Position::new(5, 5);
1402 assert!(!position_in_range(position, range));
1403 }
1404
1405 #[test]
1406 fn test_position_in_range_after() {
1407 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1408 let position = Position::new(5, 25);
1409 assert!(!position_in_range(position, range));
1410 }
1411
1412 #[test]
1413 fn test_position_in_range_different_line_before() {
1414 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1415 let position = Position::new(4, 15);
1416 assert!(!position_in_range(position, range));
1417 }
1418
1419 #[test]
1420 fn test_position_in_range_different_line_after() {
1421 let range = Range::new(Position::new(5, 10), Position::new(5, 20));
1422 let position = Position::new(6, 15);
1423 assert!(!position_in_range(position, range));
1424 }
1425
1426 #[test]
1427 fn test_position_in_range_multiline() {
1428 let range = Range::new(Position::new(5, 10), Position::new(7, 5));
1429 let position = Position::new(6, 0);
1430 assert!(position_in_range(position, range));
1431 }
1432
1433 #[test]
1434 fn test_escape_markdown_link_breakout_payload() {
1435 let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
1436 let escaped = escape_markdown(payload);
1437 assert_eq!(
1438 escaped,
1439 r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
1440 );
1441 assert!(!escaped.contains("]("));
1442 }
1443
1444 #[test]
1445 fn test_escape_markdown_backslash_and_backtick() {
1446 assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
1447 }
1448
1449 #[test]
1450 fn test_escape_markdown_autolink_angle_brackets() {
1451 // `<...>` around a bare URL is a CommonMark autolink; `<`/`>` must be escaped
1452 // so it cannot render as a live link independent of the `[]`/`()` escaping.
1453 let escaped = escape_markdown("pkg <https://evil.example>");
1454 assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
1455 assert!(!escaped.contains('<') || escaped.contains(r"\<"));
1456 }
1457
1458 #[test]
1459 fn test_escape_markdown_control_chars_become_spaces() {
1460 assert_eq!(escape_markdown("a\nb"), "a b");
1461 assert_eq!(escape_markdown("a\r\nb"), "a b");
1462 assert_eq!(escape_markdown("a\tb"), "a b");
1463 assert_eq!(escape_markdown("a\0b"), "a b");
1464 }
1465
1466 #[test]
1467 fn test_escape_markdown_newline_cannot_break_out_of_heading() {
1468 // A raw newline used to terminate the ATX heading line early, letting the
1469 // rest of the name (potentially another "# [...](...)" sequence) render as
1470 // separate, unescaped Markdown blocks.
1471 let escaped = escape_markdown("react\n# [fake](https://evil.example)");
1472 assert!(!escaped.contains('\n'));
1473 }
1474
1475 #[test]
1476 fn test_escape_markdown_hyphenated_name_round_trips_visually() {
1477 // Escaping a hyphen (ASCII punctuation) is visually inert on render — CommonMark
1478 // renders `\-` as a literal `-` — so common package names are unaffected in
1479 // practice even though the raw Markdown source now escapes them.
1480 assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
1481 }
1482
1483 #[test]
1484 fn test_markdown_code_span_plain_content() {
1485 assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
1486 }
1487
1488 #[test]
1489 fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
1490 assert_eq!(markdown_code_span("a`b"), "``a`b``");
1491 assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
1492 }
1493
1494 #[test]
1495 fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
1496 let span = markdown_code_span("`leading");
1497 assert!(span.starts_with("`` `"));
1498 }
1499
1500 #[test]
1501 fn test_markdown_code_span_replaces_control_chars() {
1502 let span = markdown_code_span("1.0\n[evil](https://evil.example)");
1503 assert!(!span.contains('\n'));
1504 }
1505
1506 #[test]
1507 fn test_markdown_code_span_empty_content() {
1508 assert_eq!(markdown_code_span(""), "` `");
1509 }
1510
1511 #[test]
1512 fn test_markdown_code_span_backtick_payload_cannot_break_span() {
1513 // A payload attempting to close the code span early and splice in a live
1514 // link must not succeed regardless of backtick count in the content.
1515 let payload = "1.0` <https://evil.example>` more";
1516 let span = markdown_code_span(payload);
1517 // The fence must be strictly longer than any backtick run in the (sanitized)
1518 // content, so no substring of `span` after the opening fence can act as a
1519 // closing fence before the real one.
1520 let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
1521 let inner = &span[opening_fence_len..span.len() - opening_fence_len];
1522 assert!(
1523 !inner.contains(&"`".repeat(opening_fence_len)),
1524 "content contains a run of backticks as long as the fence: {span}"
1525 );
1526 }
1527
1528 #[test]
1529 fn test_is_same_major_minor_full_match() {
1530 assert!(is_same_major_minor("1.2.3", "1.2.9"));
1531 }
1532
1533 #[test]
1534 fn test_is_same_major_minor_exact_match() {
1535 assert!(is_same_major_minor("1.2.3", "1.2.3"));
1536 }
1537
1538 #[test]
1539 fn test_is_same_major_minor_major_only_match() {
1540 assert!(is_same_major_minor("1", "1.2.3"));
1541 assert!(is_same_major_minor("1.2.3", "1"));
1542 }
1543
1544 #[test]
1545 fn test_is_same_major_minor_no_match_different_minor() {
1546 assert!(!is_same_major_minor("1.2.3", "1.3.0"));
1547 }
1548
1549 #[test]
1550 fn test_is_same_major_minor_no_match_different_major() {
1551 assert!(!is_same_major_minor("1.2.3", "2.2.3"));
1552 }
1553
1554 #[test]
1555 fn test_is_same_major_minor_empty_strings() {
1556 assert!(!is_same_major_minor("", ""));
1557 assert!(!is_same_major_minor("1.2.3", ""));
1558 assert!(!is_same_major_minor("", "1.2.3"));
1559 }
1560
1561 #[test]
1562 fn test_is_safe_version_string_accepts_ordinary_versions() {
1563 assert!(is_safe_version_string("1.2.3"));
1564 assert!(is_safe_version_string("1.2.3-beta.1+build"));
1565 assert!(is_safe_version_string("v1.2.3"));
1566 }
1567
1568 #[test]
1569 fn test_is_safe_version_string_rejects_empty_or_whitespace() {
1570 assert!(!is_safe_version_string(""));
1571 assert!(!is_safe_version_string(" "));
1572 assert!(!is_safe_version_string("\t\n"));
1573 }
1574
1575 #[test]
1576 fn test_is_safe_version_string_rejects_control_and_structural_characters() {
1577 for bad in [
1578 "1.2.3\n",
1579 "1.2.3\t",
1580 "1.2.3\"",
1581 "1.2.3'",
1582 "1.2.3<",
1583 "1.2.3>",
1584 "1.2.3&",
1585 "1.2.3\\",
1586 "1.0.0\", \"malicious\": \"true",
1587 ] {
1588 assert!(
1589 !is_safe_version_string(bad),
1590 "expected {bad:?} to be rejected"
1591 );
1592 }
1593 }
1594
1595 #[test]
1596 fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
1597 // Regression (critic S2): `$`/`{`/`}` are outside the allowlist, so a
1598 // Gradle Kotlin/Groovy `${...}` interpolation payload written into
1599 // build.gradle(.kts) can never reach a version literal via this gate.
1600 for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
1601 assert!(
1602 !is_safe_version_string(bad),
1603 "expected {bad:?} to be rejected"
1604 );
1605 }
1606 }
1607
1608 #[test]
1609 fn test_is_safe_version_string_rejects_invisible_unicode() {
1610 // Regression (critic M1): `char::is_control()` alone only covers
1611 // category Cc — format/separator characters like the bidi override
1612 // U+202E, zero-width space U+200B, and the JS/JSON5 line terminators
1613 // U+2028/U+2029 must also be rejected by the allowlist.
1614 for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
1615 assert!(
1616 !is_safe_version_string(bad),
1617 "expected {bad:?} to be rejected"
1618 );
1619 }
1620 }
1621
1622 #[test]
1623 fn test_is_safe_version_string_accepts_pep440_epoch() {
1624 // PEP 440 epochs (`1!2.0`) are legitimate PyPI versions.
1625 assert!(is_safe_version_string("1!2.0"));
1626 }
1627
1628 #[test]
1629 fn test_is_safe_version_string_length_cap() {
1630 assert!(is_safe_version_string(&"1".repeat(64)));
1631 assert!(!is_safe_version_string(&"1".repeat(65)));
1632 }
1633
1634 #[test]
1635 fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
1636 assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
1637 assert!(is_safe_maven_coordinate_segment("commons-lang3"));
1638 assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
1639 }
1640
1641 #[test]
1642 fn test_is_safe_maven_coordinate_segment_rejects_empty() {
1643 assert!(!is_safe_maven_coordinate_segment(""));
1644 }
1645
1646 #[test]
1647 fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
1648 for bad in [
1649 "commons</artifactId><parent>",
1650 "commons\"",
1651 "commons'",
1652 "commons&",
1653 "commons\nlang3",
1654 "commons\tlang3",
1655 ] {
1656 assert!(
1657 !is_safe_maven_coordinate_segment(bad),
1658 "expected {bad:?} to be rejected"
1659 );
1660 }
1661 }
1662
1663 #[test]
1664 fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
1665 assert!(!is_safe_maven_coordinate_segment(
1666 "org.apache.commons:commons-lang3"
1667 ));
1668 }
1669
1670 #[test]
1671 fn test_is_safe_maven_coordinate_segment_length_cap() {
1672 assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
1673 assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
1674 }
1675
1676 #[test]
1677 fn test_is_safe_registry_url_accepts_real_urls() {
1678 assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
1679 assert!(is_safe_registry_url(
1680 "https://github.com/apple/swift-nio.git"
1681 ));
1682 assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
1683 }
1684
1685 #[test]
1686 fn test_is_safe_registry_url_rejects_non_https_scheme() {
1687 // Every real Swift package registry response is HTTPS; accepting `http://` would
1688 // only hand a compromised registry a transport-downgrade lever.
1689 assert!(!is_safe_registry_url("http://example.com/repo"));
1690 assert!(!is_safe_registry_url("file:///etc/passwd"));
1691 assert!(!is_safe_registry_url("javascript:alert(1)"));
1692 assert!(!is_safe_registry_url("ftp://example.com/repo"));
1693 assert!(!is_safe_registry_url(""));
1694 }
1695
1696 #[test]
1697 fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
1698 for bad in [
1699 "https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
1700 "https://evil.example\\",
1701 "https://evil.example\nlet x = 1",
1702 "https://evil.example`echo`",
1703 "https://evil.example<script>",
1704 ] {
1705 assert!(
1706 !is_safe_registry_url(bad),
1707 "expected {bad:?} to be rejected"
1708 );
1709 }
1710 }
1711
1712 #[test]
1713 fn test_is_safe_registry_url_length_cap() {
1714 let prefix = "https://example.com/";
1715 let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
1716 assert_eq!(at_cap.len(), 2048);
1717 assert!(is_safe_registry_url(&at_cap));
1718
1719 let over_cap = format!("{at_cap}a");
1720 assert_eq!(over_cap.len(), 2049);
1721 assert!(!is_safe_registry_url(&over_cap));
1722 }
1723
1724 #[test]
1725 fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
1726 for good in [
1727 "serde", // Cargo
1728 "requests", // PyPI
1729 "@scope/name", // npm/Deno scoped
1730 "monolog/monolog", // Composer vendor/package
1731 "github.com/org/repo", // Go module path
1732 "path", // Dart
1733 "org.apache.commons:commons-lang3", // Gradle group:artifact
1734 "Newtonsoft.Json", // NuGet
1735 "rails", // Bundler
1736 "npm:react", // Deno npm-scheme specifier
1737 "jsr:@std/fs", // Deno jsr-scheme specifier
1738 "github.com/foo/bar~compat", // Go path element with `~`
1739 ] {
1740 assert!(
1741 is_safe_package_name(good),
1742 "expected {good:?} to be accepted"
1743 );
1744 }
1745 }
1746
1747 #[test]
1748 fn test_is_safe_package_name_rejects_empty() {
1749 assert!(!is_safe_package_name(""));
1750 }
1751
1752 #[test]
1753 fn test_is_safe_package_name_rejects_non_ascii() {
1754 // Deliberately excluded: the allowlist is ASCII-only, so a legacy non-ASCII
1755 // npm package name (a handful exist, e.g. Unicode-normalized scopes) is
1756 // rejected rather than risking homograph/normalization tricks in a manifest.
1757 assert!(!is_safe_package_name("café"));
1758 assert!(!is_safe_package_name("пакет"));
1759 }
1760
1761 #[test]
1762 fn test_is_safe_package_name_accepts_dot_dot_shapes() {
1763 // `.`/`/` are individually legal (PyPI dotted names, npm/Composer scopes), so
1764 // `..`/`../..` pass the charset too. This is not a path-traversal risk: every
1765 // sink treats `name` as manifest text (a TOML/JSON/YAML/XML value or a
1766 // string-literal argument), never as a filesystem path.
1767 assert!(is_safe_package_name(".."));
1768 assert!(is_safe_package_name("../.."));
1769 }
1770
1771 #[test]
1772 fn test_is_safe_package_name_rejects_structural_breakout_characters() {
1773 for bad in [
1774 "evil\"\nbackdoor = \"9.9.9",
1775 "evil\", git = \"https://evil",
1776 "evil\\",
1777 "evil'",
1778 "evil<script>",
1779 "evil`echo`",
1780 "evil\ninjected = true",
1781 "evil\tname",
1782 ] {
1783 assert!(
1784 !is_safe_package_name(bad),
1785 "expected {bad:?} to be rejected"
1786 );
1787 }
1788 }
1789
1790 #[test]
1791 fn test_is_safe_package_name_length_cap() {
1792 assert!(is_safe_package_name(&"a".repeat(256)));
1793 assert!(!is_safe_package_name(&"a".repeat(257)));
1794 }
1795
1796 #[test]
1797 fn test_is_same_major_minor_partial_versions() {
1798 assert!(is_same_major_minor("1.2", "1.2.3"));
1799 assert!(is_same_major_minor("1.2.3", "1.2"));
1800 }
1801
1802 #[test]
1803 fn test_ecosystem_formatter_defaults() {
1804 let formatter = MockFormatter;
1805 assert_eq!(
1806 formatter.normalize_package_name(&pkg("test-pkg")),
1807 "test-pkg"
1808 );
1809 assert_eq!(formatter.yanked_message(), "This version has been yanked");
1810 assert_eq!(formatter.yanked_label(), "*(yanked)*");
1811 }
1812
1813 #[test]
1814 fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
1815 let formatter = MockFormatter;
1816 let dep = MockDep {
1817 name: pkg("test-pkg"),
1818 version_req: VersionReq::new("1.0.0"),
1819 version_range: Range::default(),
1820 name_range: Range::default(),
1821 };
1822 assert_eq!(
1823 formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
1824 formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
1825 );
1826 }
1827
1828 #[test]
1829 fn test_ecosystem_formatter_version_satisfies() {
1830 let formatter = MockFormatter;
1831
1832 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
1833
1834 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
1835 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
1836
1837 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
1838 assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
1839
1840 assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
1841 assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.3"));
1842 }
1843
1844 #[test]
1845 fn test_ecosystem_formatter_custom_normalize() {
1846 struct PyPIFormatter;
1847
1848 impl PackageNaming for PyPIFormatter {
1849 fn normalize_package_name(&self, name: &PackageName) -> String {
1850 name.as_str().to_lowercase().replace('-', "_")
1851 }
1852 }
1853
1854 impl PackageRendering for PyPIFormatter {
1855 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
1856 format!(
1857 ">={},<{}",
1858 version,
1859 version.as_str().split('.').next().unwrap_or("0")
1860 )
1861 }
1862
1863 fn package_url(&self, name: &PackageName) -> String {
1864 format!("https://pypi.org/project/{}", name)
1865 }
1866 }
1867
1868 impl RequirementResolution for PyPIFormatter {}
1869
1870 impl DiagnosticMessages for PyPIFormatter {}
1871
1872 impl DiagnosticPolicy for PyPIFormatter {}
1873
1874 impl SourcePolicy for PyPIFormatter {}
1875
1876 impl OsvNaming for PyPIFormatter {}
1877
1878 let formatter = PyPIFormatter;
1879 assert_eq!(
1880 formatter.normalize_package_name(&pkg("Test-Package")),
1881 "test_package"
1882 );
1883 assert_eq!(
1884 formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
1885 ">=1.2.3,<1"
1886 );
1887 assert_eq!(
1888 formatter.package_url(&pkg("requests")),
1889 "https://pypi.org/project/requests"
1890 );
1891 }
1892}