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" | "POLICY_DENY") | (451, _) => {
253 CleanLibraryError::PolicyDeny { reason_code, message }
254 }
255 (403, "RISK_ACCEPTANCE_REQUIRED") => CleanLibraryError::RiskAcceptanceRequired {
256 reason_code,
257 message,
258 guidance: None,
259 docs_url: Some("https://docs.cleanlibrary.io/risk-acceptance".to_string()),
260 },
261 (403, "INTEGRITY_FAILURE") => CleanLibraryError::IntegrityFailure { reason_code, message },
262 (403, "INSUFFICIENT_DATA_FAIL_CLOSED") => {
263 CleanLibraryError::InsufficientData { reason_code, message }
264 }
265 (429, _) => {
266 let retry_after_seconds = headers
267 .get("Retry-After")
268 .and_then(|v| v.to_str().ok())
269 .and_then(|s| s.parse().ok())
270 .unwrap_or(60);
271 CleanLibraryError::RateLimitExceeded { retry_after_seconds, message }
272 }
273 (404, _) => CleanLibraryError::PackageNotFound(message),
274 (500..=599, _) => CleanLibraryError::ServerError {
275 status,
276 message,
277 retryable: matches!(status, 502 | 503 | 504),
278 },
279 _ => CleanLibraryError::ServerError {
280 status,
281 message,
282 retryable: false,
283 },
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use reqwest::header::{HeaderMap, HeaderValue};
291
292 fn headers_with_reason(code: &str) -> HeaderMap {
293 let mut h = HeaderMap::new();
294 h.insert("X-CleanLibrary-Reason", HeaderValue::from_str(code).unwrap());
295 h
296 }
297
298 fn problem_headers() -> HeaderMap {
299 let mut h = HeaderMap::new();
300 h.insert(
301 reqwest::header::CONTENT_TYPE,
302 HeaderValue::from_static("application/problem+json"),
303 );
304 h
305 }
306
307 const NOT_INGESTED_BODY: &str = r#"{
310 "type": "https://cleanlibrary.dev/problems/not-ingested",
311 "title": "Package not in CleanLibrary catalog",
312 "status": 404,
313 "detail": "npm/left-pad@99.0.0 has not been ingested",
314 "reason_class": "not_ingested",
315 "retryable": false
316 }"#;
317
318 #[test]
319 fn problem_json_maps_to_structured_problem() {
320 let err = from_http(404, &problem_headers(), NOT_INGESTED_BODY);
321 match err {
322 CleanLibraryError::Problem { problem } => {
323 assert_eq!(problem.type_uri, "https://cleanlibrary.dev/problems/not-ingested");
324 assert_eq!(problem.reason_class, "not_ingested");
325 assert_eq!(problem.status, 404);
326 assert!(!problem.retryable);
327 assert_eq!(problem.detail.as_deref(), Some("npm/left-pad@99.0.0 has not been ingested"));
328 }
329 other => panic!("expected Problem, got {other:?}"),
330 }
331 }
332
333 #[test]
334 fn problem_json_degraded_is_retryable_with_selfhealing() {
335 let body = r#"{
336 "type": "https://cleanlibrary.dev/problems/dependency-degraded-self-healing",
337 "title": "Upstream advisories degraded — self-healing",
338 "status": 503,
339 "reason_class": "dependency_degraded",
340 "retryable": true,
341 "selfHealing": true
342 }"#;
343 match from_http(503, &problem_headers(), body) {
344 CleanLibraryError::Problem { problem } => {
345 assert_eq!(problem.reason_class, "dependency_degraded");
346 assert!(problem.retryable);
347 assert_eq!(problem.self_healing, Some(true));
348 }
349 other => panic!("expected Problem, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn problem_json_tolerates_unknown_extension_fields() {
355 let body = r#"{
356 "type": "https://cleanlibrary.dev/problems/permanently-blocked",
357 "title": "Verdict requires action to resolve",
358 "status": 422,
359 "reason_class": "permanently_blocked",
360 "retryable": false,
361 "resolution": "upgrade to >=1.2.3",
362 "future_extension_field": {"nested": [1,2,3]}
363 }"#;
364 match from_http(422, &problem_headers(), body) {
365 CleanLibraryError::Problem { problem } => {
366 assert_eq!(problem.reason_class, "permanently_blocked");
367 assert_eq!(problem.resolution.as_deref(), Some("upgrade to >=1.2.3"));
368 }
369 other => panic!("expected Problem, got {other:?}"),
370 }
371 }
372
373 #[test]
374 fn problem_type_uri_sniff_without_content_type() {
375 let err = from_http(404, &HeaderMap::new(), NOT_INGESTED_BODY);
378 assert!(matches!(err, CleanLibraryError::Problem { .. }));
379 }
380
381 #[test]
382 fn non_problem_body_falls_through_to_legacy_routing() {
383 let body = r#"{"type": "some_other_thing", "message": "nope"}"#;
386 let err = from_http(401, &HeaderMap::new(), body);
387 assert!(matches!(err, CleanLibraryError::Authentication { .. }));
388 }
389
390 #[test]
391 fn legacy_reason_header_still_maps_without_problem_json() {
392 let err = from_http(429, &headers_with_reason("RATE_LIMIT"), "slow down");
394 assert!(matches!(err, CleanLibraryError::RateLimitExceeded { .. }));
395 }
396
397 #[test]
398 fn maps_401_to_auth() {
399 let err = from_http(401, &HeaderMap::new(), "bad token");
400 assert!(matches!(err, CleanLibraryError::Authentication { .. }));
401 }
402
403 #[test]
404 fn maps_403_policy_deny_verdict() {
405 let err = from_http(403, &headers_with_reason("POLICY_DENY_VERDICT"), "verdict denies");
406 assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
407 assert_eq!(err.reason_code(), Some("POLICY_DENY_VERDICT"));
408 }
409
410 #[test]
411 fn maps_451_npm_legal_alias_to_policy_deny() {
412 let err = from_http(451, &HeaderMap::new(), "");
413 assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
414 }
415
416 #[test]
417 fn maps_403_risk_acceptance_required() {
418 let err = from_http(
419 403,
420 &headers_with_reason("RISK_ACCEPTANCE_REQUIRED"),
421 "needs explicit acceptance",
422 );
423 match err {
424 CleanLibraryError::RiskAcceptanceRequired { docs_url, .. } => {
425 assert!(docs_url.is_some());
426 }
427 other => panic!("expected RiskAcceptanceRequired, got {:?}", other),
428 }
429 }
430
431 #[test]
432 fn maps_429_with_retry_after() {
433 let mut h = HeaderMap::new();
434 h.insert("Retry-After", HeaderValue::from_static("42"));
435 let err = from_http(429, &h, "throttled");
436 match err {
437 CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
438 assert_eq!(retry_after_seconds, 42);
439 }
440 other => panic!("expected RateLimitExceeded, got {:?}", other),
441 }
442 assert!(err.is_retryable());
443 }
444
445 #[test]
446 fn maps_429_default_retry_after() {
447 let err = from_http(429, &HeaderMap::new(), "");
448 match err {
449 CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
450 assert_eq!(retry_after_seconds, 60);
451 }
452 other => panic!("expected RateLimitExceeded, got {:?}", other),
453 }
454 }
455
456 #[test]
457 fn maps_404_to_package_not_found() {
458 let err = from_http(404, &HeaderMap::new(), "not in catalog");
459 assert!(matches!(err, CleanLibraryError::PackageNotFound(_)));
460 }
461
462 #[test]
463 fn maps_500s_retryability() {
464 for status in [500, 501, 505] {
465 let err = from_http(status, &HeaderMap::new(), "");
466 match err {
467 CleanLibraryError::ServerError { retryable, .. } => assert!(!retryable),
468 _ => panic!("expected ServerError"),
469 }
470 }
471 for status in [502, 503, 504] {
472 let err = from_http(status, &HeaderMap::new(), "");
473 match err {
474 CleanLibraryError::ServerError { retryable, .. } => assert!(retryable),
475 _ => panic!("expected ServerError"),
476 }
477 }
478 }
479
480 #[test]
481 fn integrity_failure_carries_reason() {
482 let err = from_http(403, &headers_with_reason("INTEGRITY_FAILURE"), "hash mismatch");
483 match err {
484 CleanLibraryError::IntegrityFailure { reason_code, .. } => {
485 assert_eq!(reason_code, "INTEGRITY_FAILURE");
486 }
487 _ => panic!("expected IntegrityFailure"),
488 }
489 }
490
491 #[test]
492 fn insufficient_data_carries_reason() {
493 let err = from_http(
494 403,
495 &headers_with_reason("INSUFFICIENT_DATA_FAIL_CLOSED"),
496 "no verdict",
497 );
498 assert!(matches!(err, CleanLibraryError::InsufficientData { .. }));
499 }
500}