Skip to main content

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}