1use thiserror::Error;
6
7#[non_exhaustive]
10#[derive(Debug, Error)]
11pub enum CleanLibraryError {
12 #[error("config error: {0}")]
13 Config(#[from] crate::config::ConfigError),
14
15 #[error("policy deny [{reason_code}]: {message}")]
18 PolicyDeny { reason_code: String, message: String },
19
20 #[error("integrity failure [{reason_code}]: {message}")]
23 IntegrityFailure { reason_code: String, message: String },
24
25 #[error("rate limit exceeded; retry after {retry_after_seconds}s ({message})")]
27 RateLimitExceeded { retry_after_seconds: u64, message: String },
28
29 #[error("risk acceptance required [{reason_code}]: {message}")]
33 RiskAcceptanceRequired {
34 reason_code: String,
35 message: String,
36 guidance: Option<String>,
37 docs_url: Option<String>,
38 },
39
40 #[error("authentication failed [{reason_code}]: {message}")]
42 Authentication { reason_code: String, message: String },
43
44 #[error("insufficient data [{reason_code}]: {message}")]
47 InsufficientData { reason_code: String, message: String },
48
49 #[error("coverage incomplete [{reason_code}]: {message}")]
53 CoverageIncomplete { reason_code: String, message: String },
54
55 #[error("attestation invalid [{reason_code}]: {message}")]
58 AttestationInvalid { reason_code: String, message: String },
59
60 #[error("package not found: {0}")]
62 PackageNotFound(String),
63
64 #[error("server error {status}: {message}")]
66 ServerError { status: u16, message: String, retryable: bool },
67
68 #[error("{problem}")]
79 Problem { problem: ProblemDetails },
80
81 #[error("transport error: {0}")]
83 Transport(#[from] TransportError),
84
85 #[error("parse error: {0}")]
87 Parse(String),
88}
89
90#[derive(Debug, Clone, Default, serde::Deserialize)]
97#[serde(default)]
98pub struct ProblemDetails {
99 #[serde(rename = "type")]
102 pub type_uri: String,
103 pub title: String,
105 pub status: u16,
107 pub detail: Option<String>,
109 pub instance: Option<String>,
111 pub reason_class: String,
115 pub retryable: bool,
117 #[serde(rename = "selfHealing")]
119 pub self_healing: Option<bool>,
120 pub resolution: Option<String>,
122}
123
124impl std::fmt::Display for ProblemDetails {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 let class = if self.reason_class.is_empty() {
127 "problem"
128 } else {
129 self.reason_class.as_str()
130 };
131 write!(f, "[{}] {}", class, self.title)?;
132 if let Some(detail) = &self.detail {
133 write!(f, ": {}", detail)?;
134 }
135 Ok(())
136 }
137}
138
139pub fn parse_problem_details(
147 headers: &reqwest::header::HeaderMap,
148 body: &str,
149) -> Option<ProblemDetails> {
150 let ct_is_problem = headers
151 .get(reqwest::header::CONTENT_TYPE)
152 .and_then(|v| v.to_str().ok())
153 .map(|ct| ct.starts_with("application/problem+json"))
154 .unwrap_or(false);
155 let problem: ProblemDetails = serde_json::from_str(body).ok()?;
156 if problem.type_uri.is_empty() {
157 return None;
158 }
159 if ct_is_problem || problem.type_uri.starts_with("https://cleanlibrary.dev/problems/") {
160 Some(problem)
161 } else {
162 None
163 }
164}
165
166#[derive(Debug, Error)]
167pub enum TransportError {
168 #[error("network: {0}")]
169 Network(#[source] reqwest::Error),
170
171 #[error("invalid endpoint URL: {0}")]
172 InvalidUrl(String),
173
174 #[error("TLS required; refusing plaintext endpoint {0} (localhost exempt for testing)")]
175 TlsRequired(String),
176
177 #[error("timeout")]
178 Timeout,
179}
180
181impl CleanLibraryError {
182 pub fn is_retryable(&self) -> bool {
185 match self {
186 Self::ServerError { retryable, .. } => *retryable,
187 Self::Transport(TransportError::Timeout) => true,
188 Self::Transport(TransportError::Network(_)) => true,
189 Self::RateLimitExceeded { .. } => true,
190 _ => false,
191 }
192 }
193
194 pub fn reason_code(&self) -> Option<&str> {
196 match self {
197 Self::PolicyDeny { reason_code, .. }
198 | Self::IntegrityFailure { reason_code, .. }
199 | Self::RiskAcceptanceRequired { reason_code, .. }
200 | Self::Authentication { reason_code, .. }
201 | Self::InsufficientData { reason_code, .. } => Some(reason_code),
202 _ => None,
203 }
204 }
205}
206
207pub fn from_http(
210 status: u16,
211 headers: &reqwest::header::HeaderMap,
212 body: &str,
213) -> CleanLibraryError {
214 if let Some(problem) = parse_problem_details(headers, body) {
220 return CleanLibraryError::Problem { problem };
221 }
222
223 let reason_code = headers
224 .get("X-CleanLibrary-Reason")
225 .and_then(|v| v.to_str().ok())
226 .unwrap_or("UNKNOWN")
227 .to_string();
228 let message = if body.is_empty() {
229 format!("HTTP {}", status)
230 } else {
231 body.to_string()
232 };
233
234 match (status, reason_code.as_str()) {
235 (401, _) => CleanLibraryError::Authentication { reason_code, message },
236 (403, "KEY_INVALID" | "KEY_EXPIRED" | "KEY_SCOPE_INSUFFICIENT") => {
237 CleanLibraryError::Authentication { reason_code, message }
238 }
239 (403, "POLICY_DENY_VERDICT" | "POLICY_DENY_RULE_EXPLICIT") | (451, _) => {
240 CleanLibraryError::PolicyDeny { reason_code, message }
241 }
242 (403, "RISK_ACCEPTANCE_REQUIRED") => CleanLibraryError::RiskAcceptanceRequired {
243 reason_code,
244 message,
245 guidance: None,
246 docs_url: Some("https://docs.cleanlibrary.io/risk-acceptance".to_string()),
247 },
248 (403, "INTEGRITY_FAILURE") => CleanLibraryError::IntegrityFailure { reason_code, message },
249 (403, "INSUFFICIENT_DATA_FAIL_CLOSED") => {
250 CleanLibraryError::InsufficientData { reason_code, message }
251 }
252 (429, _) => {
253 let retry_after_seconds = headers
254 .get("Retry-After")
255 .and_then(|v| v.to_str().ok())
256 .and_then(|s| s.parse().ok())
257 .unwrap_or(60);
258 CleanLibraryError::RateLimitExceeded { retry_after_seconds, message }
259 }
260 (404, _) => CleanLibraryError::PackageNotFound(message),
261 (500..=599, _) => CleanLibraryError::ServerError {
262 status,
263 message,
264 retryable: matches!(status, 502 | 503 | 504),
265 },
266 _ => CleanLibraryError::ServerError {
267 status,
268 message,
269 retryable: false,
270 },
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use reqwest::header::{HeaderMap, HeaderValue};
278
279 fn headers_with_reason(code: &str) -> HeaderMap {
280 let mut h = HeaderMap::new();
281 h.insert("X-CleanLibrary-Reason", HeaderValue::from_str(code).unwrap());
282 h
283 }
284
285 fn problem_headers() -> HeaderMap {
286 let mut h = HeaderMap::new();
287 h.insert(
288 reqwest::header::CONTENT_TYPE,
289 HeaderValue::from_static("application/problem+json"),
290 );
291 h
292 }
293
294 const NOT_INGESTED_BODY: &str = r#"{
297 "type": "https://cleanlibrary.dev/problems/not-ingested",
298 "title": "Package not in CleanLibrary catalog",
299 "status": 404,
300 "detail": "npm/left-pad@99.0.0 has not been ingested",
301 "reason_class": "not_ingested",
302 "retryable": false
303 }"#;
304
305 #[test]
306 fn problem_json_maps_to_structured_problem() {
307 let err = from_http(404, &problem_headers(), NOT_INGESTED_BODY);
308 match err {
309 CleanLibraryError::Problem { problem } => {
310 assert_eq!(problem.type_uri, "https://cleanlibrary.dev/problems/not-ingested");
311 assert_eq!(problem.reason_class, "not_ingested");
312 assert_eq!(problem.status, 404);
313 assert!(!problem.retryable);
314 assert_eq!(problem.detail.as_deref(), Some("npm/left-pad@99.0.0 has not been ingested"));
315 }
316 other => panic!("expected Problem, got {other:?}"),
317 }
318 }
319
320 #[test]
321 fn problem_json_degraded_is_retryable_with_selfhealing() {
322 let body = r#"{
323 "type": "https://cleanlibrary.dev/problems/dependency-degraded-self-healing",
324 "title": "Upstream advisories degraded — self-healing",
325 "status": 503,
326 "reason_class": "dependency_degraded",
327 "retryable": true,
328 "selfHealing": true
329 }"#;
330 match from_http(503, &problem_headers(), body) {
331 CleanLibraryError::Problem { problem } => {
332 assert_eq!(problem.reason_class, "dependency_degraded");
333 assert!(problem.retryable);
334 assert_eq!(problem.self_healing, Some(true));
335 }
336 other => panic!("expected Problem, got {other:?}"),
337 }
338 }
339
340 #[test]
341 fn problem_json_tolerates_unknown_extension_fields() {
342 let body = r#"{
343 "type": "https://cleanlibrary.dev/problems/permanently-blocked",
344 "title": "Verdict requires action to resolve",
345 "status": 422,
346 "reason_class": "permanently_blocked",
347 "retryable": false,
348 "resolution": "upgrade to >=1.2.3",
349 "future_extension_field": {"nested": [1,2,3]}
350 }"#;
351 match from_http(422, &problem_headers(), body) {
352 CleanLibraryError::Problem { problem } => {
353 assert_eq!(problem.reason_class, "permanently_blocked");
354 assert_eq!(problem.resolution.as_deref(), Some("upgrade to >=1.2.3"));
355 }
356 other => panic!("expected Problem, got {other:?}"),
357 }
358 }
359
360 #[test]
361 fn problem_type_uri_sniff_without_content_type() {
362 let err = from_http(404, &HeaderMap::new(), NOT_INGESTED_BODY);
365 assert!(matches!(err, CleanLibraryError::Problem { .. }));
366 }
367
368 #[test]
369 fn non_problem_body_falls_through_to_legacy_routing() {
370 let body = r#"{"type": "some_other_thing", "message": "nope"}"#;
373 let err = from_http(401, &HeaderMap::new(), body);
374 assert!(matches!(err, CleanLibraryError::Authentication { .. }));
375 }
376
377 #[test]
378 fn legacy_reason_header_still_maps_without_problem_json() {
379 let err = from_http(429, &headers_with_reason("RATE_LIMIT"), "slow down");
381 assert!(matches!(err, CleanLibraryError::RateLimitExceeded { .. }));
382 }
383
384 #[test]
385 fn maps_401_to_auth() {
386 let err = from_http(401, &HeaderMap::new(), "bad token");
387 assert!(matches!(err, CleanLibraryError::Authentication { .. }));
388 }
389
390 #[test]
391 fn maps_403_policy_deny_verdict() {
392 let err = from_http(403, &headers_with_reason("POLICY_DENY_VERDICT"), "verdict denies");
393 assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
394 assert_eq!(err.reason_code(), Some("POLICY_DENY_VERDICT"));
395 }
396
397 #[test]
398 fn maps_451_npm_legal_alias_to_policy_deny() {
399 let err = from_http(451, &HeaderMap::new(), "");
400 assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
401 }
402
403 #[test]
404 fn maps_403_risk_acceptance_required() {
405 let err = from_http(
406 403,
407 &headers_with_reason("RISK_ACCEPTANCE_REQUIRED"),
408 "needs explicit acceptance",
409 );
410 match err {
411 CleanLibraryError::RiskAcceptanceRequired { docs_url, .. } => {
412 assert!(docs_url.is_some());
413 }
414 other => panic!("expected RiskAcceptanceRequired, got {:?}", other),
415 }
416 }
417
418 #[test]
419 fn maps_429_with_retry_after() {
420 let mut h = HeaderMap::new();
421 h.insert("Retry-After", HeaderValue::from_static("42"));
422 let err = from_http(429, &h, "throttled");
423 match err {
424 CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
425 assert_eq!(retry_after_seconds, 42);
426 }
427 other => panic!("expected RateLimitExceeded, got {:?}", other),
428 }
429 assert!(err.is_retryable());
430 }
431
432 #[test]
433 fn maps_429_default_retry_after() {
434 let err = from_http(429, &HeaderMap::new(), "");
435 match err {
436 CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
437 assert_eq!(retry_after_seconds, 60);
438 }
439 other => panic!("expected RateLimitExceeded, got {:?}", other),
440 }
441 }
442
443 #[test]
444 fn maps_404_to_package_not_found() {
445 let err = from_http(404, &HeaderMap::new(), "not in catalog");
446 assert!(matches!(err, CleanLibraryError::PackageNotFound(_)));
447 }
448
449 #[test]
450 fn maps_500s_retryability() {
451 for status in [500, 501, 505] {
452 let err = from_http(status, &HeaderMap::new(), "");
453 match err {
454 CleanLibraryError::ServerError { retryable, .. } => assert!(!retryable),
455 _ => panic!("expected ServerError"),
456 }
457 }
458 for status in [502, 503, 504] {
459 let err = from_http(status, &HeaderMap::new(), "");
460 match err {
461 CleanLibraryError::ServerError { retryable, .. } => assert!(retryable),
462 _ => panic!("expected ServerError"),
463 }
464 }
465 }
466
467 #[test]
468 fn integrity_failure_carries_reason() {
469 let err = from_http(403, &headers_with_reason("INTEGRITY_FAILURE"), "hash mismatch");
470 match err {
471 CleanLibraryError::IntegrityFailure { reason_code, .. } => {
472 assert_eq!(reason_code, "INTEGRITY_FAILURE");
473 }
474 _ => panic!("expected IntegrityFailure"),
475 }
476 }
477
478 #[test]
479 fn insufficient_data_carries_reason() {
480 let err = from_http(
481 403,
482 &headers_with_reason("INSUFFICIENT_DATA_FAIL_CLOSED"),
483 "no verdict",
484 );
485 assert!(matches!(err, CleanLibraryError::InsufficientData { .. }));
486 }
487}