deps_core/error.rs
1use thiserror::Error;
2
3use crate::net_policy::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.
206 package: String,
207 /// Name of the registry that reported the package as missing.
208 registry: &'static str,
209 },
210
211 /// A registry HTTP request returned a non-success status code.
212 #[error("{}", http_status_message(*status, url))]
213 HttpStatus {
214 /// URL that was requested — stored redacted (#789): the raw value is unreachable
215 /// from this field's type.
216 url: RedactedUrl,
217 /// HTTP status code returned.
218 status: u16,
219 },
220
221 /// A registry's response body failed to deserialize as JSON.
222 #[error("failed to parse {registry} response for {package}: {source}")]
223 ApiResponse {
224 /// Name of the package whose response failed to parse.
225 package: String,
226 /// Name of the registry the response came from.
227 registry: &'static str,
228 /// The underlying JSON deserialization error.
229 #[source]
230 source: serde_json::Error,
231 },
232
233 /// A response body exceeded the configured size cap and was rejected before full download.
234 #[error("{}", response_too_large_message(url, *limit))]
235 ResponseTooLarge {
236 /// URL the oversized response came from — stored redacted (#789): the raw value is
237 /// unreachable from this field's type.
238 url: RedactedUrl,
239 /// The size cap, in bytes, that was exceeded.
240 limit: usize,
241 },
242
243 /// Deliberately shared between two distinct rejection kinds: malformed version-requirement
244 /// strings (all ecosystems) and malformed Go module paths (`deps-go`, which has no separate
245 /// variant for the latter — see its `validate_module_path`). Nothing in the workspace
246 /// discriminates on this variant beyond rendering its message, so a consumer-specific split
247 /// was deferred (#399).
248 #[error("invalid version requirement: {0}")]
249 InvalidVersionReq(String),
250
251 /// A filesystem I/O operation failed.
252 #[error("I/O error: {0}")]
253 Io(#[from] std::io::Error),
254
255 /// A JSON parsing operation failed outside of a registry response context.
256 #[error("JSON error: {0}")]
257 Json(#[from] serde_json::Error),
258
259 /// The manifest file's ecosystem could not be determined from any registered router.
260 #[error("unsupported ecosystem: {0}")]
261 UnsupportedEcosystem(String),
262
263 /// More than one ecosystem's routing rules matched the same manifest path.
264 #[error("ambiguous ecosystem detection for file: {0}")]
265 AmbiguousEcosystem(String),
266
267 /// A URI supplied by the client or a manifest could not be parsed.
268 #[error("invalid URI: {0}")]
269 InvalidUri(String),
270
271 /// Returned by `deps_core::cache::HttpCache`'s 4 send sites (issue #483) when
272 /// `network.offline` is set, instead of attempting the request. `url` is the request
273 /// that was blocked, for diagnostic/logging purposes.
274 #[error("{}", offline_message(url))]
275 Offline {
276 /// The request URL that was blocked — stored redacted (#789): the raw value is
277 /// unreachable from this field's type.
278 url: RedactedUrl,
279 },
280
281 /// A multi-hop alternate/private-index chain's resolution was halted because a hop
282 /// returned a genuine transport error (5xx, timeout, connection failure) rather than a
283 /// clean "not found" — the chain deliberately does not fall through to a further, less
284 /// trusted hop in this case (`deps_pypi`'s FR-005(c)/NFR-003(3), #513). Carries no
285 /// arbitrary error text — mirrors [`Self::RateLimited`]'s pre-vetted-message precedent
286 /// (see [`Self::fetch_failure`]'s security-load-bearing invariant) — so its
287 /// classification there can safely be [`FetchFailure::Actionable`] with a fixed, safe
288 /// message, surfacing this case in hover/diagnostics instead of only a `tracing::warn!`.
289 #[error(
290 "index chain resolution halted by a transport error on one hop — not falling back \
291 to a less-trusted index"
292 )]
293 ChainResolutionHalted,
294}
295
296/// Hand-written, not derived: originally because a derived `Debug` would have printed
297/// `HttpStatus.url`, `Offline.url`, `ResponseTooLarge.url`, and `RegistryError.package` raw
298/// and unredacted (#767 code-review follow-up) — any `tracing::warn!(?err, ...)`/`{err:?}`
299/// call site, a common and arguably more idiomatic alternative to `%err`, would have bypassed
300/// the hand-written `Display` text entirely and reintroduced the raw URL/query string. Since
301/// #789, those four fields are typed [`RedactedUrl`] directly, so a derived `Debug` would
302/// actually be safe too (each field's own `Debug` already forwards to redacted text) — this
303/// impl is kept hand-written anyway as defense-in-depth against a future field-type
304/// regression, not because it is still load-bearing on its own. Every field is still shown
305/// (this is not a summary).
306impl std::fmt::Debug for DepsError {
307 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
308 match self {
309 Self::ParseError { file_type, source } => f
310 .debug_struct("ParseError")
311 .field("file_type", file_type)
312 .field("source", source)
313 .finish(),
314 Self::RegistryError { package, source } => f
315 .debug_struct("RegistryError")
316 .field("package", package)
317 .field("source", source)
318 .finish(),
319 Self::CacheError(message) => f.debug_tuple("CacheError").field(message).finish(),
320 Self::RateLimited { message } => f
321 .debug_struct("RateLimited")
322 .field("message", message)
323 .finish(),
324 Self::PackageNotFound { package, registry } => f
325 .debug_struct("PackageNotFound")
326 .field("package", package)
327 .field("registry", registry)
328 .finish(),
329 Self::HttpStatus { url, status } => f
330 .debug_struct("HttpStatus")
331 .field("url", url)
332 .field("status", status)
333 .finish(),
334 Self::ApiResponse {
335 package,
336 registry,
337 source,
338 } => f
339 .debug_struct("ApiResponse")
340 .field("package", package)
341 .field("registry", registry)
342 .field("source", source)
343 .finish(),
344 Self::ResponseTooLarge { url, limit } => f
345 .debug_struct("ResponseTooLarge")
346 .field("url", url)
347 .field("limit", limit)
348 .finish(),
349 Self::InvalidVersionReq(req) => f.debug_tuple("InvalidVersionReq").field(req).finish(),
350 Self::Io(source) => f.debug_tuple("Io").field(source).finish(),
351 Self::Json(source) => f.debug_tuple("Json").field(source).finish(),
352 Self::UnsupportedEcosystem(ecosystem) => f
353 .debug_tuple("UnsupportedEcosystem")
354 .field(ecosystem)
355 .finish(),
356 Self::AmbiguousEcosystem(path) => {
357 f.debug_tuple("AmbiguousEcosystem").field(path).finish()
358 }
359 Self::InvalidUri(uri) => f.debug_tuple("InvalidUri").field(uri).finish(),
360 Self::Offline { url } => f.debug_struct("Offline").field("url", url).finish(),
361 Self::ChainResolutionHalted => f.write_str("ChainResolutionHalted"),
362 }
363 }
364}
365
366impl DepsError {
367 /// Returns `true` when this error means the registry was successfully asked and
368 /// answered "this package doesn't exist", as opposed to the registry not having
369 /// been answerable at all (network failure, timeout, malformed response, 5xx).
370 ///
371 /// Distinguishing the two matters for diagnostics (#267): a genuine not-found is
372 /// evidence the package name is wrong, while any other error is evidence only that
373 /// this particular request failed — reporting the latter as "Unknown package" would
374 /// mislabel a transient registry outage as a nonexistent dependency. Covers
375 /// [`DepsError::PackageNotFound`] (the ecosystems that map a 404 to it explicitly:
376 /// npm, PyPI, Go, Swift) and a bare [`DepsError::HttpStatus`] with `status == 404`
377 /// (the ecosystems that propagate the raw HTTP status instead: Cargo, Maven, Gradle,
378 /// Bundler, Dart, Composer, NuGet).
379 ///
380 /// # Examples
381 ///
382 /// ```
383 /// use deps_core::DepsError;
384 ///
385 /// let not_found = DepsError::PackageNotFound {
386 /// package: "left-pad".into(),
387 /// registry: "npm",
388 /// };
389 /// assert!(not_found.is_not_found());
390 ///
391 /// let http_404 = DepsError::HttpStatus {
392 /// url: "https://crates.io/api/v1/crates/left-pad".into(),
393 /// status: 404,
394 /// };
395 /// assert!(http_404.is_not_found());
396 ///
397 /// let outage = DepsError::HttpStatus {
398 /// url: "https://crates.io/api/v1/crates/serde".into(),
399 /// status: 503,
400 /// };
401 /// assert!(!outage.is_not_found());
402 ///
403 /// let cache_err = DepsError::CacheError("connection reset".into());
404 /// assert!(!cache_err.is_not_found());
405 /// ```
406 #[must_use]
407 pub const fn is_not_found(&self) -> bool {
408 matches!(
409 self,
410 Self::PackageNotFound { .. } | Self::HttpStatus { status: 404, .. }
411 )
412 }
413
414 /// Classifies this error for the per-dependency "registry lookup failed" diagnostic
415 /// (#478), distinguishing a failure with a safe, actionable hint to show the user from
416 /// one whose raw text must never reach a diagnostic.
417 ///
418 /// **Security-load-bearing invariant**: [`FetchFailure::Actionable`] is produced *only*
419 /// from [`Self::RateLimited`]'s pre-vetted, IP-free canned message. Every other variant
420 /// must classify as [`FetchFailure::Transient`] — never call `.to_string()`/`Display` on
421 /// an arbitrary `DepsError` to build an `Actionable` value, since a raw `HttpStatus` or
422 /// `RegistryError` body can embed the caller's public IP (`github.rs:332-346`, exercised
423 /// by the `github` crate's `test_parse_tags_page_github_rate_limit_returns_error`).
424 ///
425 /// # Examples
426 ///
427 /// ```
428 /// use deps_core::error::{DepsError, FetchFailure};
429 ///
430 /// let rate_limited = DepsError::RateLimited { message: "set GITHUB_TOKEN".into() };
431 /// assert_eq!(
432 /// rate_limited.fetch_failure(),
433 /// FetchFailure::Actionable("set GITHUB_TOKEN".into())
434 /// );
435 ///
436 /// let other = DepsError::CacheError("connection reset".into());
437 /// assert_eq!(other.fetch_failure(), FetchFailure::Transient);
438 /// ```
439 #[must_use]
440 pub fn fetch_failure(&self) -> FetchFailure {
441 match self {
442 Self::RateLimited { message } => FetchFailure::Actionable(message.clone()),
443 // Fixed, pre-vetted message — see `Self::ChainResolutionHalted`'s own doc for why
444 // this is safe to build as `Actionable` the same way `RateLimited` is.
445 Self::ChainResolutionHalted => FetchFailure::Actionable(
446 "index unreachable — resolution halted, not falling back to a less-trusted \
447 index"
448 .to_string(),
449 ),
450 _ => FetchFailure::Transient,
451 }
452 }
453
454 /// Returns `true` when this error means a request was blocked by `network.offline`
455 /// (issue #483), as opposed to any other network or registry failure.
456 ///
457 /// Used by `deps_maven::registry` to skip poisoning its negative-search-failure
458 /// cache with an offline block, so toggling `network.offline` back to `false` takes
459 /// effect immediately instead of being masked by `RECENT_FAILURE_TTL`.
460 ///
461 /// # Examples
462 ///
463 /// ```
464 /// use deps_core::DepsError;
465 ///
466 /// let offline = DepsError::Offline { url: "https://crates.io/".into() };
467 /// assert!(offline.is_offline());
468 ///
469 /// let other = DepsError::CacheError("connection reset".into());
470 /// assert!(!other.is_offline());
471 /// ```
472 #[must_use]
473 pub const fn is_offline(&self) -> bool {
474 matches!(self, Self::Offline { .. })
475 }
476
477 /// A URL-free summary of this error, safe to attach to a `tracing` field or log line at
478 /// an outbound-request chokepoint. [`Self::HttpStatus`], [`Self::Offline`],
479 /// [`Self::ResponseTooLarge`], and [`Self::RegistryError`]'s own `Display` now redact
480 /// their URL via [`RedactedUrl`] (#767, #789), but this summary deliberately still never
481 /// derives from `self`'s `Display`/`Debug`: a future variant added here should not be
482 /// able to reintroduce a leak just by being included in a `{self}` interpolation.
483 /// [`Self::RegistryError`]'s wrapped [`SanitizedRegistryError`] can no longer re-embed
484 /// the raw URL through its own `Display` either way, since its only constructor strips
485 /// it unconditionally.
486 ///
487 /// Returns the HTTP status code when this is [`Self::HttpStatus`], plus a coarse,
488 /// URL-free cause discriminant for every variant — so a routine transport
489 /// failure/timeout still carries some triage signal instead of collapsing to `status =
490 /// None` with nothing else.
491 #[must_use]
492 pub(crate) const fn safe_tracing_summary(&self) -> (Option<u16>, &'static str) {
493 match self {
494 Self::HttpStatus { status, .. } => (Some(*status), "http-status"),
495 Self::RegistryError { .. } => (None, "transport"),
496 Self::CacheError(_) => (None, "cache"),
497 Self::Offline { .. } => (None, "offline"),
498 Self::ResponseTooLarge { .. } => (None, "response-too-large"),
499 Self::RateLimited { .. } => (None, "rate-limited"),
500 Self::ApiResponse { .. } => (None, "api-response"),
501 Self::PackageNotFound { .. } => (None, "not-found"),
502 _ => (None, "other"),
503 }
504 }
505}
506
507/// Outcome of a registry fetch attempt for one dependency, as recorded in
508/// `DocumentState::outcomes` (`deps-lsp`) and rendered by
509/// [`crate::lsp_helpers::generate_diagnostics_from_cache`] (#478).
510///
511/// Replaces a bare `HashSet<PackageName>` membership check so the per-dependency diagnostic
512/// can distinguish a failure with a safe, user-actionable hint from an opaque one, without
513/// ever threading raw, potentially IP-bearing error text into the diagnostic (see
514/// [`DepsError::fetch_failure`]).
515#[non_exhaustive]
516#[derive(Clone, Debug, PartialEq, Eq)]
517pub enum FetchFailure {
518 /// The fetch failed with a pre-vetted, safe-to-display hint (currently only produced
519 /// from [`DepsError::RateLimited`]).
520 Actionable(String),
521 /// The fetch failed for a reason with no safe user-facing detail to show — the
522 /// diagnostic falls back to a generic "lookup failed" message.
523 Transient,
524 /// The dependency was never actually queried (e.g. a name/source collision detected
525 /// before the fetch, see `deps-lsp`'s `dedup_dependencies_by_source`) — renders the
526 /// same generic "lookup failed" message as [`Self::Transient`], since the absence of
527 /// an attempt is not evidence the package doesn't exist.
528 NotAttempted,
529}
530
531/// Convenience type alias for `Result<T, DepsError>`.
532///
533/// This is the standard `Result` type used throughout the deps-lsp codebase.
534/// It simplifies function signatures by defaulting the error type to `DepsError`.
535///
536/// # Examples
537///
538/// ```
539/// use deps_core::error::Result;
540///
541/// fn get_version(name: &str) -> Result<String> {
542/// if name.is_empty() {
543/// return Err(deps_core::error::DepsError::CacheError("empty name".into()));
544/// }
545/// Ok("1.0.0".into())
546/// }
547/// ```
548pub type Result<T> = std::result::Result<T, DepsError>;
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use std::error::Error as StdError;
554
555 #[test]
556 fn test_error_display() {
557 let error = DepsError::CacheError("test error".into());
558 assert_eq!(error.to_string(), "cache error: test error");
559 }
560
561 /// #789: `SanitizedRegistryError`'s own `Display` must never re-embed the request URL,
562 /// even when the wrapped `reqwest::Error` genuinely carries one — a builder-only error
563 /// like `Client::get("not a url").build().unwrap_err()` never populates `.url()`, so this
564 /// needs a real transport-level failure (connection refused on a closed loopback port) to
565 /// actually exercise the strip, mirroring `deps_core::cache`'s own
566 /// `test_registry_error_source_redacts_url_on_real_transport_error`.
567 #[tokio::test]
568 async fn test_sanitized_registry_error_strips_url_on_real_transport_error() {
569 let port = {
570 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
571 listener.local_addr().unwrap().port()
572 };
573 let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");
574
575 let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
576 assert!(
577 reqwest_err.url().is_some(),
578 "test setup invariant: the underlying reqwest::Error must carry a URL"
579 );
580
581 let sanitized: SanitizedRegistryError = reqwest_err.into();
582 assert!(
583 !sanitized.to_string().contains("super-secret-value"),
584 "sanitized: {sanitized}"
585 );
586 assert!(
587 !format!("{sanitized:?}").contains("super-secret-value"),
588 "sanitized debug: {sanitized:?}"
589 );
590 }
591
592 /// #789 S2: `SanitizedRegistryError` must treat itself as a leaf node for source-chaining
593 /// purposes — `reqwest`'s redirect policy can nest a *second* `reqwest::Error` (with its
594 /// own populated `url` field) as the outer error's `source` (see the type's own docs for
595 /// the exact `https_only`/redirect code path), which `.without_url()` on the outer error
596 /// alone does not touch. This proves the wrapper discards a real, populated source chain
597 /// (not merely that one never existed): the *raw* wrapped `reqwest::Error` behind
598 /// `sanitized.0` (accessible here since `mod tests` is a child of `error`) genuinely has
599 /// `.source().is_some()` for a real connection-refused failure, yet `SanitizedRegistryError`'s
600 /// own `source()` is unconditionally `None`.
601 #[tokio::test]
602 async fn test_sanitized_registry_error_source_is_always_none() {
603 let port = {
604 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
605 listener.local_addr().unwrap().port()
606 };
607 let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");
608
609 let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
610 let sanitized: SanitizedRegistryError = reqwest_err.into();
611 assert!(
612 sanitized.0.source().is_some(),
613 "test setup invariant: a connection-refused error must have a populated source chain"
614 );
615 assert!(
616 StdError::source(&sanitized).is_none(),
617 "SanitizedRegistryError::source() must always be None"
618 );
619 }
620
621 /// #756 code-review finding: `safe_tracing_summary` must never derive its output from
622 /// `self`'s own `Display`/`Debug` (both embed the raw, unredacted request URL for several
623 /// variants) — pinning the exact `(status, cause)` pairs for every variant a `HttpCache`/
624 /// `OsvClient` call site can actually produce, so a routine transport failure still
625 /// carries triage signal (`cause`) instead of collapsing to `(None, "other")`.
626 #[test]
627 fn test_safe_tracing_summary_covers_every_reachable_variant() {
628 assert_eq!(
629 DepsError::HttpStatus {
630 url: "https://example.com/pkg?token=secret".into(),
631 status: 503,
632 }
633 .safe_tracing_summary(),
634 (Some(503), "http-status")
635 );
636 assert_eq!(
637 DepsError::RegistryError {
638 package: "https://example.com/pkg?token=secret".into(),
639 source: reqwest::Client::new()
640 .get("not a url")
641 .build()
642 .unwrap_err()
643 .into(),
644 }
645 .safe_tracing_summary(),
646 (None, "transport")
647 );
648 assert_eq!(
649 DepsError::CacheError("poisoned lock".into()).safe_tracing_summary(),
650 (None, "cache")
651 );
652 assert_eq!(
653 DepsError::Offline {
654 url: "https://example.com/pkg?token=secret".into(),
655 }
656 .safe_tracing_summary(),
657 (None, "offline")
658 );
659 assert_eq!(
660 DepsError::ResponseTooLarge {
661 url: "https://example.com/pkg?token=secret".into(),
662 limit: 1024,
663 }
664 .safe_tracing_summary(),
665 (None, "response-too-large")
666 );
667 }
668
669 /// A `(status, cause)` pair must never itself carry the URL — the whole point of this
670 /// method — even when the source error's own `Display` would have.
671 #[test]
672 fn test_safe_tracing_summary_output_never_contains_the_url() {
673 let error = DepsError::HttpStatus {
674 url: "https://npm.internal/pkg?token=super-secret-value".into(),
675 status: 503,
676 };
677 let (status, cause) = error.safe_tracing_summary();
678 assert_eq!(status, Some(503));
679 assert!(!cause.contains("super-secret-value"));
680 assert!(!cause.contains("npm.internal"));
681 }
682
683 #[test]
684 fn test_response_too_large() {
685 let error = DepsError::ResponseTooLarge {
686 url: "https://example.com/data".into(),
687 limit: 32 * 1024 * 1024,
688 };
689 assert_eq!(
690 error.to_string(),
691 "response body for https://example.com/data exceeds 33554432 byte limit"
692 );
693 }
694
695 /// #767: `HttpStatus`'s `Display` is surfaced verbatim through `window/showMessage`
696 /// (`deps-lsp`'s fetch-failure toast), so a query-string credential (e.g. an `.npmrc`
697 /// `?_authToken=...` value after `${VAR}` expansion) must never reach it.
698 #[test]
699 fn test_http_status_display_redacts_query_string() {
700 let error = DepsError::HttpStatus {
701 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
702 status: 503,
703 };
704 let message = error.to_string();
705 assert!(
706 !message.contains("super-secret-value"),
707 "message: {message}"
708 );
709 assert_eq!(
710 message,
711 "HTTP 503 Service Unavailable for https://npm.internal/pkg"
712 );
713 }
714
715 /// #767 companion for [`DepsError::RegistryError`], whose `package` field is frequently
716 /// populated with a raw URL rather than a package name (`deps-core::cache`'s
717 /// `read_body_capped`/`get_cached_with_headers_via`/`post_json`/`get_cached_bytes`).
718 #[test]
719 fn test_registry_error_display_redacts_query_string() {
720 let error = DepsError::RegistryError {
721 package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
722 source: reqwest::Client::new()
723 .get("not a url")
724 .build()
725 .unwrap_err()
726 .into(),
727 };
728 let message = error.to_string();
729 assert!(
730 !message.contains("super-secret-value"),
731 "message: {message}"
732 );
733 assert!(message.starts_with("registry request failed for https://npm.internal/pkg:"));
734 }
735
736 /// #767 M1: `RegistryError::package` is documented as a package name, and an npm-scoped
737 /// name (leading `@`, no scheme) must never be mangled by the URL redaction meant for
738 /// call sites that populate this field with a URL instead — reproduced false positive:
739 /// `url_for_tracing`'s unparseable-URL fallback previously misread `@types/node` as
740 /// `user@host`-shaped userinfo and rendered `***@types/node`.
741 #[test]
742 fn test_registry_error_display_does_not_mangle_scoped_package_name() {
743 let error = DepsError::RegistryError {
744 package: "@types/node".into(),
745 source: reqwest::Client::new()
746 .get("not a url")
747 .build()
748 .unwrap_err()
749 .into(),
750 };
751 let message = error.to_string();
752 assert!(
753 message.starts_with("registry request failed for @types/node:"),
754 "message: {message}"
755 );
756 }
757
758 /// #767: `Offline`'s `Display` also embeds the blocked request URL — same redaction
759 /// applies.
760 #[test]
761 fn test_offline_display_redacts_query_string() {
762 let error = DepsError::Offline {
763 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
764 };
765 let message = error.to_string();
766 assert!(
767 !message.contains("super-secret-value"),
768 "message: {message}"
769 );
770 }
771
772 /// #767: `ResponseTooLarge`'s `Display` also embeds the source URL — same redaction
773 /// applies.
774 #[test]
775 fn test_response_too_large_display_redacts_query_string() {
776 let error = DepsError::ResponseTooLarge {
777 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
778 limit: 1024,
779 };
780 let message = error.to_string();
781 assert!(
782 !message.contains("super-secret-value"),
783 "message: {message}"
784 );
785 }
786
787 /// Code-review follow-up on #767: `DepsError` derives `Debug` no longer — a derived
788 /// impl would print `HttpStatus.url`/`Offline.url`/`ResponseTooLarge.url`/
789 /// `RegistryError.package` raw, so any `tracing::warn!(?err, ...)`/`{err:?}` call site
790 /// (a common, arguably more idiomatic alternative to `%err`) would bypass every
791 /// hand-written `Display` redaction above and reintroduce the leak. Covers all four
792 /// URL-shaped variants via `{:?}`.
793 #[test]
794 fn test_debug_redacts_query_string_for_every_url_bearing_variant() {
795 let credential_bearing = [
796 DepsError::HttpStatus {
797 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
798 status: 503,
799 },
800 DepsError::Offline {
801 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
802 },
803 DepsError::ResponseTooLarge {
804 url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
805 limit: 1024,
806 },
807 DepsError::RegistryError {
808 package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
809 source: reqwest::Client::new()
810 .get("not a url")
811 .build()
812 .unwrap_err()
813 .into(),
814 },
815 ];
816 for error in credential_bearing {
817 let debug = format!("{error:?}");
818 assert!(
819 !debug.contains("super-secret-value"),
820 "Debug leaked the credential for {error}: {debug}"
821 );
822 }
823 }
824
825 /// #789 S1 regression: destructuring `HttpStatus.url` directly (bypassing `Display`/
826 /// `Debug` entirely) must still yield only redacted text — the field's type itself
827 /// (`RedactedUrl`, not `String`) is what makes this true, not any redaction step at the
828 /// access site. There is no way to get the raw string back out even via direct field
829 /// access, because nothing raw was ever stored in the field to begin with.
830 #[test]
831 fn test_http_status_url_field_is_redacted_even_via_direct_destructure() {
832 let error = DepsError::HttpStatus {
833 url: RedactedUrl::new("https://npm.internal/pkg?_authToken=super-secret-value"),
834 status: 401,
835 };
836 let DepsError::HttpStatus { url, .. } = error else {
837 unreachable!()
838 };
839 assert_eq!(url.to_string(), "https://npm.internal/pkg");
840 assert!(!url.to_string().contains("super-secret-value"));
841 }
842
843 /// `Debug` must still show every field for the ordinary, non-URL-bearing variants —
844 /// this is a redaction fix, not a summary, so field values unrelated to a URL must
845 /// come through unchanged.
846 #[test]
847 fn test_debug_still_shows_non_url_fields() {
848 let error = DepsError::PackageNotFound {
849 package: "left-pad".into(),
850 registry: "npm",
851 };
852 let debug = format!("{error:?}");
853 assert!(debug.contains("left-pad"), "debug: {debug}");
854 assert!(debug.contains("npm"), "debug: {debug}");
855 }
856
857 #[test]
858 fn test_invalid_version_req() {
859 let error = DepsError::InvalidVersionReq("invalid".into());
860 assert_eq!(error.to_string(), "invalid version requirement: invalid");
861 }
862
863 #[test]
864 fn test_parse_error() {
865 let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data");
866 let error = DepsError::ParseError {
867 file_type: "Cargo.toml".into(),
868 source: Box::new(io_err),
869 };
870 assert!(error.to_string().contains("failed to parse Cargo.toml"));
871 }
872
873 #[test]
874 fn test_io_error_conversion() {
875 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
876 let error: DepsError = io_err.into();
877 assert!(error.to_string().contains("I/O error"));
878 }
879
880 #[test]
881 fn test_unsupported_ecosystem() {
882 let error = DepsError::UnsupportedEcosystem("unknown".into());
883 assert_eq!(error.to_string(), "unsupported ecosystem: unknown");
884 }
885
886 #[test]
887 fn test_ambiguous_ecosystem() {
888 let error = DepsError::AmbiguousEcosystem("file.txt".into());
889 assert_eq!(
890 error.to_string(),
891 "ambiguous ecosystem detection for file: file.txt"
892 );
893 }
894
895 #[test]
896 fn test_invalid_uri() {
897 let error = DepsError::InvalidUri("http://example.com".into());
898 assert_eq!(error.to_string(), "invalid URI: http://example.com");
899 }
900
901 #[test]
902 fn test_offline_error_display_and_predicate() {
903 let error = DepsError::Offline {
904 url: "https://crates.io/api/v1/crates/serde".into(),
905 };
906 assert!(error.to_string().contains("offline"));
907 assert!(error.is_offline());
908 assert!(!error.is_not_found());
909
910 let other = DepsError::CacheError("boom".into());
911 assert!(!other.is_offline());
912 }
913
914 #[test]
915 fn test_package_not_found() {
916 let error = DepsError::PackageNotFound {
917 package: "flask".into(),
918 registry: "PyPI",
919 };
920 assert_eq!(error.to_string(), "flask not found on PyPI");
921 }
922
923 #[test]
924 fn test_http_status_with_known_reason() {
925 let error = DepsError::HttpStatus {
926 url: "https://example.com/data".into(),
927 status: 404,
928 };
929 assert_eq!(
930 error.to_string(),
931 "HTTP 404 Not Found for https://example.com/data"
932 );
933 }
934
935 #[test]
936 fn test_http_status_with_unknown_code() {
937 let error = DepsError::HttpStatus {
938 url: "https://example.com/data".into(),
939 status: 599,
940 };
941 assert_eq!(error.to_string(), "HTTP 599 for https://example.com/data");
942 }
943
944 #[test]
945 fn test_api_response_error() {
946 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
947 let error = DepsError::ApiResponse {
948 package: "flask".into(),
949 registry: "PyPI",
950 source: json_err,
951 };
952 assert!(
953 error
954 .to_string()
955 .starts_with("failed to parse PyPI response for flask:")
956 );
957 }
958
959 /// Exhaustive companion to the doc-test on [`DepsError::fetch_failure`]: every variant
960 /// other than [`DepsError::RateLimited`] and [`DepsError::ChainResolutionHalted`] must
961 /// classify as [`FetchFailure::Transient`]. This is the invariant the doc comment calls
962 /// security-load-bearing (a future variant wired to `Actionable` by mistake could leak
963 /// raw, potentially IP-bearing error text into a diagnostic), so it must be a real test
964 /// enumerating every variant, not just a handful of spot checks. `ChainResolutionHalted`
965 /// is exempted from the "everything else is Transient" list — like `RateLimited`, it
966 /// carries no arbitrary payload, only a fixed, pre-vetted message, so it is safe to be
967 /// the second `Actionable`-producing variant (see its own doc and #513's M2 fix).
968 #[test]
969 fn test_fetch_failure_classifies_every_non_rate_limited_variant_as_transient() {
970 // A `reqwest::Error` built from an invalid URL — `RequestBuilder::build`
971 // is synchronous and fails on URL parsing alone, so this needs no network
972 // access or async runtime.
973 let reqwest_err = reqwest::Client::new()
974 .get("not a valid url")
975 .build()
976 .unwrap_err();
977 let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
978 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
979
980 let non_rate_limited = [
981 DepsError::ParseError {
982 file_type: "Cargo.toml".into(),
983 source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
984 },
985 DepsError::RegistryError {
986 package: "flask".into(),
987 source: reqwest_err.into(),
988 },
989 DepsError::CacheError("connection reset".into()),
990 DepsError::PackageNotFound {
991 package: "flask".into(),
992 registry: "PyPI",
993 },
994 DepsError::HttpStatus {
995 url: "https://example.com".into(),
996 status: 500,
997 },
998 DepsError::ApiResponse {
999 package: "flask".into(),
1000 registry: "PyPI",
1001 source: json_err,
1002 },
1003 DepsError::ResponseTooLarge {
1004 url: "https://example.com".into(),
1005 limit: 1024,
1006 },
1007 DepsError::InvalidVersionReq("bad range".into()),
1008 DepsError::Io(io_err),
1009 DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err()),
1010 DepsError::UnsupportedEcosystem("unknown".into()),
1011 DepsError::AmbiguousEcosystem("file.txt".into()),
1012 DepsError::InvalidUri("not a uri".into()),
1013 DepsError::Offline {
1014 url: "https://example.com".into(),
1015 },
1016 ];
1017
1018 for error in non_rate_limited {
1019 assert_eq!(
1020 error.fetch_failure(),
1021 FetchFailure::Transient,
1022 "expected Transient for {error:?}"
1023 );
1024 }
1025
1026 let rate_limited = DepsError::RateLimited {
1027 message: "set GITHUB_TOKEN to increase the rate limit".into(),
1028 };
1029 assert_eq!(
1030 rate_limited.fetch_failure(),
1031 FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".into())
1032 );
1033
1034 assert_eq!(
1035 DepsError::ChainResolutionHalted.fetch_failure(),
1036 FetchFailure::Actionable(
1037 "index unreachable — resolution halted, not falling back to a less-trusted \
1038 index"
1039 .to_string()
1040 )
1041 );
1042 }
1043}