Skip to main content

deps_core/
error.rs

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