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