use thiserror::Error;
use crate::net_policy::RedactedUrl;
fn http_status_message(status: u16, url: &RedactedUrl) -> String {
let reason = reqwest::StatusCode::from_u16(status)
.ok()
.and_then(|s| s.canonical_reason());
reason.map_or_else(
|| format!("HTTP {status} for {url}"),
|reason| format!("HTTP {status} {reason} for {url}"),
)
}
fn registry_error_message(package: &RedactedUrl, source: &SanitizedRegistryError) -> String {
format!("registry request failed for {package}: {source}")
}
fn response_too_large_message(url: &RedactedUrl, limit: usize) -> String {
format!("response body for {url} exceeds {limit} byte limit")
}
fn offline_message(url: &RedactedUrl) -> String {
format!("offline: request to {url} was blocked by network.offline")
}
pub struct SanitizedRegistryError(reqwest::Error);
impl From<reqwest::Error> for SanitizedRegistryError {
fn from(error: reqwest::Error) -> Self {
Self(error.without_url())
}
}
impl std::fmt::Display for SanitizedRegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl std::fmt::Debug for SanitizedRegistryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SanitizedRegistryError({})", self.0)
}
}
impl std::error::Error for SanitizedRegistryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
#[non_exhaustive]
#[derive(Error)]
pub enum DepsError {
#[error("failed to parse {file_type}: {source}")]
ParseError {
file_type: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("{}", registry_error_message(package, source))]
RegistryError {
package: RedactedUrl,
#[source]
source: SanitizedRegistryError,
},
#[error("cache error: {0}")]
CacheError(String),
#[error("{message}")]
RateLimited {
message: String,
},
#[error("{package} not found on {registry}")]
PackageNotFound {
package: String,
registry: &'static str,
},
#[error("{}", http_status_message(*status, url))]
HttpStatus {
url: RedactedUrl,
status: u16,
},
#[error("failed to parse {registry} response for {package}: {source}")]
ApiResponse {
package: String,
registry: &'static str,
#[source]
source: serde_json::Error,
},
#[error("{}", response_too_large_message(url, *limit))]
ResponseTooLarge {
url: RedactedUrl,
limit: usize,
},
#[error("invalid version requirement: {0}")]
InvalidVersionReq(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("unsupported ecosystem: {0}")]
UnsupportedEcosystem(String),
#[error("ambiguous ecosystem detection for file: {0}")]
AmbiguousEcosystem(String),
#[error("invalid URI: {0}")]
InvalidUri(String),
#[error("{}", offline_message(url))]
Offline {
url: RedactedUrl,
},
#[error(
"index chain resolution halted by a transport error on one hop — not falling back \
to a less-trusted index"
)]
ChainResolutionHalted,
}
impl std::fmt::Debug for DepsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ParseError { file_type, source } => f
.debug_struct("ParseError")
.field("file_type", file_type)
.field("source", source)
.finish(),
Self::RegistryError { package, source } => f
.debug_struct("RegistryError")
.field("package", package)
.field("source", source)
.finish(),
Self::CacheError(message) => f.debug_tuple("CacheError").field(message).finish(),
Self::RateLimited { message } => f
.debug_struct("RateLimited")
.field("message", message)
.finish(),
Self::PackageNotFound { package, registry } => f
.debug_struct("PackageNotFound")
.field("package", package)
.field("registry", registry)
.finish(),
Self::HttpStatus { url, status } => f
.debug_struct("HttpStatus")
.field("url", url)
.field("status", status)
.finish(),
Self::ApiResponse {
package,
registry,
source,
} => f
.debug_struct("ApiResponse")
.field("package", package)
.field("registry", registry)
.field("source", source)
.finish(),
Self::ResponseTooLarge { url, limit } => f
.debug_struct("ResponseTooLarge")
.field("url", url)
.field("limit", limit)
.finish(),
Self::InvalidVersionReq(req) => f.debug_tuple("InvalidVersionReq").field(req).finish(),
Self::Io(source) => f.debug_tuple("Io").field(source).finish(),
Self::Json(source) => f.debug_tuple("Json").field(source).finish(),
Self::UnsupportedEcosystem(ecosystem) => f
.debug_tuple("UnsupportedEcosystem")
.field(ecosystem)
.finish(),
Self::AmbiguousEcosystem(path) => {
f.debug_tuple("AmbiguousEcosystem").field(path).finish()
}
Self::InvalidUri(uri) => f.debug_tuple("InvalidUri").field(uri).finish(),
Self::Offline { url } => f.debug_struct("Offline").field("url", url).finish(),
Self::ChainResolutionHalted => f.write_str("ChainResolutionHalted"),
}
}
}
impl DepsError {
#[must_use]
pub const fn is_not_found(&self) -> bool {
matches!(
self,
Self::PackageNotFound { .. } | Self::HttpStatus { status: 404, .. }
)
}
#[must_use]
pub fn fetch_failure(&self) -> FetchFailure {
match self {
Self::RateLimited { message } => FetchFailure::Actionable(message.clone()),
Self::ChainResolutionHalted => FetchFailure::Actionable(
"index unreachable — resolution halted, not falling back to a less-trusted \
index"
.to_string(),
),
_ => FetchFailure::Transient,
}
}
#[must_use]
pub const fn is_offline(&self) -> bool {
matches!(self, Self::Offline { .. })
}
#[must_use]
pub(crate) const fn safe_tracing_summary(&self) -> (Option<u16>, &'static str) {
match self {
Self::HttpStatus { status, .. } => (Some(*status), "http-status"),
Self::RegistryError { .. } => (None, "transport"),
Self::CacheError(_) => (None, "cache"),
Self::Offline { .. } => (None, "offline"),
Self::ResponseTooLarge { .. } => (None, "response-too-large"),
Self::RateLimited { .. } => (None, "rate-limited"),
Self::ApiResponse { .. } => (None, "api-response"),
Self::PackageNotFound { .. } => (None, "not-found"),
_ => (None, "other"),
}
}
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FetchFailure {
Actionable(String),
Transient,
NotAttempted,
}
pub type Result<T> = std::result::Result<T, DepsError>;
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as StdError;
#[test]
fn test_error_display() {
let error = DepsError::CacheError("test error".into());
assert_eq!(error.to_string(), "cache error: test error");
}
#[tokio::test]
async fn test_sanitized_registry_error_strips_url_on_real_transport_error() {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");
let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
assert!(
reqwest_err.url().is_some(),
"test setup invariant: the underlying reqwest::Error must carry a URL"
);
let sanitized: SanitizedRegistryError = reqwest_err.into();
assert!(
!sanitized.to_string().contains("super-secret-value"),
"sanitized: {sanitized}"
);
assert!(
!format!("{sanitized:?}").contains("super-secret-value"),
"sanitized debug: {sanitized:?}"
);
}
#[tokio::test]
async fn test_sanitized_registry_error_source_is_always_none() {
let port = {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.local_addr().unwrap().port()
};
let url = format!("http://127.0.0.1:{port}/pkg?token=super-secret-value");
let reqwest_err = reqwest::Client::new().get(&url).send().await.unwrap_err();
let sanitized: SanitizedRegistryError = reqwest_err.into();
assert!(
sanitized.0.source().is_some(),
"test setup invariant: a connection-refused error must have a populated source chain"
);
assert!(
StdError::source(&sanitized).is_none(),
"SanitizedRegistryError::source() must always be None"
);
}
#[test]
fn test_safe_tracing_summary_covers_every_reachable_variant() {
assert_eq!(
DepsError::HttpStatus {
url: "https://example.com/pkg?token=secret".into(),
status: 503,
}
.safe_tracing_summary(),
(Some(503), "http-status")
);
assert_eq!(
DepsError::RegistryError {
package: "https://example.com/pkg?token=secret".into(),
source: reqwest::Client::new()
.get("not a url")
.build()
.unwrap_err()
.into(),
}
.safe_tracing_summary(),
(None, "transport")
);
assert_eq!(
DepsError::CacheError("poisoned lock".into()).safe_tracing_summary(),
(None, "cache")
);
assert_eq!(
DepsError::Offline {
url: "https://example.com/pkg?token=secret".into(),
}
.safe_tracing_summary(),
(None, "offline")
);
assert_eq!(
DepsError::ResponseTooLarge {
url: "https://example.com/pkg?token=secret".into(),
limit: 1024,
}
.safe_tracing_summary(),
(None, "response-too-large")
);
}
#[test]
fn test_safe_tracing_summary_output_never_contains_the_url() {
let error = DepsError::HttpStatus {
url: "https://npm.internal/pkg?token=super-secret-value".into(),
status: 503,
};
let (status, cause) = error.safe_tracing_summary();
assert_eq!(status, Some(503));
assert!(!cause.contains("super-secret-value"));
assert!(!cause.contains("npm.internal"));
}
#[test]
fn test_response_too_large() {
let error = DepsError::ResponseTooLarge {
url: "https://example.com/data".into(),
limit: 32 * 1024 * 1024,
};
assert_eq!(
error.to_string(),
"response body for https://example.com/data exceeds 33554432 byte limit"
);
}
#[test]
fn test_http_status_display_redacts_query_string() {
let error = DepsError::HttpStatus {
url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
status: 503,
};
let message = error.to_string();
assert!(
!message.contains("super-secret-value"),
"message: {message}"
);
assert_eq!(
message,
"HTTP 503 Service Unavailable for https://npm.internal/pkg"
);
}
#[test]
fn test_registry_error_display_redacts_query_string() {
let error = DepsError::RegistryError {
package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
source: reqwest::Client::new()
.get("not a url")
.build()
.unwrap_err()
.into(),
};
let message = error.to_string();
assert!(
!message.contains("super-secret-value"),
"message: {message}"
);
assert!(message.starts_with("registry request failed for https://npm.internal/pkg:"));
}
#[test]
fn test_registry_error_display_does_not_mangle_scoped_package_name() {
let error = DepsError::RegistryError {
package: "@types/node".into(),
source: reqwest::Client::new()
.get("not a url")
.build()
.unwrap_err()
.into(),
};
let message = error.to_string();
assert!(
message.starts_with("registry request failed for @types/node:"),
"message: {message}"
);
}
#[test]
fn test_offline_display_redacts_query_string() {
let error = DepsError::Offline {
url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
};
let message = error.to_string();
assert!(
!message.contains("super-secret-value"),
"message: {message}"
);
}
#[test]
fn test_response_too_large_display_redacts_query_string() {
let error = DepsError::ResponseTooLarge {
url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
limit: 1024,
};
let message = error.to_string();
assert!(
!message.contains("super-secret-value"),
"message: {message}"
);
}
#[test]
fn test_debug_redacts_query_string_for_every_url_bearing_variant() {
let credential_bearing = [
DepsError::HttpStatus {
url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
status: 503,
},
DepsError::Offline {
url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
},
DepsError::ResponseTooLarge {
url: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
limit: 1024,
},
DepsError::RegistryError {
package: "https://npm.internal/pkg?_authToken=super-secret-value".into(),
source: reqwest::Client::new()
.get("not a url")
.build()
.unwrap_err()
.into(),
},
];
for error in credential_bearing {
let debug = format!("{error:?}");
assert!(
!debug.contains("super-secret-value"),
"Debug leaked the credential for {error}: {debug}"
);
}
}
#[test]
fn test_http_status_url_field_is_redacted_even_via_direct_destructure() {
let error = DepsError::HttpStatus {
url: RedactedUrl::new("https://npm.internal/pkg?_authToken=super-secret-value"),
status: 401,
};
let DepsError::HttpStatus { url, .. } = error else {
unreachable!()
};
assert_eq!(url.to_string(), "https://npm.internal/pkg");
assert!(!url.to_string().contains("super-secret-value"));
}
#[test]
fn test_debug_still_shows_non_url_fields() {
let error = DepsError::PackageNotFound {
package: "left-pad".into(),
registry: "npm",
};
let debug = format!("{error:?}");
assert!(debug.contains("left-pad"), "debug: {debug}");
assert!(debug.contains("npm"), "debug: {debug}");
}
#[test]
fn test_invalid_version_req() {
let error = DepsError::InvalidVersionReq("invalid".into());
assert_eq!(error.to_string(), "invalid version requirement: invalid");
}
#[test]
fn test_parse_error() {
let io_err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad data");
let error = DepsError::ParseError {
file_type: "Cargo.toml".into(),
source: Box::new(io_err),
};
assert!(error.to_string().contains("failed to parse Cargo.toml"));
}
#[test]
fn test_io_error_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
let error: DepsError = io_err.into();
assert!(error.to_string().contains("I/O error"));
}
#[test]
fn test_unsupported_ecosystem() {
let error = DepsError::UnsupportedEcosystem("unknown".into());
assert_eq!(error.to_string(), "unsupported ecosystem: unknown");
}
#[test]
fn test_ambiguous_ecosystem() {
let error = DepsError::AmbiguousEcosystem("file.txt".into());
assert_eq!(
error.to_string(),
"ambiguous ecosystem detection for file: file.txt"
);
}
#[test]
fn test_invalid_uri() {
let error = DepsError::InvalidUri("http://example.com".into());
assert_eq!(error.to_string(), "invalid URI: http://example.com");
}
#[test]
fn test_offline_error_display_and_predicate() {
let error = DepsError::Offline {
url: "https://crates.io/api/v1/crates/serde".into(),
};
assert!(error.to_string().contains("offline"));
assert!(error.is_offline());
assert!(!error.is_not_found());
let other = DepsError::CacheError("boom".into());
assert!(!other.is_offline());
}
#[test]
fn test_package_not_found() {
let error = DepsError::PackageNotFound {
package: "flask".into(),
registry: "PyPI",
};
assert_eq!(error.to_string(), "flask not found on PyPI");
}
#[test]
fn test_http_status_with_known_reason() {
let error = DepsError::HttpStatus {
url: "https://example.com/data".into(),
status: 404,
};
assert_eq!(
error.to_string(),
"HTTP 404 Not Found for https://example.com/data"
);
}
#[test]
fn test_http_status_with_unknown_code() {
let error = DepsError::HttpStatus {
url: "https://example.com/data".into(),
status: 599,
};
assert_eq!(error.to_string(), "HTTP 599 for https://example.com/data");
}
#[test]
fn test_api_response_error() {
let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
let error = DepsError::ApiResponse {
package: "flask".into(),
registry: "PyPI",
source: json_err,
};
assert!(
error
.to_string()
.starts_with("failed to parse PyPI response for flask:")
);
}
#[test]
fn test_fetch_failure_classifies_every_non_rate_limited_variant_as_transient() {
let reqwest_err = reqwest::Client::new()
.get("not a valid url")
.build()
.unwrap_err();
let json_err = serde_json::from_str::<serde_json::Value>("{invalid}").unwrap_err();
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let non_rate_limited = [
DepsError::ParseError {
file_type: "Cargo.toml".into(),
source: Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, "bad")),
},
DepsError::RegistryError {
package: "flask".into(),
source: reqwest_err.into(),
},
DepsError::CacheError("connection reset".into()),
DepsError::PackageNotFound {
package: "flask".into(),
registry: "PyPI",
},
DepsError::HttpStatus {
url: "https://example.com".into(),
status: 500,
},
DepsError::ApiResponse {
package: "flask".into(),
registry: "PyPI",
source: json_err,
},
DepsError::ResponseTooLarge {
url: "https://example.com".into(),
limit: 1024,
},
DepsError::InvalidVersionReq("bad range".into()),
DepsError::Io(io_err),
DepsError::Json(serde_json::from_str::<serde_json::Value>("{bad}").unwrap_err()),
DepsError::UnsupportedEcosystem("unknown".into()),
DepsError::AmbiguousEcosystem("file.txt".into()),
DepsError::InvalidUri("not a uri".into()),
DepsError::Offline {
url: "https://example.com".into(),
},
];
for error in non_rate_limited {
assert_eq!(
error.fetch_failure(),
FetchFailure::Transient,
"expected Transient for {error:?}"
);
}
let rate_limited = DepsError::RateLimited {
message: "set GITHUB_TOKEN to increase the rate limit".into(),
};
assert_eq!(
rate_limited.fetch_failure(),
FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".into())
);
assert_eq!(
DepsError::ChainResolutionHalted.fetch_failure(),
FetchFailure::Actionable(
"index unreachable — resolution halted, not falling back to a less-trusted \
index"
.to_string()
)
);
}
}