Skip to main content

deps_core/
error.rs

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