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        // CLEANLIB-870 (gating half): "POLICY_DENY" is what the App actually
240        // sends today (cleanlib-gcs-catalog::serve::DecisionKind::reason_header,
241        // confirmed live against GET /v1/fetch/npm/left-pad/1.3.0 post-
242        // CLEANLIB-867 -- `x-cleanlibrary-reason: POLICY_DENY`). Neither
243        // "POLICY_DENY_VERDICT" nor "POLICY_DENY_RULE_EXPLICIT" is emitted
244        // anywhere in the current server codebase (grepped cleanlib-app,
245        // cleanlib-core, cleanlib-gcs-catalog -- zero hits for either) --
246        // this match arm was checking for reason codes nothing sends, so
247        // every real policy-deny response fell through to the generic
248        // `ServerError` catch-all below instead of the semantically correct
249        // `PolicyDeny`, silently defeating any caller (like `fetch`) that
250        // wants to branch on the specific decision. Kept the legacy names
251        // too in case another surface still emits them.
252        (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    // ─── CLEANLIB-536 RFC 9457 problem+json ─────────────────────────────────
308
309    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        // Defensive: a proxy strips the content-type but the body is a canonical
376        // problem doc → still surfaced as Problem.
377        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        // A plain JSON error body with a `type` field that is NOT a problems URI
384        // must NOT be misclassified — legacy header/status routing wins.
385        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        // Back-compat: no problem+json → the 429 rate-limit path is unchanged.
393        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}