deps_core/error.rs
1use thiserror::Error;
2
3use crate::net_policy::{RedactedName, RedactedUrl};
4
5/// Reconstructs the "{status} {reason}" text `reqwest::StatusCode`'s `Display`
6/// produces, since `HttpStatus` stores a bare `u16` for structural matching
7/// and loses the canonical reason phrase otherwise.
8///
9/// `url` is already a [`RedactedUrl`] (#789: `HttpStatus::url`'s field type itself makes the
10/// raw value unreachable, so there is nothing left to redact here) — this is the only place
11/// `HttpStatus`'s `Display` text is built.
12fn http_status_message(status: u16, url: &RedactedUrl) -> String {
13 let reason = reqwest::StatusCode::from_u16(status)
14 .ok()
15 .and_then(|s| s.canonical_reason());
16 reason.map_or_else(
17 || format!("HTTP {status} for {url}"),
18 |reason| format!("HTTP {status} {reason} for {url}"),
19 )
20}
21
22/// Builds [`DepsError::RegistryError`]'s `Display` text from its already-[`RedactedUrl`]
23/// `package` field (#767, #789).
24///
25/// [`DepsError::RegistryError`]'s `package` field is documented as a package name, but
26/// several `deps-core::cache` call sites populate it with a URL instead, so it is stored as
27/// [`RedactedUrl`] rather than a plain `String` — a no-op for an actual package name
28/// (including an npm-scoped one like `@types/node`, which an earlier revision of this
29/// function mangled into `***@types/node` before
30/// [`crate::net_policy::redact_userinfo`]'s empty-userinfo false positive was fixed at the
31/// root — #767 M1/code-review follow-up). `source`'s own `Display` can no longer re-embed the
32/// raw URL either: [`SanitizedRegistryError`]'s only constructor strips it unconditionally.
33fn registry_error_message(package: &RedactedUrl, source: &SanitizedRegistryError) -> String {
34 format!("registry request failed for {package}: {source}")
35}
36
37/// Builds [`DepsError::ResponseTooLarge`]'s `Display` text from its already-[`RedactedUrl`]
38/// `url` field (#767, #789).
39fn response_too_large_message(url: &RedactedUrl, limit: usize) -> String {
40 format!("response body for {url} exceeds {limit} byte limit")
41}
42
43/// Builds [`DepsError::Offline`]'s `Display` text from its already-[`RedactedUrl`] `url`
44/// field (#767, #789).
45fn offline_message(url: &RedactedUrl) -> String {
46 format!("offline: request to {url} was blocked by network.offline")
47}
48
49/// Wraps a `reqwest::Error` with its embedded request URL stripped, for storage in
50/// [`DepsError::RegistryError`]'s `source` field.
51///
52/// `reqwest::Error`'s own `Display` appends `" for url (...)"` when the underlying error
53/// carries a URL — `reqwest::Error::without_url()` strips this, but relying on every
54/// `RegistryError`-construction site to remember to call it is exactly the discipline gap
55/// this type closes (issue #789, see [`RedactedUrl`]'s own docs for the same problem on the
56/// URL-string side). The only constructor (`From<reqwest::Error>`) applies `.without_url()`
57/// unconditionally, so a raw URL can never reach `DepsError`'s `Display`/`Debug` through
58/// `{source}` forwarding, even when a future call site forgets.
59///
60/// **`self.0`'s own `source` chain is never exposed**, through either `Debug` or
61/// [`std::error::Error::source`] — this is deliberate, not an oversight. `.without_url()`
62/// only clears the *outer* error's own `url` field; `reqwest`'s `source` field is
63/// independent of it, and that source is not always a URL-free `hyper`/`io` error: reqwest
64/// 0.13.4's redirect policy (`src/redirect.rs`, the `https_only` check in
65/// `TowerPolicy::redirect`) rejects an `http://` redirect target by building
66/// `crate::error::redirect(crate::error::url_bad_scheme(next_url.clone()), next_url)` — the
67/// *inner* `url_bad_scheme(...)` error is itself a full `reqwest::Error` with `next_url`
68/// populated via `.with_url(...)`, nested as the *outer* error's `source`. That inner `url`
69/// is a field `.without_url()` on the outer error never touches, and `reqwest::Error`'s own
70/// derived-style `Debug` impl recursively prints `source`'s `Debug` (including that inner
71/// `url`) — so both a manual `.source()` walk and `{:?}` on the raw `reqwest::Error` can leak
72/// it. Since `reqwest` exposes no `source_mut()`-style API to reach in and strip that nested
73/// URL, this type treats itself as a leaf node instead: its [`std::error::Error::source`]
74/// impl always returns `None`, and `Debug` is hand-written to forward to the (already-safe)
75/// `Display` text rather than to `self.0`'s own `Debug`.
76///
77/// **Precision note on what is actually verified today**: this project's own client
78/// configuration never calls `reqwest::ClientBuilder::https_only` (`grep -rn '\.https_only('
79/// crates/` finds no hits), so the nested-URL shape above cannot currently occur through this
80/// crate's own request paths — the regression test below instead exercises a genuinely
81/// populated source chain via a real connection-refused (io-level) failure, which is what
82/// this project's client config can actually produce, and confirms it is discarded. The
83/// `https_only`/redirect mechanism itself is read directly from `reqwest` 0.13.4's pinned
84/// source (`redirect.rs`), not reproduced live here (doing so would need a TLS test harness
85/// this project does not otherwise have). `source()` returning `None` unconditionally — not
86/// only when a URL-bearing nested error is possible — is what makes this correct regardless:
87/// if a future change enables `https_only` on a shared client, this type's contract does not
88/// need re-auditing.
89///
90/// # Examples
91///
92/// ```
93/// use deps_core::error::SanitizedRegistryError;
94///
95/// // A builder-only `reqwest::Error` never carries a URL in the first place, so this
96/// // demonstrates the wrapper's `Display`/`Debug` forwarding without needing a real request.
97/// let raw = reqwest::Client::new().get("not a url").build().unwrap_err();
98/// let sanitized: SanitizedRegistryError = raw.into();
99/// assert!(!sanitized.to_string().is_empty());
100/// ```
101pub struct SanitizedRegistryError(reqwest::Error);
102
103impl From<reqwest::Error> for SanitizedRegistryError {
104 fn from(error: reqwest::Error) -> Self {
105 Self(error.without_url())
106 }
107}
108
109impl std::fmt::Display for SanitizedRegistryError {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 std::fmt::Display::fmt(&self.0, f)
112 }
113}
114
115impl std::fmt::Debug for SanitizedRegistryError {
116 /// Hand-written, not derived: forwards to `Display` (safe — `reqwest::Error`'s own
117 /// `Display` never recurses into its `source`'s text) instead of `self.0`'s own `Debug`,
118 /// which does recurse into `source` and would reopen the nested-URL leak this type's own
119 /// docs describe.
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 write!(f, "SanitizedRegistryError({})", self.0)
122 }
123}
124
125impl std::error::Error for SanitizedRegistryError {
126 /// Always `None` — see this type's own docs for why the wrapped error's source chain is
127 /// never safe to expose, even via this trait's usual chain-walking contract.
128 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
129 None
130 }
131}
132
133/// Core error types for deps-lsp.
134///
135/// Extended from Phase 1 to support multiple ecosystems (Cargo, npm, PyPI).
136/// All errors provide structured error handling with source error tracking.
137///
138/// # Examples
139///
140/// ```
141/// use deps_core::error::{DepsError, Result};
142///
143/// fn parse_file(content: &str, file_type: &str) -> Result<()> {
144/// // Parsing errors are automatically wrapped
145/// if content.is_empty() {
146/// return Err(DepsError::ParseError {
147/// file_type: file_type.into(),
148/// source: Box::new(std::io::Error::new(
149/// std::io::ErrorKind::InvalidData,
150/// "empty content"
151/// )),
152/// });
153/// }
154/// Ok(())
155/// }
156/// ```
157#[non_exhaustive]
158#[derive(Error)]
159pub enum DepsError {
160 /// A manifest or lockfile failed to parse.
161 #[error("failed to parse {file_type}: {source}")]
162 ParseError {
163 /// Ecosystem/file kind being parsed (e.g. `"Cargo.toml"`), for the error message.
164 file_type: String,
165 /// The underlying parser error.
166 #[source]
167 source: Box<dyn std::error::Error + Send + Sync>,
168 },
169
170 /// A registry HTTP request failed at the transport layer.
171 #[error("{}", registry_error_message(package, source))]
172 RegistryError {
173 /// Name of the package the request was for — or, at several `deps-core::cache` call
174 /// sites, the request URL instead (see [`RedactedUrl`]'s own docs). Redaction is safe
175 /// to apply unconditionally: a genuine URL is redacted for real, and a genuine package
176 /// name is left alone unless it happens to contain a `:` or a non-leading `@` (a
177 /// leading `@`, e.g. an npm-scoped name like `@types/node`, is left untouched), in
178 /// which case it is redacted the same way a credential-bearing value would be (e.g. a
179 /// Maven/Gradle `group:artifact` coordinate becomes `group:***`) — a cosmetic false
180 /// positive, never a correctness issue, since no current call site populates this
181 /// field with a coordinate (only `cache.rs` does, always with a URL).
182 package: RedactedUrl,
183 /// The underlying `reqwest` error, with its embedded request URL stripped.
184 #[source]
185 source: SanitizedRegistryError,
186 },
187
188 /// The cache layer itself failed (e.g. a poisoned lock), independent of any registry request.
189 #[error("cache error: {0}")]
190 CacheError(String),
191
192 /// A registry request was rejected for exceeding a rate limit. Unlike other variants,
193 /// `message` is a pre-vetted, IP-free, actionable hint safe to surface verbatim in a
194 /// per-dependency diagnostic (see [`Self::fetch_failure`]) — never build one from a raw
195 /// registry error body, which can embed the caller's public IP (`github.rs:332-346`).
196 #[error("{message}")]
197 RateLimited {
198 /// Pre-vetted, IP-free message safe to surface verbatim in a diagnostic.
199 message: String,
200 },
201
202 /// A package name was not found on the given registry.
203 #[error("{package} not found on {registry}")]
204 PackageNotFound {
205 /// Name of the package that was looked up — stored redacted (#1209): the raw value
206 /// is unreachable from this field's type.
207 package: RedactedName,
208 /// Name of the registry that reported the package as missing.
209 registry: &'static str,
210 },
211
212 /// A registry HTTP request returned a non-success status code.
213 #[error("{}", http_status_message(*status, url))]
214 HttpStatus {
215 /// URL that was requested — stored redacted (#789): the raw value is unreachable
216 /// from this field's type.
217 url: RedactedUrl,
218 /// HTTP status code returned.
219 status: u16,
220 },
221
222 /// A registry's response body failed to deserialize as JSON.
223 #[error("failed to parse {registry} response for {package}: {source}")]
224 ApiResponse {
225 /// Name of the package whose response failed to parse — stored redacted (#1209): the
226 /// raw value is unreachable from this field's type.
227 package: RedactedName,
228 /// Name of the registry the response came from.
229 registry: &'static str,
230 /// The underlying JSON deserialization error.
231 #[source]
232 source: serde_json::Error,
233 },
234
235 /// A response body exceeded the configured size cap and was rejected before full download.
236 #[error("{}", response_too_large_message(url, *limit))]
237 ResponseTooLarge {
238 /// URL the oversized response came from — stored redacted (#789): the raw value is
239 /// unreachable from this field's type.
240 url: RedactedUrl,
241 /// The size cap, in bytes, that was exceeded.
242 limit: usize,
243 },
244
245 /// Deliberately shared between two distinct rejection kinds: malformed version-requirement
246 /// strings (all ecosystems) and malformed Go module paths (`deps-go`, which has no separate
247 /// variant for the latter — see its `validate_module_path`). Nothing in the workspace
248 /// discriminates on this variant beyond rendering its message, so a consumer-specific split
249 /// was deferred (#399).
250 #[error("invalid version requirement: {0}")]
251 InvalidVersionReq(String),
252
253 /// A filesystem I/O operation failed.
254 #[error("I/O error: {0}")]
255 Io(#[from] std::io::Error),
256
257 /// A JSON parsing operation failed outside of a registry response context.
258 #[error("JSON error: {0}")]
259 Json(#[from] serde_json::Error),
260
261 /// The manifest file's ecosystem could not be determined from any registered router.
262 #[error("unsupported ecosystem: {0}")]
263 UnsupportedEcosystem(String),
264
265 /// More than one ecosystem's routing rules matched the same manifest path.
266 #[error("ambiguous ecosystem detection for file: {0}")]
267 AmbiguousEcosystem(String),
268
269 /// A URI supplied by the client or a manifest could not be parsed.
270 #[error("invalid URI: {0}")]
271 InvalidUri(String),
272
273 /// Returned by `deps_core::cache::HttpCache`'s 4 send sites (issue #483) when
274 /// `network.offline` is set, instead of attempting the request. `url` is the request
275 /// that was blocked, for diagnostic/logging purposes.
276 #[error("{}", offline_message(url))]
277 Offline {
278 /// The request URL that was blocked — stored redacted (#789): the raw value is
279 /// unreachable from this field's type.
280 url: RedactedUrl,
281 },
282
283 /// A multi-hop alternate/private-index chain's resolution was halted because a hop
284 /// returned a genuine transport error (5xx, timeout, connection failure) rather than a
285 /// clean "not found" — the chain deliberately does not fall through to a further, less
286 /// trusted hop in this case (`deps_pypi`'s FR-005(c)/NFR-003(3), #513). Carries no
287 /// arbitrary error text — mirrors [`Self::RateLimited`]'s pre-vetted-message precedent
288 /// (see [`Self::fetch_failure`]'s security-load-bearing invariant) — so its
289 /// classification there can safely be [`FetchFailure::Actionable`] with a fixed, safe
290 /// message, surfacing this case in hover/diagnostics instead of only a `tracing::warn!`.
291 #[error(
292 "index chain resolution halted by a transport error on one hop — not falling back \
293 to a less-trusted index"
294 )]
295 ChainResolutionHalted,
296}
297
298/// Hand-written, not derived: originally because a derived `Debug` would have printed
299/// `HttpStatus.url`, `Offline.url`, `ResponseTooLarge.url`, and `RegistryError.package` raw
300/// and unredacted (#767 code-review follow-up) — any `tracing::warn!(?err, ...)`/`{err:?}`
301/// call site, a common and arguably more idiomatic alternative to `%err`, would have bypassed
302/// the hand-written `Display` text entirely and reintroduced the raw URL/query string. Since
303/// #789, those four fields are typed [`RedactedUrl`] directly, so a derived `Debug` would
304/// actually be safe too (each field's own `Debug` already forwards to redacted text) — this
305/// impl is kept hand-written anyway as defense-in-depth against a future field-type
306/// regression, not because it is still load-bearing on its own. Every field is still shown
307/// (this is not a summary).
308impl std::fmt::Debug for DepsError {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 Self::ParseError { file_type, source } => f
312 .debug_struct("ParseError")
313 .field("file_type", file_type)
314 .field("source", source)
315 .finish(),
316 Self::RegistryError { package, source } => f
317 .debug_struct("RegistryError")
318 .field("package", package)
319 .field("source", source)
320 .finish(),
321 Self::CacheError(message) => f.debug_tuple("CacheError").field(message).finish(),
322 Self::RateLimited { message } => f
323 .debug_struct("RateLimited")
324 .field("message", message)
325 .finish(),
326 Self::PackageNotFound { package, registry } => f
327 .debug_struct("PackageNotFound")
328 .field("package", package)
329 .field("registry", registry)
330 .finish(),
331 Self::HttpStatus { url, status } => f
332 .debug_struct("HttpStatus")
333 .field("url", url)
334 .field("status", status)
335 .finish(),
336 Self::ApiResponse {
337 package,
338 registry,
339 source,
340 } => f
341 .debug_struct("ApiResponse")
342 .field("package", package)
343 .field("registry", registry)
344 .field("source", source)
345 .finish(),
346 Self::ResponseTooLarge { url, limit } => f
347 .debug_struct("ResponseTooLarge")
348 .field("url", url)
349 .field("limit", limit)
350 .finish(),
351 Self::InvalidVersionReq(req) => f.debug_tuple("InvalidVersionReq").field(req).finish(),
352 Self::Io(source) => f.debug_tuple("Io").field(source).finish(),
353 Self::Json(source) => f.debug_tuple("Json").field(source).finish(),
354 Self::UnsupportedEcosystem(ecosystem) => f
355 .debug_tuple("UnsupportedEcosystem")
356 .field(ecosystem)
357 .finish(),
358 Self::AmbiguousEcosystem(path) => {
359 f.debug_tuple("AmbiguousEcosystem").field(path).finish()
360 }
361 Self::InvalidUri(uri) => f.debug_tuple("InvalidUri").field(uri).finish(),
362 Self::Offline { url } => f.debug_struct("Offline").field("url", url).finish(),
363 Self::ChainResolutionHalted => f.write_str("ChainResolutionHalted"),
364 }
365 }
366}
367
368impl DepsError {
369 /// Returns `true` when this error means the registry was successfully asked and
370 /// answered "this package doesn't exist", as opposed to the registry not having
371 /// been answerable at all (network failure, timeout, malformed response, 5xx).
372 ///
373 /// Distinguishing the two matters for diagnostics (#267): a genuine not-found is
374 /// evidence the package name is wrong, while any other error is evidence only that
375 /// this particular request failed — reporting the latter as "Unknown package" would
376 /// mislabel a transient registry outage as a nonexistent dependency. Covers
377 /// [`DepsError::PackageNotFound`] (the ecosystems that map a 404 to it explicitly:
378 /// npm, PyPI, Go, Swift) and a bare [`DepsError::HttpStatus`] with `status == 404`
379 /// (the ecosystems that propagate the raw HTTP status instead: Cargo, Maven, Gradle,
380 /// Bundler, Dart, Composer, NuGet).
381 ///
382 /// # Examples
383 ///
384 /// ```
385 /// use deps_core::DepsError;
386 ///
387 /// let not_found = DepsError::PackageNotFound {
388 /// package: "left-pad".into(),
389 /// registry: "npm",
390 /// };
391 /// assert!(not_found.is_not_found());
392 ///
393 /// let http_404 = DepsError::HttpStatus {
394 /// url: "https://crates.io/api/v1/crates/left-pad".into(),
395 /// status: 404,
396 /// };
397 /// assert!(http_404.is_not_found());
398 ///
399 /// let outage = DepsError::HttpStatus {
400 /// url: "https://crates.io/api/v1/crates/serde".into(),
401 /// status: 503,
402 /// };
403 /// assert!(!outage.is_not_found());
404 ///
405 /// let cache_err = DepsError::CacheError("connection reset".into());
406 /// assert!(!cache_err.is_not_found());
407 /// ```
408 #[must_use]
409 pub const fn is_not_found(&self) -> bool {
410 matches!(
411 self,
412 Self::PackageNotFound { .. } | Self::HttpStatus { status: 404, .. }
413 )
414 }
415
416 /// Classifies this error for the per-dependency "registry lookup failed" diagnostic
417 /// (#478), distinguishing a failure with a safe, actionable hint to show the user from
418 /// one whose raw text must never reach a diagnostic.
419 ///
420 /// **Security-load-bearing invariant**: [`FetchFailure::Actionable`] is produced *only*
421 /// from [`Self::RateLimited`]'s pre-vetted, IP-free canned message. Every other variant
422 /// must classify as [`FetchFailure::Transient`] — never call `.to_string()`/`Display` on
423 /// an arbitrary `DepsError` to build an `Actionable` value, since a raw `HttpStatus` or
424 /// `RegistryError` body can embed the caller's public IP (`github.rs:332-346`, exercised
425 /// by the `github` crate's `test_parse_tags_page_github_rate_limit_returns_error`).
426 ///
427 /// # Examples
428 ///
429 /// ```
430 /// use deps_core::error::{DepsError, FetchFailure};
431 ///
432 /// let rate_limited = DepsError::RateLimited { message: "set GITHUB_TOKEN".into() };
433 /// assert_eq!(
434 /// rate_limited.fetch_failure(),
435 /// FetchFailure::Actionable("set GITHUB_TOKEN".into())
436 /// );
437 ///
438 /// let other = DepsError::CacheError("connection reset".into());
439 /// assert_eq!(other.fetch_failure(), FetchFailure::Transient);
440 /// ```
441 #[must_use]
442 pub fn fetch_failure(&self) -> FetchFailure {
443 match self {
444 Self::RateLimited { message } => FetchFailure::Actionable(message.clone()),
445 // Fixed, pre-vetted message — see `Self::ChainResolutionHalted`'s own doc for why
446 // this is safe to build as `Actionable` the same way `RateLimited` is.
447 Self::ChainResolutionHalted => FetchFailure::Actionable(
448 "index unreachable — resolution halted, not falling back to a less-trusted \
449 index"
450 .to_string(),
451 ),
452 _ => FetchFailure::Transient,
453 }
454 }
455
456 /// Returns `true` when this error means a request was blocked by `network.offline`
457 /// (issue #483), as opposed to any other network or registry failure.
458 ///
459 /// Used by `deps_maven::registry` to skip poisoning its negative-search-failure
460 /// cache with an offline block, so toggling `network.offline` back to `false` takes
461 /// effect immediately instead of being masked by `RECENT_FAILURE_TTL`.
462 ///
463 /// # Examples
464 ///
465 /// ```
466 /// use deps_core::DepsError;
467 ///
468 /// let offline = DepsError::Offline { url: "https://crates.io/".into() };
469 /// assert!(offline.is_offline());
470 ///
471 /// let other = DepsError::CacheError("connection reset".into());
472 /// assert!(!other.is_offline());
473 /// ```
474 #[must_use]
475 pub const fn is_offline(&self) -> bool {
476 matches!(self, Self::Offline { .. })
477 }
478
479 /// A URL-free summary of this error, safe to attach to a `tracing` field or log line at
480 /// an outbound-request chokepoint. [`Self::HttpStatus`], [`Self::Offline`],
481 /// [`Self::ResponseTooLarge`], and [`Self::RegistryError`]'s own `Display` now redact
482 /// their URL via [`RedactedUrl`] (#767, #789), but this summary deliberately still never
483 /// derives from `self`'s `Display`/`Debug`: a future variant added here should not be
484 /// able to reintroduce a leak just by being included in a `{self}` interpolation.
485 /// [`Self::RegistryError`]'s wrapped [`SanitizedRegistryError`] can no longer re-embed
486 /// the raw URL through its own `Display` either way, since its only constructor strips
487 /// it unconditionally.
488 ///
489 /// Returns the HTTP status code when this is [`Self::HttpStatus`], plus a coarse,
490 /// URL-free cause discriminant for every variant — so a routine transport
491 /// failure/timeout still carries some triage signal instead of collapsing to `status =
492 /// None` with nothing else.
493 #[must_use]
494 pub(crate) const fn safe_tracing_summary(&self) -> (Option<u16>, &'static str) {
495 match self {
496 Self::HttpStatus { status, .. } => (Some(*status), "http-status"),
497 Self::RegistryError { .. } => (None, "transport"),
498 Self::CacheError(_) => (None, "cache"),
499 Self::Offline { .. } => (None, "offline"),
500 Self::ResponseTooLarge { .. } => (None, "response-too-large"),
501 Self::RateLimited { .. } => (None, "rate-limited"),
502 Self::ApiResponse { .. } => (None, "api-response"),
503 Self::PackageNotFound { .. } => (None, "not-found"),
504 _ => (None, "other"),
505 }
506 }
507}
508
509/// Outcome of a registry fetch attempt for one dependency, as recorded in
510/// `DocumentState::outcomes` (`deps-lsp`) and rendered by
511/// [`crate::lsp_helpers::generate_diagnostics_from_cache`] (#478).
512///
513/// Replaces a bare `HashSet<PackageName>` membership check so the per-dependency diagnostic
514/// can distinguish a failure with a safe, user-actionable hint from an opaque one, without
515/// ever threading raw, potentially IP-bearing error text into the diagnostic (see
516/// [`DepsError::fetch_failure`]).
517#[non_exhaustive]
518#[derive(Clone, Debug, PartialEq, Eq)]
519pub enum FetchFailure {
520 /// The fetch failed with a pre-vetted, safe-to-display hint (currently only produced
521 /// from [`DepsError::RateLimited`]).
522 Actionable(String),
523 /// The fetch failed for a reason with no safe user-facing detail to show — the
524 /// diagnostic falls back to a generic "lookup failed" message.
525 Transient,
526 /// The dependency was never actually queried (e.g. a name/source collision detected
527 /// before the fetch, see `deps-lsp`'s `dedup_dependencies_by_source`) — renders the
528 /// same generic "lookup failed" message as [`Self::Transient`], since the absence of
529 /// an attempt is not evidence the package doesn't exist.
530 NotAttempted,
531}
532
533/// Convenience type alias for `Result<T, DepsError>`.
534///
535/// This is the standard `Result` type used throughout the deps-lsp codebase.
536/// It simplifies function signatures by defaulting the error type to `DepsError`.
537///
538/// # Examples
539///
540/// ```
541/// use deps_core::error::Result;
542///
543/// fn get_version(name: &str) -> Result<String> {
544/// if name.is_empty() {
545/// return Err(deps_core::error::DepsError::CacheError("empty name".into()));
546/// }
547/// Ok("1.0.0".into())
548/// }
549/// ```
550pub type Result<T> = std::result::Result<T, DepsError>;
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555 use std::error::Error as StdError;
556
557 #[test]
558 fn test_error_display() {
559 let error = DepsError::CacheError("test error".into());
560 assert_eq!(error.to_string(), "cache error: test error");
561 }
562
563 /// #789: `SanitizedRegistryError`'s own `Display` must never re-embed the request URL,
564 /// even when the wrapped `reqwest::Error` genuinely carries one — a builder-only error
565 /// like `Client::get("not a url").build().unwrap_err()` never populates `.url()`, so this
566 /// needs a real transport-level failure (connection refused on a closed loopback port) to
567 /// actually exercise the strip, mirroring `deps_core::cache`'s own
568 /// `test_registry_error_source_redacts_url_on_real_transport_error`.
569 #[tokio::test]
570 async fn test_sanitized_registry_error_strips_url_on_real_transport_error() {
571 let port = {
572 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
573 listener.local_addr().unwrap().port()
574 };
575 let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");
576
577 let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
578 assert!(
579 reqwest_err.url().is_some(),
580 "test setup invariant: the underlying reqwest::Error must carry a URL"
581 );
582
583 let sanitized: SanitizedRegistryError = reqwest_err.into();
584 assert!(
585 !sanitized.to_string().contains("super-secret-value"),
586 "sanitized: {sanitized}"
587 );
588 assert!(
589 !format!("{sanitized:?}").contains("super-secret-value"),
590 "sanitized debug: {sanitized:?}"
591 );
592 }
593
594 /// #789 S2: `SanitizedRegistryError` must treat itself as a leaf node for source-chaining
595 /// purposes — `reqwest`'s redirect policy can nest a *second* `reqwest::Error` (with its
596 /// own populated `url` field) as the outer error's `source` (see the type's own docs for
597 /// the exact `https_only`/redirect code path), which `.without_url()` on the outer error
598 /// alone does not touch. This proves the wrapper discards a real, populated source chain
599 /// (not merely that one never existed): the *raw* wrapped `reqwest::Error` behind
600 /// `sanitized.0` (accessible here since `mod tests` is a child of `error`) genuinely has
601 /// `.source().is_some()` for a real connection-refused failure, yet `SanitizedRegistryError`'s
602 /// own `source()` is unconditionally `None`.
603 #[tokio::test]
604 async fn test_sanitized_registry_error_source_is_always_none() {
605 let port = {
606 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
607 listener.local_addr().unwrap().port()
608 };
609 let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");
610
611 let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
612 let sanitized: SanitizedRegistryError = reqwest_err.into();
613 assert!(
614 sanitized.0.source().is_some(),
615 "test setup invariant: a connection-refused error must have a populated source chain"
616 );
617 assert!(
618 StdError::source(&sanitized).is_none(),
619 "SanitizedRegistryError::source() must always be None"
620 );
621 }
622
623 /// #756 code-review finding: `safe_tracing_summary` must never derive its output from
624 /// `self`'s own `Display`/`Debug` (both embed the raw, unredacted request URL for several
625 /// variants) — pinning the exact `(status, cause)` pairs for every variant a `HttpCache`/
626 /// `OsvClient` call site can actually produce, so a routine transport failure still
627 /// carries triage signal (`cause`) instead of collapsing to `(None, "other")`.
628 #[test]
629 fn test_safe_tracing_summary_covers_every_reachable_variant() {
630 assert_eq!(
631 DepsError::HttpStatus {
632 url: "https://example.com/pkg?token=secret".into(),
633 status: 503,
634 }
635 .safe_tracing_summary(),
636 (Some(503), "http-status")
637 );
638 assert_eq!(
639 DepsError::RegistryError {
640 package: "https://example.com/pkg?token=secret".into(),
641 source: reqwest::Client::new()
642 .get("not a url")
643 .build()
644 .unwrap_err()
645 .into(),
646 }
647 .safe_tracing_summary(),
648 (None, "transport")
649 );
650 assert_eq!(
651 DepsError::CacheError("poisoned lock".into()).safe_tracing_summary(),
652 (None, "cache")
653 );
654 assert_eq!(
655 DepsError::Offline {
656 url: "https://example.com/pkg?token=secret".into(),
657 }
658 .safe_tracing_summary(),
659 (None, "offline")
660 );
661 assert_eq!(
662 DepsError::ResponseTooLarge {
663 url: "https://example.com/pkg?token=secret".into(),
664 limit: 1024,
665 }
666 .safe_tracing_summary(),
667 (None, "response-too-large")
668 );
669 }
670
671 /// A `(status, cause)` pair must never itself carry the URL — the whole point of this
672 /// method — even when the source error's own `Display` would have.
673 #[test]
674 fn test_safe_tracing_summary_output_never_contains_the_url() {
675 let error = DepsError::HttpStatus {
676 url: "https://npm.internal/pkg?token=super-secret-value".into(),
677 status: 503,
678 };
679 let (status, cause) = error.safe_tracing_summary();
680 assert_eq!(status, Some(503));
681 assert!(!cause.contains("super-secret-value"));
682 assert!(!cause.contains("npm.internal"));
683 }
684
685 #[test]
686 fn test_response_too_large() {
687 let error = DepsError::ResponseTooLarge {
688 url: "https://example.com/data".into(),
689 limit: 32 * 1024 * 1024,
690 };
691 assert_eq!(
692 error.to_string(),
693 "response body for https://example.com/data exceeds 33554432 byte limit"
694 );
695 }
696
697 /// #767: `HttpStatus`'s `Display` is surfaced verbatim through `window/showMessage`
698 /// (`deps-lsp`'s fetch-failure toast), so a query-string credential (e.g. an `.npmrc`
699 /// `?_authToken=...` value after `${VAR}` expansion) must never reach it.
700 #[test]
701 fn test_http_status_display_redacts_query_string() {
702 let error = DepsError::HttpStatus {
703 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
704 status: 503,
705 };
706 let message = error.to_string();
707 assert!(
708 !message.contains("super-secret-value"),
709 "message: {message}"
710 );
711 assert_eq!(
712 message,
713 "HTTP 503 Service Unavailable for https://npm.internal/pkg"
714 );
715 }
716
717 /// #767 companion for [`DepsError::RegistryError`], whose `package` field is frequently
718 /// populated with a raw URL rather than a package name (`deps-core::cache`'s
719 /// `read_body_capped`/`get_cached_with_headers_via`/`post_json`/`get_cached_bytes`).
720 #[test]
721 fn test_registry_error_display_redacts_query_string() {
722 let error = DepsError::RegistryError {
723 package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
724 source: reqwest::Client::new()
725 .get("not a url")
726 .build()
727 .unwrap_err()
728 .into(),
729 };
730 let message = error.to_string();
731 assert!(
732 !message.contains("super-secret-value"),
733 "message: {message}"
734 );
735 assert!(message.starts_with("registry request failed for https://npm.internal/pkg:"));
736 }
737
738 /// #767 M1: `RegistryError::package` is documented as a package name, and an npm-scoped
739 /// name (leading `@`, no scheme) must never be mangled by the URL redaction meant for
740 /// call sites that populate this field with a URL instead — reproduced false positive:
741 /// `url_for_tracing`'s unparseable-URL fallback previously misread `@types/node` as
742 /// `user@host`-shaped userinfo and rendered `***@types/node`.
743 #[test]
744 fn test_registry_error_display_does_not_mangle_scoped_package_name() {
745 let error = DepsError::RegistryError {
746 package: "@types/node".into(),
747 source: reqwest::Client::new()
748 .get("not a url")
749 .build()
750 .unwrap_err()
751 .into(),
752 };
753 let message = error.to_string();
754 assert!(
755 message.starts_with("registry request failed for @types/node:"),
756 "message: {message}"
757 );
758 }
759
760 /// #767: `Offline`'s `Display` also embeds the blocked request URL — same redaction
761 /// applies.
762 #[test]
763 fn test_offline_display_redacts_query_string() {
764 let error = DepsError::Offline {
765 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
766 };
767 let message = error.to_string();
768 assert!(
769 !message.contains("super-secret-value"),
770 "message: {message}"
771 );
772 }
773
774 /// #767: `ResponseTooLarge`'s `Display` also embeds the source URL — same redaction
775 /// applies.
776 #[test]
777 fn test_response_too_large_display_redacts_query_string() {
778 let error = DepsError::ResponseTooLarge {
779 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
780 limit: 1024,
781 };
782 let message = error.to_string();
783 assert!(
784 !message.contains("super-secret-value"),
785 "message: {message}"
786 );
787 }
788
789 /// Code-review follow-up on #767: `DepsError` derives `Debug` no longer — a derived
790 /// impl would print `HttpStatus.url`/`Offline.url`/`ResponseTooLarge.url`/
791 /// `RegistryError.package` raw, so any `tracing::warn!(?err, ...)`/`{err:?}` call site
792 /// (a common, arguably more idiomatic alternative to `%err`) would bypass every
793 /// hand-written `Display` redaction above and reintroduce the leak. Covers all four
794 /// URL-shaped variants via `{:?}`.
795 #[test]
796 fn test_debug_redacts_query_string_for_every_url_bearing_variant() {
797 let credential_bearing = [
798 DepsError::HttpStatus {
799 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
800 status: 503,
801 },
802 DepsError::Offline {
803 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
804 },
805 DepsError::ResponseTooLarge {
806 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
807 limit: 1024,
808 },
809 DepsError::RegistryError {
810 package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
811 source: reqwest::Client::new()
812 .get("not a url")
813 .build()
814 .unwrap_err()
815 .into(),
816 },
817 ];
818 for error in credential_bearing {
819 let debug = format!("{error:?}");
820 assert!(
821 !debug.contains("super-secret-value"),
822 "Debug leaked the credential for {error}: {debug}"
823 );
824 }
825 }
826
827 /// #789 S1 regression: destructuring `HttpStatus.url` directly (bypassing `Display`/
828 /// `Debug` entirely) must still yield only redacted text — the field's type itself
829 /// (`RedactedUrl`, not `String`) is what makes this true, not any redaction step at the
830 /// access site. There is no way to get the raw string back out even via direct field
831 /// access, because nothing raw was ever stored in the field to begin with.
832 #[test]
833 fn test_http_status_url_field_is_redacted_even_via_direct_destructure() {
834 let error = DepsError::HttpStatus {
835 url: RedactedUrl::new("https://npm.internal/pkg?_authToken=super-secret-value"),
836 status: 401,
837 };
838 let DepsError::HttpStatus { url, .. } = error else {
839 unreachable!()
840 };
841 assert_eq!(url.to_string(), "https://npm.internal/pkg");
842 assert!(!url.to_string().contains("super-secret-value"));
843 }
844
845 /// `Debug` must still show every field for the ordinary, non-URL-bearing variants —
846 /// this is a redaction fix, not a summary, so field values unrelated to a URL must
847 /// come through unchanged.
848 #[test]
849 fn test_debug_still_shows_non_url_fields() {
850 let error = DepsError::PackageNotFound {
851 package: "left-pad".into(),
852 registry: "npm",
853 };
854 let debug = format!("{error:?}");
855 assert!(debug.contains("left-pad"), "debug: {debug}");
856 assert!(debug.contains("npm"), "debug: {debug}");
857 }
858
859 #[test]
860 fn test_invalid_version_req() {
861 let error = DepsError::InvalidVersionReq("invalid".into());
862 assert_eq!(error.to_string(), "invalid version requirement: invalid");
863 }
864
865 #[test]
866 fn test_parse_error() {
867 let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data");
868 let error = DepsError::ParseError {
869 file_type: "Cargo.toml".into(),
870 source: Box::new(io_err),
871 };
872 assert!(error.to_string().contains("failed to parse Cargo.toml"));
873 }
874
875 #[test]
876 fn test_io_error_conversion() {
877 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
878 let error: DepsError = io_err.into();
879 assert!(error.to_string().contains("I/O error"));
880 }
881
882 #[test]
883 fn test_unsupported_ecosystem() {
884 let error = DepsError::UnsupportedEcosystem("unknown".into());
885 assert_eq!(error.to_string(), "unsupported ecosystem: unknown");
886 }
887
888 #[test]
889 fn test_ambiguous_ecosystem() {
890 let error = DepsError::AmbiguousEcosystem("file.txt".into());
891 assert_eq!(
892 error.to_string(),
893 "ambiguous ecosystem detection for file: file.txt"
894 );
895 }
896
897 #[test]
898 fn test_invalid_uri() {
899 let error = DepsError::InvalidUri("http://example.com".into());
900 assert_eq!(error.to_string(), "invalid URI: http://example.com");
901 }
902
903 #[test]
904 fn test_offline_error_display_and_predicate() {
905 let error = DepsError::Offline {
906 url: "https://crates.io/api/v1/crates/serde".into(),
907 };
908 assert!(error.to_string().contains("offline"));
909 assert!(error.is_offline());
910 assert!(!error.is_not_found());
911
912 let other = DepsError::CacheError("boom".into());
913 assert!(!other.is_offline());
914 }
915
916 #[test]
917 fn test_package_not_found() {
918 let error = DepsError::PackageNotFound {
919 package: "flask".into(),
920 registry: "PyPI",
921 };
922 assert_eq!(error.to_string(), "flask not found on PyPI");
923 }
924
925 /// #1209: `PackageNotFound.package` is redacted at construction (via [`RedactedName`]),
926 /// so a credential embedded in a manifest's name-shaped field (e.g. Maven property
927 /// interpolation producing `group:artifact:secret@host`) must never reach this variant's
928 /// `Display`/`Debug` — the sink a server log or an LSP client toast (`window/showMessage`)
929 /// reads verbatim.
930 #[test]
931 fn test_package_not_found_display_redacts_credential_shaped_name() {
932 let error = DepsError::PackageNotFound {
933 package: "com.example:deploy:AUDITSENTINEL0000@git.internal.corp".into(),
934 registry: "Maven Central",
935 };
936 assert!(
937 !error.to_string().contains("AUDITSENTINEL0000"),
938 "Display leaked a credential-shaped package name: {error}"
939 );
940 assert!(
941 !format!("{error:?}").contains("AUDITSENTINEL0000"),
942 "Debug leaked a credential-shaped package name: {error:?}"
943 );
944 assert!(error.to_string().contains("git.internal.corp"));
945 }
946
947 /// Same sink, `ApiResponse` variant (#1209).
948 #[test]
949 fn test_api_response_display_redacts_credential_shaped_name() {
950 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
951 let error = DepsError::ApiResponse {
952 package: "com.example:deploy:AUDITSENTINEL0000@git.internal.corp".into(),
953 registry: "Maven Central",
954 source: json_err,
955 };
956 assert!(
957 !error.to_string().contains("AUDITSENTINEL0000"),
958 "Display leaked a credential-shaped package name: {error}"
959 );
960 assert!(
961 !format!("{error:?}").contains("AUDITSENTINEL0000"),
962 "Debug leaked a credential-shaped package name: {error:?}"
963 );
964 }
965
966 #[test]
967 fn test_http_status_with_known_reason() {
968 let error = DepsError::HttpStatus {
969 url: "https://example.com/data".into(),
970 status: 404,
971 };
972 assert_eq!(
973 error.to_string(),
974 "HTTP 404 Not Found for https://example.com/data"
975 );
976 }
977
978 #[test]
979 fn test_http_status_with_unknown_code() {
980 let error = DepsError::HttpStatus {
981 url: "https://example.com/data".into(),
982 status: 599,
983 };
984 assert_eq!(error.to_string(), "HTTP 599 for https://example.com/data");
985 }
986
987 #[test]
988 fn test_api_response_error() {
989 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
990 let error = DepsError::ApiResponse {
991 package: "flask".into(),
992 registry: "PyPI",
993 source: json_err,
994 };
995 assert!(
996 error
997 .to_string()
998 .starts_with("failed to parse PyPI response for flask:")
999 );
1000 }
1001
1002 /// Exhaustive companion to the doc-test on [`DepsError::fetch_failure`]: every variant
1003 /// other than [`DepsError::RateLimited`] and [`DepsError::ChainResolutionHalted`] must
1004 /// classify as [`FetchFailure::Transient`]. This is the invariant the doc comment calls
1005 /// security-load-bearing (a future variant wired to `Actionable` by mistake could leak
1006 /// raw, potentially IP-bearing error text into a diagnostic), so it must be a real test
1007 /// enumerating every variant, not just a handful of spot checks. `ChainResolutionHalted`
1008 /// is exempted from the "everything else is Transient" list — like `RateLimited`, it
1009 /// carries no arbitrary payload, only a fixed, pre-vetted message, so it is safe to be
1010 /// the second `Actionable`-producing variant (see its own doc and #513's M2 fix).
1011 #[test]
1012 fn test_fetch_failure_classifies_every_non_rate_limited_variant_as_transient() {
1013 // A `reqwest::Error` built from an invalid URL — `RequestBuilder::build`
1014 // is synchronous and fails on URL parsing alone, so this needs no network
1015 // access or async runtime.
1016 let reqwest_err = reqwest::Client::new()
1017 .get("not a valid url")
1018 .build()
1019 .unwrap_err();
1020 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
1021 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1022
1023 let non_rate_limited = [
1024 DepsError::ParseError {
1025 file_type: "Cargo.toml".into(),
1026 source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
1027 },
1028 DepsError::RegistryError {
1029 package: "flask".into(),
1030 source: reqwest_err.into(),
1031 },
1032 DepsError::CacheError("connection reset".into()),
1033 DepsError::PackageNotFound {
1034 package: "flask".into(),
1035 registry: "PyPI",
1036 },
1037 DepsError::HttpStatus {
1038 url: "https://example.com".into(),
1039 status: 500,
1040 },
1041 DepsError::ApiResponse {
1042 package: "flask".into(),
1043 registry: "PyPI",
1044 source: json_err,
1045 },
1046 DepsError::ResponseTooLarge {
1047 url: "https://example.com".into(),
1048 limit: 1024,
1049 },
1050 DepsError::InvalidVersionReq("bad range".into()),
1051 DepsError::Io(io_err),
1052 DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err()),
1053 DepsError::UnsupportedEcosystem("unknown".into()),
1054 DepsError::AmbiguousEcosystem("file.txt".into()),
1055 DepsError::InvalidUri("not a uri".into()),
1056 DepsError::Offline {
1057 url: "https://example.com".into(),
1058 },
1059 ];
1060
1061 for error in non_rate_limited {
1062 assert_eq!(
1063 error.fetch_failure(),
1064 FetchFailure::Transient,
1065 "expected Transient for {error:?}"
1066 );
1067 }
1068
1069 let rate_limited = DepsError::RateLimited {
1070 message: "set GITHUB_TOKEN to increase the rate limit".into(),
1071 };
1072 assert_eq!(
1073 rate_limited.fetch_failure(),
1074 FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".into())
1075 );
1076
1077 assert_eq!(
1078 DepsError::ChainResolutionHalted.fetch_failure(),
1079 FetchFailure::Actionable(
1080 "index unreachable — resolution halted, not falling back to a less-trusted \
1081 index"
1082 .to_string()
1083 )
1084 );
1085 }
1086}