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