deps_core/registry.rs
1use crate::error::{DepsError, Result};
2use crate::parser::DependencySource;
3use crate::{ConcreteVersion, EcosystemId, PackageName, VersionReq};
4use dashmap::DashMap;
5use dashmap::mapref::entry::Entry;
6use std::any::Any;
7use std::hash::Hash;
8use std::pin::Pin;
9
10type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
11
12/// Generic package registry interface.
13///
14/// Implementors provide access to a package registry (crates.io, npm, PyPI, etc.)
15/// with version lookup, search, and metadata retrieval capabilities.
16///
17/// All methods return `Result<T>` to allow graceful error handling.
18/// LSP handlers must never panic on registry errors.
19///
20/// # Type Erasure
21///
22/// This trait uses `Box<dyn Trait>` return types instead of associated types
23/// to allow runtime polymorphism and dynamic ecosystem registration.
24///
25/// # Registry client API contract (#834)
26///
27/// Each ecosystem crate's own registry struct (`CratesIoRegistry`, `NpmRegistry`,
28/// `MavenCentralRegistry`, ...) additionally exposes a set of **inherent** async methods
29/// with concrete (non-boxed) parameter/return types — this trait's own methods box and
30/// type-erase, which every hover/completion/diagnostic call site needs, but a caller that
31/// already knows the concrete ecosystem (most ecosystem-crate-internal code, and every
32/// conformance test) wants the unboxed value directly. The trait method of the same name is
33/// expected to delegate to its inherent counterpart (see `deps-cargo::registry::CratesIoRegistry`
34/// for the canonical shape). Naming previously drifted per crate (issue #760, #834); the
35/// canonical vocabulary going forward is:
36///
37/// - `get_versions` — fetch all versions.
38/// - `get_versions_with` — fetch all versions plus extra data (e.g. publish dates).
39/// - `get_latest_matching` — fetch the single version matching a requirement.
40/// - `search` — fetch search results for a query.
41/// - `register_alternate` — register an alternate/private registry source.
42/// - `package_url` — build the registry's web-display URL for a package (re-exported from
43/// the crate root wherever it exists).
44/// - `with_base` — construct a registry client pointed at a non-default base URL (tests,
45/// alternate-registry hops); a plain, ungated `with_base` for production use, distinct
46/// from any additional `#[cfg(test)]`/`test-util`-gated constructor a crate also needs
47/// (see `deps-npm::registry::NpmRegistry::with_public_base_for_test` for that shape).
48/// - Pure in-memory getters (no I/O) drop the `get_` prefix entirely, per the Rust API
49/// Guidelines' C-GETTER (e.g. `ResolvedPackages::version`/`all`,
50/// `EcosystemRegistry::for_filename`/`for_uri`/`for_lockfile`/`for_watched_config`).
51///
52/// Two sanctioned, named exceptions to this vocabulary (a deliberate #834 scope decision,
53/// not drift left to fix): ecosystem-specific display-URL builder names (`gem_url` in
54/// `deps-bundler`, `crate_url` in `deps-cargo`, `jsr_package_url` in `deps-deno`) stay as-is
55/// — only re-export consistency was in scope, not the name itself; and the metadata-fetch
56/// **return type** stays per-ecosystem (`GemInfo`/`PackageInfo`/`ArtifactInfo`/`CrateInfo`/
57/// `DenoMetadata`/`GoMetadata`, ...) — only the *method* name converges on
58/// `get_package_metadata`.
59///
60/// [`crate::registry_conformance!`]'s `ty:` form asserts the first four names exist on a
61/// given registry type at compile time (and, since #834 critic S5, actually asserts they
62/// are *inherent* — see [`crate::conformance::NotInherent`]'s own doc), so a future
63/// ecosystem crate cannot silently reintroduce this drift. Wired directly into 9 of the 14
64/// ecosystem crates as of #834; `deps-gradle` reuses `deps-maven`'s `MavenCentralRegistry`
65/// unchanged and so is covered transitively through `deps-maven`'s own check. Four crates
66/// are exempt, each for a distinct, documented reason rather than a silent gap:
67/// - `deps-gitlab-ci` — route-based, no name+version registry concept at all; every one of
68/// the four trait methods is an inline stub in the `Registry` impl itself, with no real
69/// per-package logic anywhere to extract.
70/// - `deps-deno` — `DenoRegistry`, the type that implements `Registry`, is a
71/// scheme-dispatching facade with no single struct holding all four methods (it dispatches
72/// by `jsr:`/`npm:` prefix to either its `JsrRegistry` field or an `NpmRegistry`, with
73/// different call shapes on each side). Note this is a statement about `DenoRegistry`
74/// specifically, not about the crate as a whole: `JsrRegistry` itself already has genuine
75/// inherent `get_versions`/`search`, just not `get_versions_with`/`get_latest_matching`,
76/// and its `get_versions(&self, scope: &str, name: &str)` is a fifth, two-part-key
77/// argument shape for the canonical `get_versions` name (JSR packages are scoped,
78/// `@scope/name`) that this contract's table does not otherwise cover.
79/// - `deps-go`, `deps-github-actions` — both have genuine inherent `get_versions`/
80/// `get_versions_with`/`get_latest_matching`; only `search` is missing, because neither
81/// registry (the Go module proxy, the GitHub tags API) exposes a search endpoint this
82/// crate can call at all. An inherent `search` here would be a fabricated always-empty
83/// stub whose only purpose is satisfying this check, not a real, callable operation — the
84/// `Registry` trait impl's own `search` (also always empty, for the same reason) is the
85/// honest place for that.
86///
87/// # Examples
88///
89/// ```no_run
90/// use deps_core::{Registry, Version, Metadata, PackageName, ConcreteVersion};
91/// use std::any::Any;
92/// use std::pin::Pin;
93///
94/// struct MyRegistry;
95///
96/// #[derive(Clone)]
97/// struct MyVersion { version: ConcreteVersion }
98///
99/// impl Version for MyVersion {
100/// fn version_string(&self) -> &ConcreteVersion { &self.version }
101/// fn as_any(&self) -> &dyn Any { self }
102/// }
103///
104/// #[derive(Clone)]
105/// struct MyMetadata { name: PackageName, latest: ConcreteVersion }
106///
107/// impl Metadata for MyMetadata {
108/// fn name(&self) -> &PackageName { &self.name }
109/// fn description(&self) -> Option<&str> { None }
110/// fn repository(&self) -> Option<&str> { None }
111/// fn documentation(&self) -> Option<&str> { None }
112/// fn latest_version(&self) -> &ConcreteVersion { &self.latest }
113/// fn as_any(&self) -> &dyn Any { self }
114/// }
115///
116/// impl Registry for MyRegistry {
117/// fn get_versions<'a>(&'a self, _name: &'a PackageName)
118/// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Version>>>> + Send + 'a>>
119/// {
120/// Box::pin(async move { Ok(vec![Box::new(MyVersion { version: "1.0.0".into() }) as Box<dyn Version>]) })
121/// }
122///
123/// fn get_latest_matching<'a>(&'a self, _name: &'a PackageName, _req: &'a deps_core::VersionReq, _selection_context: &'a deps_core::SelectionContext)
124/// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Option<Box<dyn Version>>>> + Send + 'a>>
125/// {
126/// Box::pin(async move { Ok(None) })
127/// }
128///
129/// fn search_raw<'a>(&'a self, _query: &'a str, _limit: usize)
130/// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Metadata>>>> + Send + 'a>>
131/// {
132/// Box::pin(async move { Ok(vec![]) })
133/// }
134///
135/// fn as_any(&self) -> &dyn Any { self }
136/// }
137/// ```
138pub trait Registry: Send + Sync {
139 /// Fetches all available versions for a package.
140 ///
141 /// Returns versions sorted newest-first. May include yanked/deprecated versions.
142 ///
143 /// # Errors
144 ///
145 /// Returns error if:
146 /// - Package does not exist
147 /// - Network request fails
148 /// - Response parsing fails
149 fn get_versions<'a>(
150 &'a self,
151 name: &'a PackageName,
152 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>>;
153
154 /// Like [`get_versions`](Self::get_versions), but lets a registry that can obtain
155 /// [`Version::published_at`] only through an *extra* request gate that request behind
156 /// `freshness.is_enabled()` instead of always paying for it.
157 ///
158 /// Implementors overriding this method MUST keep every other aspect of
159 /// [`get_versions`](Self::get_versions)'s behavior — set, order, and content of the
160 /// returned versions — identical; the only difference the override may introduce is
161 /// populating [`Version::published_at`]. Callers that render publish ages MUST call this
162 /// method rather than [`get_versions`](Self::get_versions): the default implementation
163 /// below simply forwards to `get_versions` and ignores `freshness`, so a caller that
164 /// keeps calling `get_versions` silently gets no freshness signal even from a registry
165 /// that implements this override.
166 ///
167 /// Default: forwards to [`get_versions`](Self::get_versions), ignoring `freshness`. This
168 /// keeps the ten registries with no extra publish-time source unchanged, and keeps
169 /// [`FreshnessSettings`](crate::freshness::FreshnessSettings) — a `Copy + 'static` DTO —
170 /// out of every `Registry::new` signature and the `register!` ecosystem-registration
171 /// macro.
172 fn get_versions_with<'a>(
173 &'a self,
174 name: &'a PackageName,
175 freshness: crate::freshness::FreshnessSettings,
176 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
177 let _ = freshness;
178 self.get_versions(name)
179 }
180
181 /// Like [`get_versions_with`](Self::get_versions_with), but additionally carries the
182 /// dependency's resolved [`DependencySource`], for a registry that routes a single
183 /// fetch across more than one underlying index (e.g. `deps-cargo`'s `CargoRegistry`,
184 /// which dispatches a `DependencySource::AlternateRegistry` to a private sparse index
185 /// instead of crates.io).
186 ///
187 /// Default: forwards to [`get_versions_with`](Self::get_versions_with), ignoring
188 /// `source` entirely. This keeps every registry with no per-dependency routing concept
189 /// — every ecosystem except Cargo today — bit-identical: `source` is accepted and
190 /// dropped, so a caller migrating to this method from `get_versions_with` changes no
191 /// observable behavior for them.
192 ///
193 /// Callers that know which source a dependency resolved to should call this rather
194 /// than [`get_versions_with`](Self::get_versions_with), even against a registry with no
195 /// override — the whole point is that call sites don't need to know which registries
196 /// route on source and which don't.
197 fn get_versions_from<'a>(
198 &'a self,
199 name: &'a PackageName,
200 source: &'a DependencySource,
201 freshness: crate::freshness::FreshnessSettings,
202 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
203 let _ = source;
204 self.get_versions_with(name, freshness)
205 }
206
207 /// Finds the latest version matching a version requirement.
208 ///
209 /// Filter with [`RemovalStatus::blocks_resolution`], never with
210 /// [`RemovalStatus::is_flagged`]. An `AdvisoryDeprecated` version is fully
211 /// installable — excluding it turns an existing package into a false
212 /// "Unknown package" (#347). Under a wildcard/empty requirement
213 /// (`"*"`/`""`, see [`is_existence_wildcard`]) this is an *existence* check ("does this
214 /// package exist / what is its newest version for display purposes"), not an upgrade
215 /// recommendation: an implementation may prefer a non-yanked version but fall back to a
216 /// yanked one rather than returning `None` when no non-yanked version exists — a yanked
217 /// package still exists. `deps-npm` implements this fallback (mirrored by
218 /// [`select_latest_matching`](Self::select_latest_matching)'s wildcard branch on the
219 /// same registry, which every caller reaching this trait method through the shared
220 /// fetch loop actually goes through first); the exception never applies to a concrete
221 /// requirement.
222 ///
223 /// `selection_context` is the caller's [`crate::SelectionContext`] — opaque and
224 /// ecosystem-owned (see that type's own doc), mirroring [`get_versions_with`](Self::get_versions_with)'s
225 /// [`FreshnessSettings`](crate::freshness::FreshnessSettings) precedent for "an optional
226 /// extra parameter most registries ignore"; only the one registry that understands its
227 /// contents (`PackagistRegistry`, via [`crate::SelectionContext::minimum_stability`])
228 /// overrides this method to read it — every other registry ignores it. A caller with no
229 /// ecosystem-specific manifest state to thread passes [`crate::SelectionContext::none()`]
230 /// explicitly (#1444: this parameter is required, not defaulted, so skipping it is always
231 /// a visible, reviewable choice at the call site rather than an easy-to-forget separate
232 /// method name).
233 ///
234 /// # Arguments
235 ///
236 /// * `name` - Package name
237 /// * `req` - Version requirement string (e.g., "^1.0", ">=2.0")
238 /// * `selection_context` - Manifest-scoped selection state; [`crate::SelectionContext::none()`]
239 /// when the caller has none
240 ///
241 /// # Returns
242 ///
243 /// - `Ok(Some(version))` - Latest matching version found
244 /// - `Ok(None)` - No matching version found
245 /// - `Err(_)` - Network or parsing error
246 fn get_latest_matching<'a>(
247 &'a self,
248 name: &'a PackageName,
249 req: &'a VersionReq,
250 selection_context: &'a crate::SelectionContext,
251 ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>>;
252
253 /// Like [`get_latest_matching`](Self::get_latest_matching), but additionally carries the
254 /// dependency's resolved [`DependencySource`] — the `get_latest_matching`-shaped
255 /// counterpart to [`get_versions_from`](Self::get_versions_from), covering the fallback
256 /// path a caller takes when the list-based pick fails on a non-empty
257 /// [`get_versions_from`](Self::get_versions_from) result (see
258 /// `deps_core::lsp_helpers::hover`'s `list_fallback_latest` and `deps-lsp`'s
259 /// background-fetch fallback for the two call sites this exists for).
260 ///
261 /// Default: forwards to [`get_latest_matching`](Self::get_latest_matching), ignoring
262 /// `source` — every registry with no per-dependency routing concept stays bit-identical,
263 /// exactly as [`get_versions_from`](Self::get_versions_from) does for the list-fetching
264 /// side.
265 fn get_latest_matching_from<'a>(
266 &'a self,
267 name: &'a PackageName,
268 source: &'a DependencySource,
269 req: &'a VersionReq,
270 selection_context: &'a crate::SelectionContext,
271 ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
272 let _ = source;
273 self.get_latest_matching(name, req, selection_context)
274 }
275
276 /// Searches for packages by name or keywords.
277 ///
278 /// Returns up to `limit` results sorted by relevance/popularity.
279 ///
280 /// Named `search_raw`, not `search` (#1215): this required method carries no
281 /// credential-bearing-query gate of its own, and a `fn search` provided-method default
282 /// could be silently skipped by an override — an *inherent* `search` on `dyn Registry`
283 /// (below) wraps this method with [`crate::redact::is_credential_or_query_bearing`]
284 /// and cannot be silently overridden by an implementor, unlike a provided trait method
285 /// default — every `&dyn Registry`/`Arc<dyn Registry>` call site through the gate gets it
286 /// automatically. This is a dispatch-level guarantee, not a hard access barrier: `search_raw`
287 /// is still `pub`, so a caller holding a `&dyn Registry` can call it directly and skip the
288 /// gate with one extra token — call it directly only from a context that already knows
289 /// `query` is safe (e.g. a concrete registry's own inherent `search`, which independently
290 /// gates its own callers).
291 ///
292 /// # Errors
293 ///
294 /// Returns error if network request or parsing fails.
295 fn search_raw<'a>(
296 &'a self,
297 query: &'a str,
298 limit: usize,
299 ) -> BoxFuture<'a, Result<Vec<Box<dyn Metadata>>>>;
300
301 /// Whether [`search_raw`](Self::search_raw) matches `query` against a *normalized* form
302 /// of package names rather than the raw typed text.
303 ///
304 /// A `true` implementor guarantees its search is performed against normalized names (e.g.
305 /// PyPI's PEP 503 normalization, which folds `-`/`_`/`.` together), so a returned
306 /// [`Metadata::name`] is not guaranteed to be a literal subsequence of the raw `query` an
307 /// LSP client typed — the caller must not assume `filter_text` defaulting to `label` will
308 /// still match client-side. Callers use this to decide whether a completion response needs
309 /// its `filter_text` rewritten to the raw typed prefix (see
310 /// [`crate::completion::apply_raw_prefix_filter_text`]).
311 ///
312 /// Default `false` — the correct answer for every registry whose search already matches
313 /// literal substrings/prefixes of the raw query, which is every registry in this workspace
314 /// except `deps-pypi`'s `PypiRegistry` (see that crate's own override).
315 ///
316 /// An implementor returning `true` must be paired with its owning
317 /// `Ecosystem::package_search_is_incomplete` also returning `true`: the client re-issuing a
318 /// fresh search on the very next keystroke (and so discarding this response's rewritten
319 /// `filter_text` rather than continuing to filter it locally forever) is what makes the
320 /// rewrite in [`crate::completion::apply_raw_prefix_filter_text`] safe. This crate has no
321 /// compile-time way to enforce that pairing across the two traits — it is a contract on the
322 /// implementor, not a checked invariant.
323 fn search_normalizes_query(&self) -> bool {
324 false
325 }
326
327 /// Index of the latest version in `versions` satisfying `req`, with no I/O.
328 ///
329 /// Filter with [`RemovalStatus::blocks_resolution`], never with
330 /// [`RemovalStatus::is_flagged`]. An `AdvisoryDeprecated` version is fully
331 /// installable — excluding it turns an existing package into a false
332 /// "Unknown package" (#347). Under a wildcard/empty requirement (see
333 /// [`is_existence_wildcard`]) this is an *existence* check, not an upgrade
334 /// recommendation: Cargo, PyPI, Dart, npm, and Deno implement it by gating on
335 /// [`is_existence_wildcard`] and delegating to [`select_latest_for_existence`].
336 ///
337 /// `versions` must be a newest-first list as returned by this registry's
338 /// [`get_versions`](Self::get_versions). Returns an index rather than a reference so
339 /// callers holding an owned `Vec` can move the chosen element out
340 /// (`versions.into_iter().nth(i)`) — a borrow into the list would keep it frozen while
341 /// [`get_latest_matching`](Self::get_latest_matching) needs to return an owned
342 /// `Box<dyn Version>`, and `Version` has no `clone_box`.
343 ///
344 /// `selection_context` is the caller's [`crate::SelectionContext`] — see
345 /// [`get_latest_matching`](Self::get_latest_matching) for why this parameter is required
346 /// rather than defaulted; every registry with no ecosystem-specific manifest state to
347 /// refine selection with ignores it via this method's own default.
348 ///
349 /// Default: `None`. Every registry reachable from the LSP fetch path overrides this so
350 /// the fetch loop can obtain both "latest" and the full version list from one round
351 /// trip; the default exists so test doubles that never resolve a "latest" compile
352 /// unchanged.
353 fn select_latest_matching(
354 &self,
355 _versions: &[Box<dyn Version>],
356 _req: &VersionReq,
357 _selection_context: &crate::SelectionContext,
358 ) -> Option<usize> {
359 None
360 }
361
362 /// Whether [`get_versions`](Self::get_versions) results carry meaningful
363 /// per-version yank/deprecation data via [`Version::removal_status`].
364 ///
365 /// Default `true`: a registry is opted into the yanked-version diagnostic
366 /// unless it explicitly says it cannot answer. This fails toward
367 /// correctness — a registry whose `removal_status` later becomes real
368 /// data starts participating automatically, by deleting its opt-out
369 /// rather than by someone remembering to add an opt-in. Return `false`
370 /// only when `removal_status()` is hardcoded (e.g. always
371 /// `RemovalStatus::Available`) or otherwise cannot reflect real registry
372 /// data; a `true` return authorizes callers to trust `removal_status()`
373 /// on versions from this registry's normal
374 /// [`get_versions`](Self::get_versions)/
375 /// [`get_latest_matching`](Self::get_latest_matching) results — it does
376 /// not trigger any additional network request.
377 fn reports_yanked(&self) -> bool {
378 true
379 }
380
381 /// Downcast to concrete registry type for ecosystem-specific operations
382 fn as_any(&self) -> &dyn Any;
383}
384
385impl dyn Registry + '_ {
386 /// Rejects a credential- or query-bearing `query` before it ever reaches
387 /// [`Registry::search_raw`] (#1215), then delegates.
388 ///
389 /// An **inherent** method on the trait object, not a provided trait method: an inherent
390 /// method cannot be shadowed by a `Registry` implementor the way a provided trait method
391 /// can be silently overridden, so every `&dyn Registry`/`Arc<dyn Registry>` call site gets
392 /// this gate automatically, present and future — the same enforcement shape issue #1206
393 /// already established for [`crate::completion::reject_credential_bearing_value`],
394 /// generalized to the trait boundary itself rather than left to each call site to
395 /// remember. This is a dispatch-level guarantee, not a hard access barrier:
396 /// [`Registry::search_raw`] is still `pub`, so a caller holding a `&dyn Registry` can call
397 /// it directly and skip this gate with one extra token — see that method's own doc.
398 ///
399 /// Returns `Ok(vec![])` without calling [`Registry::search_raw`] at all when `query` is
400 /// flagged by [`crate::redact::is_credential_or_query_bearing`] — a redacted value is
401 /// not a useful search term, so the request is dropped rather than redacted-and-sent.
402 ///
403 /// # Errors
404 ///
405 /// Returns error if [`Registry::search_raw`]'s underlying network request or parsing
406 /// fails.
407 ///
408 /// # Examples
409 ///
410 /// ```
411 /// use deps_core::Registry;
412 ///
413 /// struct MyRegistry;
414 /// # use deps_core::{Metadata, PackageName, Version};
415 /// # use std::any::Any;
416 /// # use std::pin::Pin;
417 /// impl Registry for MyRegistry {
418 /// # fn get_versions<'a>(&'a self, _name: &'a PackageName)
419 /// # -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Version>>>> + Send + 'a>>
420 /// # { Box::pin(async move { Ok(vec![]) }) }
421 /// # fn get_latest_matching<'a>(&'a self, _name: &'a PackageName, _req: &'a deps_core::VersionReq, _selection_context: &'a deps_core::SelectionContext)
422 /// # -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Option<Box<dyn Version>>>> + Send + 'a>>
423 /// # { Box::pin(async move { Ok(None) }) }
424 /// fn search_raw<'a>(&'a self, _query: &'a str, _limit: usize)
425 /// -> Pin<Box<dyn std::future::Future<Output = deps_core::error::Result<Vec<Box<dyn Metadata>>>> + Send + 'a>>
426 /// {
427 /// panic!("must never be reached for a credential-bearing query");
428 /// }
429 /// # fn as_any(&self) -> &dyn Any { self }
430 /// }
431 ///
432 /// # #[tokio::main]
433 /// # async fn main() {
434 /// let registry: &dyn Registry = &MyRegistry;
435 /// let results = registry
436 /// .search("user:hunter2@registry.example", 10)
437 /// .await
438 /// .unwrap();
439 /// assert!(results.is_empty());
440 /// # }
441 /// ```
442 pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<Box<dyn Metadata>>> {
443 if crate::redact::is_credential_or_query_bearing(query) {
444 crate::lsp_helpers::warn_rejected_value("credential_bearing", "registry search", query);
445 return Ok(vec![]);
446 }
447 self.search_raw(query, limit).await
448 }
449}
450
451/// Whether `version` contains one of the common pre-release substrings
452/// (`-alpha`, `-beta`, `-rc`, `-dev`, `-pre`, `-snapshot`, `-canary`,
453/// `-nightly`), case-insensitively.
454///
455/// This is [`Version::is_prerelease`]'s default heuristic, extracted so an
456/// ecosystem that overrides `is_prerelease` to cover format-specific gaps
457/// (e.g. Composer's `dev-` branch-alias prefix) can extend this baseline
458/// instead of copy-pasting the substring list out of sync with it.
459///
460/// # Examples
461///
462/// ```
463/// use deps_core::has_default_prerelease_marker;
464///
465/// assert!(has_default_prerelease_marker("1.0.0-beta"));
466/// assert!(has_default_prerelease_marker("1.0.0-RC1"));
467/// assert!(!has_default_prerelease_marker("1.0.0"));
468/// ```
469#[must_use]
470pub fn has_default_prerelease_marker(version: &str) -> bool {
471 let v = version.to_lowercase();
472 v.contains("-alpha")
473 || v.contains("-beta")
474 || v.contains("-rc")
475 || v.contains("-dev")
476 || v.contains("-pre")
477 || v.contains("-snapshot")
478 || v.contains("-canary")
479 || v.contains("-nightly")
480}
481
482/// Maps a `404` (plus any status in `also_not_found`) [`DepsError::HttpStatus`] to
483/// [`DepsError::PackageNotFound`], passing through any other error unchanged.
484///
485/// Extracted from four byte-identical copies of this mapper in `deps-pypi`, `deps-deno`,
486/// `deps-npm`, and `deps-go` (deps-lsp#929 item 1). `also_not_found` keeps ecosystem-specific
487/// extra statuses explicit at the call site instead of hardcoded here — `deps-go` passes
488/// `&[410]` for the Go module proxy's "retracted" status, which the other three ecosystems
489/// must not silently inherit.
490///
491/// # Examples
492///
493/// ```
494/// use deps_core::DepsError;
495/// use deps_core::redact::RedactedUrl;
496/// use deps_core::not_found_or;
497///
498/// let url = RedactedUrl::new("https://example.test/pkg");
499/// let not_found = DepsError::HttpStatus {
500/// url: url.clone(),
501/// status: 404,
502/// };
503/// assert!(matches!(
504/// not_found_or(not_found, "pkg", "example", &[]),
505/// DepsError::PackageNotFound { .. }
506/// ));
507///
508/// let server_error = DepsError::HttpStatus { url, status: 500 };
509/// assert!(matches!(
510/// not_found_or(server_error, "pkg", "example", &[]),
511/// DepsError::HttpStatus { status: 500, .. }
512/// ));
513/// ```
514#[must_use]
515pub fn not_found_or(
516 err: DepsError,
517 package: &str,
518 registry: &'static str,
519 also_not_found: &[u16],
520) -> DepsError {
521 match err {
522 DepsError::HttpStatus { status, .. }
523 if status == 404 || also_not_found.contains(&status) =>
524 {
525 DepsError::PackageNotFound {
526 package: package.into(),
527 registry,
528 }
529 }
530 other => other,
531 }
532}
533
534/// Classifies a manifest-declared registry URL against an ecosystem's own default-registry
535/// aliases.
536///
537/// A URL matching one of `default_urls` (ignoring a trailing slash) resolves to
538/// [`DependencySource::Registry`]; anything else has no client this LSP can query, so it
539/// becomes [`DependencySource::CustomRegistry`].
540///
541/// Extracted from byte-identical logic independently added to `deps-bundler` (rubygems.org)
542/// and `deps-dart` (pub.dev, plus its legacy `pub.dartlang.org` alias) in the same change
543/// (deps-lsp#980) — this project's cross-ecosystem-consistency rule treats that pattern as a
544/// bug class rather than an acceptable duplication (see `.claude/CLAUDE.md`, and the #976/#969
545/// precedent of centralizing similarly-shaped per-crate logic into `deps-core`).
546///
547/// # Examples
548///
549/// ```
550/// use deps_core::DependencySource;
551/// use deps_core::classify_default_registry_url;
552///
553/// assert_eq!(
554/// classify_default_registry_url("https://rubygems.org".to_string(), &["https://rubygems.org"]),
555/// DependencySource::Registry
556/// );
557///
558/// // A trailing slash is ignored when comparing against the default list.
559/// assert_eq!(
560/// classify_default_registry_url("https://pub.dev/".to_string(), &["https://pub.dev", "https://pub.dartlang.org"]),
561/// DependencySource::Registry
562/// );
563///
564/// assert_eq!(
565/// classify_default_registry_url("https://gems.mycorp.com".to_string(), &["https://rubygems.org"]),
566/// DependencySource::CustomRegistry { url: "https://gems.mycorp.com".to_string() }
567/// );
568/// ```
569#[must_use]
570pub fn classify_default_registry_url(url: String, default_urls: &[&str]) -> DependencySource {
571 let normalized = url.trim_end_matches('/');
572 if default_urls.contains(&normalized) {
573 DependencySource::Registry
574 } else {
575 DependencySource::CustomRegistry { url }
576 }
577}
578
579/// Hashes an ordered sequence of `&str` routing-hop parts into an opaque
580/// `"{prefix}:{digest:016x}"` chain-identity key, using
581/// [`std::collections::hash_map::DefaultHasher`].
582///
583/// Extracted from three independently hand-rolled copies of this exact pattern
584/// (`deps_pypi::config::ResolvedChain::chain`, `deps_nuget::config::NuGetSourceChain::chain`,
585/// `deps_go::config::GoProxyChain::keyed`, #579) so "never hash a credential" is a property of
586/// one function signature instead of a convention repeated in three doc comments.
587///
588/// Deliberately takes `&str`, not `impl Hash`: a caller with a typed credential wrapper (e.g.
589/// `NuGetAuth`, which deliberately does not derive `Hash` — see its doc) must explicitly
590/// convert it to a string before it can even be considered here, rather than being able to
591/// `.hash()` the whole struct in place. Prefer a small, explicit, non-`Debug` `as_key_str()`-
592/// style accessor on the hashed field's own type over `format!("{value:?}")` at the call
593/// site — chain identity would otherwise silently change if that type's `Debug` output is
594/// ever reworded (e.g. an enum variant rename), even though nothing routing-relevant changed.
595///
596/// **Security invariant**: `parts` must contain only routing-identity data (URLs, slot/hop
597/// kind markers, boolean flags) — **never** credential material. Hashing a credential would
598/// make the chain's identity key change whenever that credential value rotates, defeating
599/// rotation-stability guarantees callers rely on (e.g. NuGet issue #561's FR-016). This
600/// function has no way to enforce that; it is a caller obligation.
601///
602/// # Examples
603///
604/// ```
605/// use deps_core::hash_routing_key;
606///
607/// let key = hash_routing_key("pypi-chain", ["https://example.test/simple/", "true"].into_iter());
608/// assert!(key.starts_with("pypi-chain:"));
609/// assert_eq!(key.len(), "pypi-chain:".len() + 16);
610///
611/// // Same parts, same key — deterministic within a process.
612/// let key2 = hash_routing_key("pypi-chain", ["https://example.test/simple/", "true"].into_iter());
613/// assert_eq!(key, key2);
614///
615/// // Hop order is part of the identity.
616/// let forward = hash_routing_key("go-proxy", ["a", "b"].into_iter());
617/// let reversed = hash_routing_key("go-proxy", ["b", "a"].into_iter());
618/// assert_ne!(forward, reversed);
619/// ```
620#[must_use]
621pub fn hash_routing_key<'a>(prefix: &str, parts: impl Iterator<Item = &'a str>) -> String {
622 use std::hash::{Hash, Hasher};
623
624 let mut hasher = std::collections::hash_map::DefaultHasher::new();
625 for part in parts {
626 part.hash(&mut hasher);
627 }
628 format!("{prefix}:{:016x}", hasher.finish())
629}
630
631/// Outcome of a registry's per-version removal/deprecation signal.
632///
633/// Replaces a bare `is_yanked(): bool`, which could not distinguish a version
634/// a registry has *hard-removed from resolution* (`Yanked`) from one that is
635/// merely flagged as deprecated/abandoned but still fully installable
636/// (`AdvisoryDeprecated`). Conflating the two caused #347: Composer's
637/// package-level `abandoned` flag was read through `is_yanked()`, so every
638/// version of an abandoned-but-installable package was filtered out of
639/// resolution, turning an existing package into a false "Unknown package"
640/// diagnostic.
641///
642/// Call [`blocks_resolution`](Self::blocks_resolution) to decide whether a
643/// version may be selected as an upgrade/latest candidate. Call
644/// [`is_flagged`](Self::is_flagged) only to surface the registry's flag to
645/// the user (e.g. a yanked/deprecated diagnostic) — never to filter
646/// resolution.
647///
648/// # Examples
649///
650/// ```
651/// use deps_core::RemovalStatus;
652///
653/// assert!(!RemovalStatus::Available.blocks_resolution());
654/// assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
655/// assert!(RemovalStatus::Yanked.blocks_resolution());
656///
657/// assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
658/// assert!(RemovalStatus::Yanked.is_flagged());
659/// assert!(!RemovalStatus::Available.is_flagged());
660/// ```
661#[non_exhaustive]
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
663pub enum RemovalStatus {
664 /// The registry reports no removal/deprecation signal for this version.
665 Available,
666 /// The registry flags this version (or its package) as
667 /// deprecated/abandoned, but the version remains fully resolvable and
668 /// installable.
669 AdvisoryDeprecated,
670 /// The registry has hard-removed this version from fresh resolution (a
671 /// real yank/retraction). Existing installs may keep using it, but it
672 /// must not be selected as an upgrade/latest candidate.
673 Yanked,
674}
675
676impl RemovalStatus {
677 /// Whether fresh resolution must skip this version.
678 ///
679 /// `true` only for [`Yanked`](Self::Yanked). An
680 /// [`AdvisoryDeprecated`](Self::AdvisoryDeprecated) version is fully
681 /// installable — excluding it from resolution is what caused #347.
682 ///
683 /// # Examples
684 ///
685 /// ```
686 /// use deps_core::RemovalStatus;
687 ///
688 /// assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
689 /// assert!(RemovalStatus::Yanked.blocks_resolution());
690 /// ```
691 #[must_use]
692 pub const fn blocks_resolution(self) -> bool {
693 matches!(self, Self::Yanked)
694 }
695
696 /// Whether the registry flags this version at all, hard or advisory.
697 ///
698 /// Use this only to decide whether to surface a warning to the user —
699 /// never to filter resolution; use
700 /// [`blocks_resolution`](Self::blocks_resolution) for that.
701 ///
702 /// # Examples
703 ///
704 /// ```
705 /// use deps_core::RemovalStatus;
706 ///
707 /// assert!(!RemovalStatus::Available.is_flagged());
708 /// assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
709 /// assert!(RemovalStatus::Yanked.is_flagged());
710 /// ```
711 #[must_use]
712 pub const fn is_flagged(self) -> bool {
713 !matches!(self, Self::Available)
714 }
715
716 /// Builds a status from a registry's hard yanked/retracted boolean flag.
717 ///
718 /// # Examples
719 ///
720 /// ```
721 /// use deps_core::RemovalStatus;
722 ///
723 /// assert_eq!(RemovalStatus::from_yanked(true), RemovalStatus::Yanked);
724 /// assert_eq!(RemovalStatus::from_yanked(false), RemovalStatus::Available);
725 /// ```
726 #[must_use]
727 pub const fn from_yanked(flag: bool) -> Self {
728 if flag { Self::Yanked } else { Self::Available }
729 }
730
731 /// Builds a status from a registry's advisory deprecated/abandoned boolean flag.
732 ///
733 /// # Examples
734 ///
735 /// ```
736 /// use deps_core::RemovalStatus;
737 ///
738 /// assert_eq!(RemovalStatus::from_advisory(true), RemovalStatus::AdvisoryDeprecated);
739 /// assert_eq!(RemovalStatus::from_advisory(false), RemovalStatus::Available);
740 /// ```
741 #[must_use]
742 pub const fn from_advisory(flag: bool) -> Self {
743 if flag {
744 Self::AdvisoryDeprecated
745 } else {
746 Self::Available
747 }
748 }
749}
750
751/// Package-level deprecation/abandonment payload: why a package is deprecated, and what
752/// to use instead.
753///
754/// Distinct from [`RemovalStatus`], which answers *"may resolution select this
755/// version"*. `Deprecation` answers *"what should the user be told"* — the reason text
756/// and/or a registry-supplied replacement package name. Kept as a separate type rather
757/// than a payload-carrying `RemovalStatus` variant: `RemovalStatus` is `Copy`, returned
758/// from `const fn`s, and matched in every ecosystem crate, so widening it would ripple
759/// everywhere for a concept that only a couple of registries expose.
760///
761/// Both fields are `None` when the registry has nothing to say beyond "deprecated" —
762/// e.g. Packagist's bare `"abandoned": true`, with no replacement named.
763///
764/// # Examples
765///
766/// ```
767/// use deps_core::Deprecation;
768///
769/// let deprecation = Deprecation {
770/// reason: Some("no longer maintained".into()),
771/// replacement: Some("some-other/package".into()),
772/// };
773///
774/// assert_eq!(deprecation.reason.as_deref(), Some("no longer maintained"));
775/// assert_eq!(deprecation.replacement.as_deref(), Some("some-other/package"));
776/// ```
777// Exhaustive, not `#[non_exhaustive]`: 25+ existing struct-literal sites across `deps-npm`,
778// `deps-composer`, and `deps-lsp` already build this by field, not worth migrating for a
779// two-field type (issue #755).
780#[derive(Debug, Clone, PartialEq, Eq)]
781pub struct Deprecation {
782 /// Free-text reason the registry gives for the deprecation, if any.
783 pub reason: Option<String>,
784 /// Registry-supplied replacement package name, if any. Only ever populated from a
785 /// structured registry field (never regex-extracted from free text — see #205's
786 /// typosquatting rationale), so it is safe to offer as a rename target.
787 pub replacement: Option<String>,
788}
789
790/// Version information trait.
791///
792/// All version types must implement this to work with generic handlers.
793pub trait Version: Send + Sync {
794 /// Version string (e.g., "1.0.214", "14.21.3").
795 fn version_string(&self) -> &ConcreteVersion;
796
797 /// This version's removal/deprecation status as reported by the registry.
798 ///
799 /// Default: [`RemovalStatus::Available`] — ecosystems with no
800 /// yank/deprecation signal need no override.
801 fn removal_status(&self) -> RemovalStatus {
802 RemovalStatus::Available
803 }
804
805 /// Package-level deprecation payload, when this version's registry data carries one.
806 ///
807 /// Default `None` — the `Option` is the capability gate: an ecosystem that never
808 /// overrides this produces no deprecation diagnostic/hover/quickfix at all, rather
809 /// than depending on a separate "does this ecosystem report deprecation" predicate.
810 fn deprecation(&self) -> Option<&Deprecation> {
811 None
812 }
813
814 /// Whether this version is a pre-release (alpha, beta, rc, etc.).
815 ///
816 /// Default implementation checks for common pre-release patterns via
817 /// [`has_default_prerelease_marker`].
818 fn is_prerelease(&self) -> bool {
819 has_default_prerelease_marker(self.version_string().as_str())
820 }
821
822 /// Available feature flags (empty if not supported by ecosystem).
823 fn features(&self) -> Vec<String> {
824 vec![]
825 }
826
827 /// Downcast to concrete version type
828 fn as_any(&self) -> &dyn Any;
829
830 /// Whether this version is stable: not hard-yanked and not a pre-release.
831 ///
832 /// An [`AdvisoryDeprecated`](RemovalStatus::AdvisoryDeprecated) version counts as
833 /// stable here — it remains fully resolvable, just flagged. Only
834 /// [`Yanked`](RemovalStatus::Yanked) disqualifies a version; see
835 /// [`RemovalStatus::blocks_resolution`].
836 fn is_stable(&self) -> bool {
837 !self.removal_status().blocks_resolution() && !self.is_prerelease()
838 }
839
840 /// When this version was published, if the registry exposes it.
841 ///
842 /// Default `None` — ecosystems without publish metadata (or where
843 /// fetching it would add a network round trip) degrade to pre-feature
844 /// behavior: no freshness signal shown, no error, no change in ranking.
845 fn published_at(&self) -> Option<crate::freshness::PublishTime> {
846 None
847 }
848
849 /// SPDX license identifier(s) declared for this version, when the registry's
850 /// already-fetched version-list response carries them.
851 ///
852 /// Default: empty slice — the capability gate for ecosystems whose hot-path
853 /// version fetch doesn't carry license data (issue #204). `Vec<String>`, never
854 /// `Option<String>`, everywhere this is implemented: a package can declare more
855 /// than one license (an SPDX `OR`/dual-license split reported as discrete
856 /// identifiers by the registry), and a single shape avoids an `Option` vs `Vec`
857 /// split across ecosystems (spec 010 plan.md's "License shape" decision). An
858 /// empty slice means "unknown/not reported", never "explicitly no license".
859 fn license(&self) -> &[String] {
860 &[]
861 }
862}
863
864/// Finds the latest stable version from a list of versions.
865///
866/// Returns the first version that is:
867/// - Not hard-yanked ([`RemovalStatus::Yanked`]) — an
868/// [`AdvisoryDeprecated`](RemovalStatus::AdvisoryDeprecated) version still counts as
869/// stable, since it remains fully resolvable
870/// - Not a pre-release (alpha, beta, rc, etc.)
871///
872/// Assumes versions are sorted newest-first (as returned by registries).
873///
874/// # Examples
875///
876/// ```
877/// use deps_core::registry::{RemovalStatus, Version, find_latest_stable};
878/// use deps_core::ConcreteVersion;
879/// use std::any::Any;
880///
881/// struct MyVersion { version: ConcreteVersion, yanked: bool }
882///
883/// impl Version for MyVersion {
884/// fn version_string(&self) -> &ConcreteVersion { &self.version }
885/// fn removal_status(&self) -> RemovalStatus { RemovalStatus::from_yanked(self.yanked) }
886/// fn as_any(&self) -> &dyn Any { self }
887/// }
888///
889/// let versions: Vec<Box<dyn Version>> = vec![
890/// Box::new(MyVersion { version: "2.0.0-alpha.1".into(), yanked: false }),
891/// Box::new(MyVersion { version: "1.5.0".into(), yanked: true }),
892/// Box::new(MyVersion { version: "1.4.0".into(), yanked: false }),
893/// ];
894///
895/// let latest = find_latest_stable(&versions);
896/// assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.4.0"));
897/// ```
898pub fn find_latest_stable(versions: &[Box<dyn Version>]) -> Option<&dyn Version> {
899 versions.iter().find(|v| v.is_stable()).map(|v| v.as_ref())
900}
901
902/// Whether `req` is a wildcard/empty requirement (`""` or `"*"`, ignoring surrounding
903/// whitespace) rather than a concrete version constraint.
904///
905/// A wildcard requirement turns [`get_latest_matching`](Registry::get_latest_matching) and
906/// [`select_latest_matching`](Registry::select_latest_matching) into an *existence* check
907/// ("does this package exist / what is its newest version for display purposes") instead of
908/// an upgrade recommendation — see [`select_latest_for_existence`], which callers must gate
909/// on this function before use.
910///
911/// # Examples
912///
913/// ```
914/// use deps_core::{VersionReq, is_existence_wildcard};
915///
916/// assert!(is_existence_wildcard(&VersionReq::new("*")));
917/// assert!(is_existence_wildcard(&VersionReq::new("")));
918/// assert!(!is_existence_wildcard(&VersionReq::new("^1.2")));
919/// ```
920#[must_use]
921pub fn is_existence_wildcard(req: &crate::VersionReq) -> bool {
922 is_existence_wildcard_str(req.as_str())
923}
924
925/// [`is_existence_wildcard`] for a raw `&str`, without allocating a [`VersionReq`] wrapper.
926///
927/// Exists for callers that hold a version requirement as `&str` rather than a `VersionReq`
928/// (e.g. an inherent method taking `req_str: &str` for its own parsing needs) — going through
929/// [`is_existence_wildcard`] there would allocate a `String` via `VersionReq::new` on every
930/// call just to check it.
931///
932/// # Examples
933///
934/// ```
935/// use deps_core::registry::is_existence_wildcard_str;
936///
937/// assert!(is_existence_wildcard_str("*"));
938/// assert!(is_existence_wildcard_str(""));
939/// assert!(!is_existence_wildcard_str("^1.2"));
940/// ```
941#[must_use]
942pub fn is_existence_wildcard_str(req: &str) -> bool {
943 matches!(req.trim(), "" | "*")
944}
945
946/// Builds the canonical existence-check requirement (`"*"`) [`is_existence_wildcard`] recognizes.
947///
948/// [`Registry::select_latest_matching`] callers pass this when they want "the newest version
949/// for display purposes" rather than an upgrade recommendation against a real constraint.
950/// Centralizes what was three independent `VersionReq::new("*")` call sites (hover, version
951/// completion, code-action quick-fixes) all constructing the identical value for the
952/// identical purpose.
953///
954/// # Examples
955///
956/// ```
957/// use deps_core::{existence_wildcard_req, is_existence_wildcard};
958///
959/// assert!(is_existence_wildcard(&existence_wildcard_req()));
960/// ```
961#[must_use]
962pub fn existence_wildcard_req() -> crate::VersionReq {
963 crate::VersionReq::new("*")
964}
965
966/// Index of the version an *existence* check should report as "latest", ignoring `req`
967/// entirely.
968///
969/// This is the shared 3-rung fallback ladder used under a wildcard/empty requirement, where
970/// every version satisfies by definition and the question is only which one to prefer for
971/// display:
972///
973/// 1. The newest version that is neither flagged
974/// ([`RemovalStatus::is_flagged`]) nor a pre-release ([`Version::is_prerelease`]).
975/// 2. Else, the newest version that does not block resolution
976/// ([`RemovalStatus::blocks_resolution`]) — an `AdvisoryDeprecated` version counts here.
977/// 3. Else, index `0` unconditionally — the newest version overall, however it is flagged.
978/// A yanked-or-prerelease-only package still exists; this rung is what turns that case
979/// into "here is its newest version" instead of a false "Unknown package" (#347, #364).
980///
981/// `versions` must be sorted newest-first, as returned by [`Registry::get_versions`]. Returns
982/// `None` only when `versions` is empty.
983///
984/// # Requirement-blindness is deliberate and dangerous
985///
986/// This function takes no `req` parameter and does not check whether the caller is under a
987/// wildcard requirement — it always returns rung 3 as a last resort, regardless of what a
988/// concrete requirement might demand. It is **only correct once the caller has already
989/// confirmed the requirement is a wildcard** via [`is_existence_wildcard`]. Calling it
990/// ungated — e.g. under a concrete `^1.2` requirement — can return a version that does not
991/// satisfy that requirement at all, silently corrupting upgrade resolution.
992///
993/// # Examples
994///
995/// ```
996/// use deps_core::registry::{RemovalStatus, Version, select_latest_for_existence};
997/// use deps_core::ConcreteVersion;
998/// use std::any::Any;
999///
1000/// struct MyVersion { version: ConcreteVersion, status: RemovalStatus, prerelease: bool }
1001///
1002/// impl Version for MyVersion {
1003/// fn version_string(&self) -> &ConcreteVersion { &self.version }
1004/// fn removal_status(&self) -> RemovalStatus { self.status }
1005/// fn is_prerelease(&self) -> bool { self.prerelease }
1006/// fn as_any(&self) -> &dyn Any { self }
1007/// }
1008///
1009/// // Newest version is yanked; rung 3 still returns it rather than `None`.
1010/// let versions = vec![
1011/// MyVersion { version: "2.0.0".into(), status: RemovalStatus::Yanked, prerelease: false },
1012/// MyVersion { version: "1.5.0".into(), status: RemovalStatus::Yanked, prerelease: false },
1013/// ];
1014///
1015/// let idx = select_latest_for_existence(&versions, |v| v as &dyn Version);
1016/// assert_eq!(idx, Some(0));
1017///
1018/// assert_eq!(select_latest_for_existence::<MyVersion>(&[], |v| v as &dyn Version), None);
1019/// ```
1020#[must_use]
1021pub fn select_latest_for_existence<T>(
1022 versions: &[T],
1023 as_version: impl Fn(&T) -> &dyn Version,
1024) -> Option<usize> {
1025 if versions.is_empty() {
1026 return None;
1027 }
1028 Some(
1029 versions
1030 .iter()
1031 .position(|v| {
1032 let v = as_version(v);
1033 !v.removal_status().is_flagged() && !v.is_prerelease()
1034 })
1035 .or_else(|| {
1036 versions
1037 .iter()
1038 .position(|v| !as_version(v).removal_status().blocks_resolution())
1039 })
1040 .unwrap_or(0),
1041 )
1042}
1043
1044/// Package metadata trait.
1045///
1046/// Used for completion items and hover documentation.
1047pub trait Metadata: Send + Sync {
1048 /// The package name as the *registry* reports it.
1049 ///
1050 /// This is the identifier the registry displays and that completion
1051 /// pastes into a manifest. It is not guaranteed byte-identical to the
1052 /// manifest-declared [`Dependency::name`](crate::ecosystem::Dependency::name)
1053 /// for the same package: casing may differ (NuGet, Composer, Swift), and
1054 /// for Maven/Gradle it is a `"group:artifact"` value synthesized from two
1055 /// separate response fields. Do not compare it to a manifest name
1056 /// without going through
1057 /// [`PackageNaming::normalize_package_name`](crate::lsp_helpers::PackageNaming::normalize_package_name).
1058 fn name(&self) -> &crate::PackageName;
1059
1060 /// Short description (optional).
1061 fn description(&self) -> Option<&str>;
1062
1063 /// Repository URL (optional).
1064 fn repository(&self) -> Option<&str>;
1065
1066 /// Documentation URL (optional).
1067 fn documentation(&self) -> Option<&str>;
1068
1069 /// Latest stable version.
1070 fn latest_version(&self) -> &ConcreteVersion;
1071
1072 /// Downcast to concrete metadata type
1073 fn as_any(&self) -> &dyn Any;
1074}
1075
1076/// Shared capacity ceiling for a per-ecosystem alternate-registry client map.
1077///
1078/// Reached via each ecosystem's own `register_alternate` (`Cargo`, `npm`, `PyPI`, `NuGet`,
1079/// `Go`, #976). Once reached, a not-yet-registered key is refused rather than evicting an
1080/// existing, possibly still-in-use, client.
1081pub const MAX_ALTERNATE_REGISTRIES: usize = 256;
1082
1083/// How a [`register_capped`]/[`register_capped_with_occupied`] key must be rendered in the
1084/// cap-reached log line (#969 S3).
1085///
1086/// A URL-shaped key is redacted via [`crate::redact::RedactedUrl`]; an already-opaque
1087/// token (e.g. a [`hash_routing_key`] digest) is logged verbatim, since `RedactedUrl`'s
1088/// Maven-coordinate/REST-path false-positive scan would otherwise collapse every such key —
1089/// which is exactly one colon plus a hex digest, the shape that scan is designed to catch —
1090/// to an identical, information-free `"<prefix>:***"`, destroying the very correlation handle
1091/// the log line exists to provide. A closed, two-variant choice rather than an open `Display`
1092/// bypass: a call site declares which shape its key is, but cannot smuggle an unredacted URL
1093/// through under the `Opaque` label by accident in the type system's own vocabulary — though
1094/// nothing stops a caller from mislabeling one at the value level, so get this right: labeling
1095/// a URL-shaped key `Opaque` leaks any query-string credential it carries (the dangerous
1096/// direction); labeling an opaque token `Url` only over-redacts a value that had no secret to
1097/// begin with (the safe direction).
1098#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1099pub enum KeyShape {
1100 /// Redact via [`crate::redact::RedactedUrl`] before logging.
1101 Url,
1102 /// Log verbatim — this value is known to never carry a credential (e.g. a
1103 /// [`hash_routing_key`] digest, or another fixed, non-URL literal).
1104 Opaque,
1105}
1106
1107/// Outcome of a [`register_capped`]/[`register_capped_with_occupied`] call.
1108///
1109/// `#[non_exhaustive]`: this is a result-shaped enum (introduced in #1218), and a distinct
1110/// outcome for a new capacity-related edge case is a realistic future addition — e.g.
1111/// distinguishing an eviction from a refusal, should this map ever gain eviction. No
1112/// consumer outside this workspace depends on it yet, making now the cheapest point to
1113/// mark it non-exhaustive; doing so later, once external crates match on it, would itself
1114/// be a breaking change (part of #1226).
1115#[non_exhaustive]
1116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1117pub enum CapResult {
1118 /// `make()` ran and its value was inserted (the `Vacant`, under-capacity arm).
1119 Inserted,
1120 /// `key` was already registered; the existing value stuck (`on_occupied` ran, if any).
1121 AlreadyPresent,
1122 /// `key` was absent but the map was at [`MAX_ALTERNATE_REGISTRIES`]; nothing was
1123 /// inserted and a cap-reached `warn!` was emitted.
1124 RefusedAtCapacity,
1125}
1126
1127/// Registers `key` in a capacity-bounded alternate-client map, refusing (never evicting)
1128/// once full.
1129///
1130/// A no-op when `key` is already registered — the first successful registration for a given
1131/// key sticks. A no-op, with a `tracing::warn!`, once [`MAX_ALTERNATE_REGISTRIES`] is reached
1132/// and `key` is not already present.
1133///
1134/// Reads `map.len()` **before** calling `entry()`: `DashMap::len` read-locks every shard,
1135/// while `entry()` holds a write guard on one — checking capacity from inside the `Vacant`
1136/// arm would self-deadlock on that shard.
1137///
1138/// `ecosystem` names the caller in the cap-reached log line.
1139/// `key_shape` picks how `key` is rendered in that same line — see [`KeyShape`].
1140///
1141/// Returns [`CapResult::Inserted`] when `make()` was inserted, [`CapResult::AlreadyPresent`]
1142/// when `key` was already occupied, or [`CapResult::RefusedAtCapacity`] when the map was at
1143/// capacity.
1144///
1145/// # Examples
1146///
1147/// ```
1148/// use dashmap::DashMap;
1149/// use deps_core::EcosystemId;
1150/// use deps_core::registry::{CapResult, KeyShape, register_capped};
1151///
1152/// let map: DashMap<String, u32> = DashMap::new();
1153/// assert_eq!(
1154/// register_capped(&map, "a".to_string(), EcosystemId::Cargo, KeyShape::Opaque, || 1),
1155/// CapResult::Inserted
1156/// );
1157/// assert_eq!(
1158/// register_capped(&map, "a".to_string(), EcosystemId::Cargo, KeyShape::Opaque, || 2),
1159/// CapResult::AlreadyPresent
1160/// );
1161/// assert_eq!(*map.get("a").unwrap(), 1);
1162/// ```
1163pub fn register_capped<K, V>(
1164 map: &DashMap<K, V>,
1165 key: K,
1166 ecosystem: EcosystemId,
1167 key_shape: KeyShape,
1168 make: impl FnOnce() -> V,
1169) -> CapResult
1170where
1171 K: Eq + Hash + AsRef<str>,
1172{
1173 register_capped_with_occupied(map, key, ecosystem, key_shape, make, |_| {})
1174}
1175
1176/// [`register_capped`], plus an `on_occupied` callback for an already-registered key.
1177///
1178/// Lets an ecosystem still act on that key — folding a re-registration's trust tier
1179/// (`deps-cargo`) or rebuilding a client whose credential digest changed (`deps-nuget`).
1180///
1181/// `on_occupied` runs instead of `make`/the capacity check, never gated by
1182/// [`MAX_ALTERNATE_REGISTRIES`] — replacing an already-occupied slot does not grow the map,
1183/// so gating it would strand a legitimate in-place update (e.g. a rotated credential) once
1184/// the cap is hit for unrelated keys.
1185///
1186/// Returns [`CapResult::Inserted`] when `make()` was inserted (the `Vacant` arm),
1187/// [`CapResult::AlreadyPresent`] or [`CapResult::RefusedAtCapacity`] otherwise —
1188/// `on_occupied` signals its own outcome (e.g. via `tracing::warn!`) rather than through this
1189/// return value.
1190///
1191/// # Examples
1192///
1193/// ```
1194/// use dashmap::DashMap;
1195/// use deps_core::EcosystemId;
1196/// use deps_core::registry::{CapResult, KeyShape, register_capped_with_occupied};
1197///
1198/// let map: DashMap<String, u32> = DashMap::new();
1199/// register_capped_with_occupied(
1200/// &map,
1201/// "a".to_string(),
1202/// EcosystemId::Cargo,
1203/// KeyShape::Opaque,
1204/// || 1,
1205/// |_| {},
1206/// );
1207/// let outcome = register_capped_with_occupied(
1208/// &map,
1209/// "a".to_string(),
1210/// EcosystemId::Cargo,
1211/// KeyShape::Opaque,
1212/// || 1,
1213/// |v| *v += 10,
1214/// );
1215/// assert_eq!(outcome, CapResult::AlreadyPresent);
1216/// assert_eq!(*map.get("a").unwrap(), 11);
1217/// ```
1218pub fn register_capped_with_occupied<K, V>(
1219 map: &DashMap<K, V>,
1220 key: K,
1221 ecosystem: EcosystemId,
1222 key_shape: KeyShape,
1223 make: impl FnOnce() -> V,
1224 on_occupied: impl FnOnce(&mut V),
1225) -> CapResult
1226where
1227 K: Eq + Hash + AsRef<str>,
1228{
1229 let at_capacity = map.len() >= MAX_ALTERNATE_REGISTRIES;
1230
1231 match map.entry(key) {
1232 Entry::Occupied(mut slot) => {
1233 on_occupied(slot.get_mut());
1234 CapResult::AlreadyPresent
1235 }
1236 Entry::Vacant(slot) => {
1237 if at_capacity {
1238 // `slot.key()` reads the key back out of the entry rather than requiring a
1239 // borrow held across the `entry()` call above, so the (possibly allocating)
1240 // redaction work below stays entirely off the far more common
1241 // successful-registration path.
1242 let key_ref = slot.key().as_ref();
1243 let rendered_key = match key_shape {
1244 KeyShape::Url => crate::redact::RedactedUrl::new(key_ref).into_inner(),
1245 KeyShape::Opaque => key_ref.to_string(),
1246 };
1247 tracing::warn!(
1248 key = %rendered_key,
1249 cap = MAX_ALTERNATE_REGISTRIES,
1250 %ecosystem,
1251 "{ecosystem} alternate registry cap reached; not registering a new entry"
1252 );
1253 return CapResult::RefusedAtCapacity;
1254 }
1255 slot.insert(make());
1256 CapResult::Inserted
1257 }
1258 }
1259}
1260
1261#[cfg(test)]
1262mod tests {
1263 use super::*;
1264
1265 use crate::test_util::MockVersion;
1266
1267 /// A [`Registry`] whose `search_raw` records whether it was called, for
1268 /// [`dyn_registry_search_rejects_credential_bearing_query_before_calling_search_raw`]
1269 /// (#1215) — mirrors `conformance.rs`'s `AlwaysHasResultsRegistry::search_was_called`.
1270 struct ProbeRegistry {
1271 search_raw_called: std::sync::atomic::AtomicBool,
1272 }
1273
1274 impl ProbeRegistry {
1275 fn new() -> Self {
1276 Self {
1277 search_raw_called: std::sync::atomic::AtomicBool::new(false),
1278 }
1279 }
1280
1281 fn search_raw_was_called(&self) -> bool {
1282 self.search_raw_called
1283 .load(std::sync::atomic::Ordering::SeqCst)
1284 }
1285 }
1286
1287 impl Registry for ProbeRegistry {
1288 fn get_versions<'a>(
1289 &'a self,
1290 _name: &'a PackageName,
1291 ) -> BoxFuture<'a, Result<Vec<Box<dyn Version>>>> {
1292 Box::pin(async move { Ok(vec![]) })
1293 }
1294
1295 fn get_latest_matching<'a>(
1296 &'a self,
1297 _name: &'a PackageName,
1298 _req: &'a VersionReq,
1299 _selection_context: &'a crate::SelectionContext,
1300 ) -> BoxFuture<'a, Result<Option<Box<dyn Version>>>> {
1301 Box::pin(async move { Ok(None) })
1302 }
1303
1304 fn search_raw<'a>(
1305 &'a self,
1306 _query: &'a str,
1307 _limit: usize,
1308 ) -> BoxFuture<'a, Result<Vec<Box<dyn Metadata>>>> {
1309 self.search_raw_called
1310 .store(true, std::sync::atomic::Ordering::SeqCst);
1311 Box::pin(async move {
1312 let metadata = crate::test_util::MockMetadata::new("probe", "1.0.0");
1313 Ok(vec![Box::new(metadata) as Box<dyn Metadata>])
1314 })
1315 }
1316
1317 fn as_any(&self) -> &dyn Any {
1318 self
1319 }
1320 }
1321
1322 #[tokio::test]
1323 async fn dyn_registry_search_rejects_credential_bearing_query_before_calling_search_raw() {
1324 let probe = ProbeRegistry::new();
1325 let registry: &dyn Registry = &probe;
1326
1327 let results = registry
1328 .search("user:hunter2@registry.example", 10)
1329 .await
1330 .unwrap();
1331
1332 assert!(
1333 results.is_empty(),
1334 "a credential-shaped query must return no results"
1335 );
1336 assert!(
1337 !probe.search_raw_was_called(),
1338 "a credential-shaped query must be rejected before ever calling `search_raw` \
1339 (issue #1215) — redacting the value and searching anyway, then dropping the \
1340 result, would still satisfy the empty-result assertion above but fail this one"
1341 );
1342 }
1343
1344 #[tokio::test]
1345 async fn dyn_registry_search_delegates_to_search_raw_for_a_safe_query() {
1346 let probe = ProbeRegistry::new();
1347 let registry: &dyn Registry = &probe;
1348
1349 let results = registry.search("serde", 10).await.unwrap();
1350
1351 assert!(
1352 !results.is_empty(),
1353 "a safe query must reach `search_raw` and return its result"
1354 );
1355 assert!(probe.search_raw_was_called());
1356 }
1357
1358 #[test]
1359 fn test_version_default_features() {
1360 let version = MockVersion::new("1.0.0").yanked(false);
1361
1362 assert_eq!(version.features(), Vec::<String>::new());
1363 }
1364
1365 #[test]
1366 fn test_version_trait_object() {
1367 let version = MockVersion::new("1.2.3").yanked(false);
1368
1369 let boxed: Box<dyn Version> = Box::new(version);
1370 assert_eq!(boxed.version_string().as_str(), "1.2.3");
1371 assert!(!boxed.removal_status().blocks_resolution());
1372 }
1373
1374 #[test]
1375 fn test_version_downcast() {
1376 let version = MockVersion::new("1.0.0").yanked(true);
1377
1378 let boxed: Box<dyn Version> = Box::new(version);
1379 let any = boxed.as_any();
1380
1381 assert!(any.is::<MockVersion>());
1382 }
1383
1384 struct MockMetadata {
1385 name: crate::PackageName,
1386 latest: ConcreteVersion,
1387 }
1388
1389 impl Metadata for MockMetadata {
1390 fn name(&self) -> &crate::PackageName {
1391 &self.name
1392 }
1393
1394 fn description(&self) -> Option<&str> {
1395 None
1396 }
1397
1398 fn repository(&self) -> Option<&str> {
1399 None
1400 }
1401
1402 fn documentation(&self) -> Option<&str> {
1403 None
1404 }
1405
1406 fn latest_version(&self) -> &ConcreteVersion {
1407 &self.latest
1408 }
1409
1410 fn as_any(&self) -> &dyn Any {
1411 self
1412 }
1413 }
1414
1415 #[test]
1416 fn test_metadata_trait_object() {
1417 let metadata = MockMetadata {
1418 name: crate::PackageName::new("test-package"),
1419 latest: "2.0.0".into(),
1420 };
1421
1422 let boxed: Box<dyn Metadata> = Box::new(metadata);
1423 assert_eq!(boxed.name(), "test-package");
1424 assert_eq!(boxed.latest_version().as_str(), "2.0.0");
1425 assert!(boxed.description().is_none());
1426 assert!(boxed.repository().is_none());
1427 assert!(boxed.documentation().is_none());
1428 }
1429
1430 #[test]
1431 fn test_metadata_with_full_info() {
1432 struct FullMetadata {
1433 name: crate::PackageName,
1434 desc: String,
1435 repo: String,
1436 docs: String,
1437 latest: ConcreteVersion,
1438 }
1439
1440 impl Metadata for FullMetadata {
1441 fn name(&self) -> &crate::PackageName {
1442 &self.name
1443 }
1444 fn description(&self) -> Option<&str> {
1445 Some(&self.desc)
1446 }
1447 fn repository(&self) -> Option<&str> {
1448 Some(&self.repo)
1449 }
1450 fn documentation(&self) -> Option<&str> {
1451 Some(&self.docs)
1452 }
1453 fn latest_version(&self) -> &ConcreteVersion {
1454 &self.latest
1455 }
1456 fn as_any(&self) -> &dyn Any {
1457 self
1458 }
1459 }
1460
1461 let meta = FullMetadata {
1462 name: crate::PackageName::new("serde"),
1463 desc: "Serialization framework".into(),
1464 repo: "https://github.com/serde-rs/serde".into(),
1465 docs: "https://docs.rs/serde".into(),
1466 latest: "1.0.214".into(),
1467 };
1468
1469 assert_eq!(meta.description(), Some("Serialization framework"));
1470 assert_eq!(meta.repository(), Some("https://github.com/serde-rs/serde"));
1471 assert_eq!(meta.documentation(), Some("https://docs.rs/serde"));
1472 }
1473
1474 #[test]
1475 fn test_is_prerelease_alpha() {
1476 let version = MockVersion::new("4.0.0-alpha.13").yanked(false);
1477 assert!(version.is_prerelease());
1478 }
1479
1480 #[test]
1481 fn test_is_prerelease_beta() {
1482 let version = MockVersion::new("2.0.0-beta.1").yanked(false);
1483 assert!(version.is_prerelease());
1484 }
1485
1486 #[test]
1487 fn test_is_prerelease_rc() {
1488 let version = MockVersion::new("1.5.0-rc.2").yanked(false);
1489 assert!(version.is_prerelease());
1490 }
1491
1492 #[test]
1493 fn test_is_prerelease_dev() {
1494 let version = MockVersion::new("3.0.0-dev").yanked(false);
1495 assert!(version.is_prerelease());
1496 }
1497
1498 #[test]
1499 fn test_is_prerelease_canary() {
1500 let version = MockVersion::new("5.0.0-canary").yanked(false);
1501 assert!(version.is_prerelease());
1502 }
1503
1504 #[test]
1505 fn test_is_prerelease_nightly() {
1506 let version = MockVersion::new("6.0.0-nightly").yanked(false);
1507 assert!(version.is_prerelease());
1508 }
1509
1510 #[test]
1511 fn test_is_not_prerelease_stable() {
1512 let version = MockVersion::new("1.2.3").yanked(false);
1513 assert!(!version.is_prerelease());
1514 }
1515
1516 #[test]
1517 fn test_is_not_prerelease_patch() {
1518 let version = MockVersion::new("1.0.214").yanked(false);
1519 assert!(!version.is_prerelease());
1520 }
1521
1522 #[test]
1523 fn test_is_stable_true() {
1524 let version = MockVersion::new("1.0.0").yanked(false);
1525 assert!(version.is_stable());
1526 }
1527
1528 #[test]
1529 fn test_is_stable_false_yanked() {
1530 let version = MockVersion::new("1.0.0").yanked(true);
1531 assert!(!version.is_stable());
1532 }
1533
1534 #[test]
1535 fn test_is_stable_false_prerelease() {
1536 let version = MockVersion::new("1.0.0-alpha.1").yanked(false);
1537 assert!(!version.is_stable());
1538 }
1539
1540 #[test]
1541 fn test_find_latest_stable_skips_prerelease() {
1542 let versions: Vec<Box<dyn Version>> = vec![
1543 Box::new(MockVersion::new("2.0.0-alpha.1").yanked(false)),
1544 Box::new(MockVersion::new("1.5.0").yanked(false)),
1545 ];
1546 let latest = super::find_latest_stable(&versions);
1547 assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1548 }
1549
1550 #[test]
1551 fn test_find_latest_stable_skips_yanked() {
1552 let versions: Vec<Box<dyn Version>> = vec![
1553 Box::new(MockVersion::new("2.0.0").yanked(true)),
1554 Box::new(MockVersion::new("1.5.0").yanked(false)),
1555 ];
1556 let latest = super::find_latest_stable(&versions);
1557 assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1558 }
1559
1560 #[test]
1561 fn test_find_latest_stable_returns_first_stable() {
1562 let versions: Vec<Box<dyn Version>> = vec![
1563 Box::new(MockVersion::new("3.0.0-beta.1").yanked(false)),
1564 Box::new(MockVersion::new("2.0.0").yanked(true)),
1565 Box::new(MockVersion::new("1.5.0").yanked(false)),
1566 Box::new(MockVersion::new("1.4.0").yanked(false)),
1567 ];
1568 let latest = super::find_latest_stable(&versions);
1569 assert_eq!(latest.map(|v| v.version_string().as_str()), Some("1.5.0"));
1570 }
1571
1572 #[test]
1573 fn test_find_latest_stable_empty_list() {
1574 let versions: Vec<Box<dyn Version>> = vec![];
1575 let latest = super::find_latest_stable(&versions);
1576 assert!(latest.is_none());
1577 }
1578
1579 #[test]
1580 fn test_find_latest_stable_no_stable_versions() {
1581 let versions: Vec<Box<dyn Version>> = vec![
1582 Box::new(MockVersion::new("2.0.0-alpha.1").yanked(false)),
1583 Box::new(MockVersion::new("1.0.0").yanked(true)),
1584 ];
1585 let latest = super::find_latest_stable(&versions);
1586 assert!(latest.is_none());
1587 }
1588
1589 struct StatusVersion {
1590 version: ConcreteVersion,
1591 status: RemovalStatus,
1592 }
1593
1594 impl Version for StatusVersion {
1595 fn version_string(&self) -> &ConcreteVersion {
1596 &self.version
1597 }
1598
1599 fn removal_status(&self) -> RemovalStatus {
1600 self.status
1601 }
1602
1603 fn as_any(&self) -> &dyn Any {
1604 self
1605 }
1606 }
1607
1608 #[test]
1609 fn test_is_stable_true_for_advisory_deprecated() {
1610 let version = StatusVersion {
1611 version: "1.0.0".into(),
1612 status: RemovalStatus::AdvisoryDeprecated,
1613 };
1614 assert!(version.is_stable());
1615 }
1616
1617 #[test]
1618 fn test_is_stable_false_for_yanked() {
1619 let version = StatusVersion {
1620 version: "1.0.0".into(),
1621 status: RemovalStatus::Yanked,
1622 };
1623 assert!(!version.is_stable());
1624 }
1625
1626 #[test]
1627 fn test_removal_status_blocks_resolution() {
1628 assert!(!RemovalStatus::Available.blocks_resolution());
1629 assert!(!RemovalStatus::AdvisoryDeprecated.blocks_resolution());
1630 assert!(RemovalStatus::Yanked.blocks_resolution());
1631 }
1632
1633 #[test]
1634 fn test_removal_status_is_flagged() {
1635 assert!(!RemovalStatus::Available.is_flagged());
1636 assert!(RemovalStatus::AdvisoryDeprecated.is_flagged());
1637 assert!(RemovalStatus::Yanked.is_flagged());
1638 }
1639
1640 #[test]
1641 fn test_removal_status_from_yanked() {
1642 assert_eq!(RemovalStatus::from_yanked(true), RemovalStatus::Yanked);
1643 assert_eq!(RemovalStatus::from_yanked(false), RemovalStatus::Available);
1644 }
1645
1646 #[test]
1647 fn test_removal_status_from_advisory() {
1648 assert_eq!(
1649 RemovalStatus::from_advisory(true),
1650 RemovalStatus::AdvisoryDeprecated
1651 );
1652 assert_eq!(
1653 RemovalStatus::from_advisory(false),
1654 RemovalStatus::Available
1655 );
1656 }
1657
1658 #[test]
1659 fn test_hash_routing_key_format() {
1660 let key = hash_routing_key("pypi-chain", ["https://example.test/simple/"].into_iter());
1661 assert!(key.starts_with("pypi-chain:"));
1662 assert_eq!(key.len(), "pypi-chain:".len() + 16);
1663 }
1664
1665 #[test]
1666 fn test_hash_routing_key_deterministic() {
1667 let parts = || ["https://a.test/", "https://b.test/", "true"].into_iter();
1668 assert_eq!(
1669 hash_routing_key("go-proxy", parts()),
1670 hash_routing_key("go-proxy", parts())
1671 );
1672 }
1673
1674 #[test]
1675 fn test_hash_routing_key_distinguishes_prefix() {
1676 let parts = || ["same"].into_iter();
1677 assert_ne!(
1678 hash_routing_key("pypi-chain", parts()),
1679 hash_routing_key("nuget-chain", parts())
1680 );
1681 }
1682
1683 #[test]
1684 fn test_hash_routing_key_distinguishes_parts() {
1685 assert_ne!(
1686 hash_routing_key("chain", ["a", "b"].into_iter()),
1687 hash_routing_key("chain", ["a", "c"].into_iter())
1688 );
1689 }
1690
1691 /// Hop order is part of chain identity (spec 034's fallback order, PyPI's FR-005
1692 /// primary-before-extras order, NuGet's declaration order all rely on this) — the same
1693 /// set of parts in a different order must not collide.
1694 #[test]
1695 fn test_hash_routing_key_order_sensitive() {
1696 assert_ne!(
1697 hash_routing_key("chain", ["a", "b", "c"].into_iter()),
1698 hash_routing_key("chain", ["c", "b", "a"].into_iter())
1699 );
1700 }
1701
1702 /// Documents that credential exclusion is a caller obligation, not something
1703 /// `hash_routing_key` enforces: two calls that differ only in a credential-shaped extra
1704 /// part produce different keys, so a caller who mistakenly includes one breaks the
1705 /// rotation-stability invariant this function's doc warns about.
1706 #[test]
1707 fn test_hash_routing_key_only_hashes_what_callers_pass() {
1708 let identity_only =
1709 hash_routing_key("nuget-chain", ["https://feed.test/", "corp"].into_iter());
1710 let with_credential_included = hash_routing_key(
1711 "nuget-chain",
1712 ["https://feed.test/", "corp", "hunter2"].into_iter(),
1713 );
1714 assert_ne!(identity_only, with_credential_included);
1715 }
1716
1717 /// #969 S1: a `KeyShape::Url` key must be redacted before it reaches the cap-reached log
1718 /// line — the query-string credential must never appear, but the non-secret host must.
1719 #[cfg(feature = "test-util")]
1720 #[test]
1721 fn test_register_capped_url_shape_redacts_credential_on_cap_reached() {
1722 let map: DashMap<String, usize> = DashMap::new();
1723 for i in 0..MAX_ALTERNATE_REGISTRIES {
1724 assert_eq!(
1725 register_capped(
1726 &map,
1727 format!("https://index{i}.example"),
1728 EcosystemId::Cargo,
1729 KeyShape::Url,
1730 || i,
1731 ),
1732 CapResult::Inserted
1733 );
1734 }
1735
1736 let log = crate::test_util::capture_tracing_output(|| {
1737 register_capped(
1738 &map,
1739 "https://overflow.example/?api_key=SUPERSECRET_TOKEN".to_string(),
1740 EcosystemId::Cargo,
1741 KeyShape::Url,
1742 || 0,
1743 );
1744 });
1745
1746 assert!(
1747 log.contains("cargo alternate registry cap reached"),
1748 "log: {log}"
1749 );
1750 assert!(!log.contains("SUPERSECRET_TOKEN"), "log: {log}");
1751 assert!(log.contains("overflow.example"), "log: {log}");
1752 }
1753
1754 /// #969 S3: a `KeyShape::Opaque` key (e.g. a [`hash_routing_key`] digest) must survive
1755 /// the cap-reached log line intact — `RedactedUrl`'s Maven-coordinate false-positive scan
1756 /// would otherwise collapse a `"<prefix>:<hex>"` shape to an information-free
1757 /// `"<prefix>:***"`, destroying the correlation handle to other diagnostics about the
1758 /// same chain.
1759 #[cfg(feature = "test-util")]
1760 #[test]
1761 fn test_register_capped_opaque_shape_is_not_redacted_on_cap_reached() {
1762 let map: DashMap<String, usize> = DashMap::new();
1763 for i in 0..MAX_ALTERNATE_REGISTRIES {
1764 let key = hash_routing_key("test-chain", std::iter::once(i.to_string().as_str()));
1765 assert_eq!(
1766 register_capped(&map, key, EcosystemId::Cargo, KeyShape::Opaque, || i),
1767 CapResult::Inserted
1768 );
1769 }
1770
1771 let overflow_key = hash_routing_key("test-chain", std::iter::once("overflow"));
1772 let log = crate::test_util::capture_tracing_output(|| {
1773 register_capped(
1774 &map,
1775 overflow_key.clone(),
1776 EcosystemId::Cargo,
1777 KeyShape::Opaque,
1778 || 0,
1779 );
1780 });
1781
1782 assert!(
1783 log.contains("cargo alternate registry cap reached"),
1784 "log: {log}"
1785 );
1786 assert!(
1787 log.contains(&overflow_key),
1788 "opaque key must not be redacted: {log}"
1789 );
1790 assert!(
1791 !log.contains("***"),
1792 "opaque key must not collapse to '***': {log}"
1793 );
1794 }
1795}