Skip to main content

cleanlib_client/
errors.rs

1//! `CleanLibraryError` hierarchy per Client spec rev1 §2.3 + Rev 2 amendment §2.
2//! Mirrors App Rev 4 §9.2 reason-code enum surfaced via `X-CleanLibrary-Reason`
3//! response header.
4
5use thiserror::Error;
6
7/// Top-level error type for cleanlib-client operations.
8/// Mirrors Rev 1 §2.3 mapping table.
9#[non_exhaustive]
10#[derive(Debug, Error)]
11pub enum CleanLibraryError {
12    #[error("config error: {0}")]
13    Config(#[from] crate::config::ConfigError),
14
15    /// Policy DENY — `403` + `POLICY_DENY_VERDICT` / `POLICY_DENY_RULE_EXPLICIT`,
16    /// or `451 Unavailable For Legal Reasons` (npm ecosystem alias).
17    #[error("policy deny [{reason_code}]: {message}")]
18    PolicyDeny { reason_code: String, message: String },
19
20    /// `403` + `INTEGRITY_FAILURE` — package hash mismatch on serve path
21    /// (security incident; App refuses serve).
22    #[error("integrity failure [{reason_code}]: {message}")]
23    IntegrityFailure { reason_code: String, message: String },
24
25    /// `429 Too Many Requests` + `Retry-After` header.
26    #[error("rate limit exceeded; retry after {retry_after_seconds}s ({message})")]
27    RateLimitExceeded { retry_after_seconds: u64, message: String },
28
29    /// `403` + `RISK_ACCEPTANCE_REQUIRED` — policy permits ALLOW but customer
30    /// hasn't signed risk-acceptance for this package/version. Customer should
31    /// emit a rule via `cleanlib risk-accept` and upload to CDP admin UI.
32    #[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    /// `401` or `403` + `KEY_INVALID` / `KEY_EXPIRED` / `KEY_SCOPE_INSUFFICIENT`.
41    #[error("authentication failed [{reason_code}]: {message}")]
42    Authentication { reason_code: String, message: String },
43
44    /// `403` + `INSUFFICIENT_DATA_FAIL_CLOSED` — verdict unavailable + customer
45    /// policy fails closed on missing data.
46    #[error("insufficient data [{reason_code}]: {message}")]
47    InsufficientData { reason_code: String, message: String },
48
49    /// CLEANLIB-657 (CX-8): a scan/fix run could not evaluate one or more
50    /// coordinates (outage / TLS / 5xx / unreachable). NOT a verdict — the gate
51    /// did not fully run; callers must never read this as clean coverage.
52    #[error("coverage incomplete [{reason_code}]: {message}")]
53    CoverageIncomplete { reason_code: String, message: String },
54
55    /// CLEANLIB-657 (CX-8): a signed attestation was present but failed
56    /// verification (signature / key / canonical-form mismatch).
57    #[error("attestation invalid [{reason_code}]: {message}")]
58    AttestationInvalid { reason_code: String, message: String },
59
60    /// `404 Not Found` — package not in catalog + ingest declined or unavailable.
61    #[error("package not found: {0}")]
62    PackageNotFound(String),
63
64    /// `5xx` — server error. `retryable=true` for 502/503/504.
65    #[error("server error {status}: {message}")]
66    ServerError { status: u16, message: String, retryable: bool },
67
68    /// CLEANLIB-536: an RFC 9457 `application/problem+json` structured error.
69    /// Carries the App's problem document — the standard
70    /// `type`/`title`/`status`/`detail`/`instance` members plus the CLEANLIB-536
71    /// extensions `reason_class` (the machine-stable branch key —
72    /// `not_ingested` / `dependency_degraded` / `permanently_blocked` /
73    /// `service_unavailable`), `retryable`, `selfHealing`, and `resolution`.
74    /// Consumers branch on `problem.reason_class` and honor `problem.retryable`
75    /// rather than scraping status codes or the raw body. Emitted when the App
76    /// response is `application/problem+json`; legacy header/status responses
77    /// keep the variants above.
78    #[error("{problem}")]
79    Problem { problem: ProblemDetails },
80
81    /// Network / TLS / timeout / DNS — pre-response transport-layer failure.
82    #[error("transport error: {0}")]
83    Transport(#[from] TransportError),
84
85    /// Response body parse failure (malformed JSON, schema mismatch).
86    #[error("parse error: {0}")]
87    Parse(String),
88}
89
90/// CLEANLIB-536: RFC 9457 `application/problem+json` problem document (reader
91/// side). Mirrors `cleanlib-app::problem::ProblemDetails` as a TOLERANT reader —
92/// every field is defaulted and unknown members are ignored (no
93/// `deny_unknown_fields`), so a new App extension never breaks parsing. Standard
94/// RFC 9457 members (`type`/`title`/`status`/`detail`/`instance`) plus the
95/// CLEANLIB-536 extensions.
96#[derive(Debug, Clone, Default, serde::Deserialize)]
97#[serde(default)]
98pub struct ProblemDetails {
99    /// The `type` URI (`https://cleanlibrary.dev/problems/<slug>`). The RFC 9457
100    /// primary identifier; presence marks a well-formed problem document.
101    #[serde(rename = "type")]
102    pub type_uri: String,
103    /// Short human-readable summary.
104    pub title: String,
105    /// HTTP status echoed in the body.
106    pub status: u16,
107    /// Longer human-readable explanation (optional per RFC 9457).
108    pub detail: Option<String>,
109    /// URI identifying the specific occurrence (optional).
110    pub instance: Option<String>,
111    /// Machine-stable branch key (`not_ingested` / `dependency_degraded` /
112    /// `permanently_blocked` / `service_unavailable`). Prefer this over `status`
113    /// for programmatic branching.
114    pub reason_class: String,
115    /// Whether retrying the SAME request could succeed.
116    pub retryable: bool,
117    /// Present when the App is actively self-healing the degraded dependency.
118    #[serde(rename = "selfHealing")]
119    pub self_healing: Option<bool>,
120    /// Suggested caller action for permanently-blocked / actionable problems.
121    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
139/// Parse an `application/problem+json` body (CLEANLIB-536) into
140/// [`ProblemDetails`], returning `None` when the body is absent or is not a
141/// problem document. A body is treated as a problem document when the response
142/// `Content-Type` is `application/problem+json` OR (defensively, in case a proxy
143/// strips the content-type) the parsed `type` URI is under the canonical
144/// `https://cleanlibrary.dev/problems/` base. A bare JSON error body that merely
145/// happens to carry a `type` field is NOT misclassified.
146pub 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    /// True if a transient retry could plausibly succeed. Callers may use
183    /// this to gate retry/backoff logic. Per Rev 1 §2.3 + App Rev 4 §9.2.
184    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    /// Reason code from App's `X-CleanLibrary-Reason` header, if any.
195    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
207/// Map HTTP `status` + headers + body into a `CleanLibraryError` variant.
208/// Reads `X-CleanLibrary-Reason` header per App Rev 4 §9.2 reason codes.
209pub fn from_http(
210    status: u16,
211    headers: &reqwest::header::HeaderMap,
212    body: &str,
213) -> CleanLibraryError {
214    // CLEANLIB-536: an RFC 9457 `application/problem+json` response is surfaced
215    // as the structured `Problem` variant (type/title/detail/reason_class/
216    // retryable/…) so consumers branch on `reason_class` rather than scraping the
217    // status or the raw body. Legacy header/status responses fall through to the
218    // reason-code routing below (back-compat).
219    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    // ─── CLEANLIB-536 RFC 9457 problem+json ─────────────────────────────────
295
296    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        // Defensive: a proxy strips the content-type but the body is a canonical
363        // problem doc → still surfaced as Problem.
364        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        // A plain JSON error body with a `type` field that is NOT a problems URI
371        // must NOT be misclassified — legacy header/status routing wins.
372        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        // Back-compat: no problem+json → the 429 rate-limit path is unchanged.
380        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}