cleanlib-client 0.1.4

HTTP client SDK for the CleanLibrary verdict API — VerdictEnvelopeV1 types, derive_status logic, transport, config, and risk-acceptance YAML emitter shared between cleanlib-cli and other CleanLibrary consumers.
Documentation
//! `CleanLibraryError` hierarchy per Client spec rev1 §2.3 + Rev 2 amendment §2.
//! Mirrors App Rev 4 §9.2 reason-code enum surfaced via `X-CleanLibrary-Reason`
//! response header.

use thiserror::Error;

/// Top-level error type for cleanlib-client operations.
/// Mirrors Rev 1 §2.3 mapping table.
#[derive(Debug, Error)]
pub enum CleanLibraryError {
    #[error("config error: {0}")]
    Config(#[from] crate::config::ConfigError),

    /// Policy DENY — `403` + `POLICY_DENY_VERDICT` / `POLICY_DENY_RULE_EXPLICIT`,
    /// or `451 Unavailable For Legal Reasons` (npm ecosystem alias).
    #[error("policy deny [{reason_code}]: {message}")]
    PolicyDeny { reason_code: String, message: String },

    /// `403` + `INTEGRITY_FAILURE` — package hash mismatch on serve path
    /// (security incident; App refuses serve).
    #[error("integrity failure [{reason_code}]: {message}")]
    IntegrityFailure { reason_code: String, message: String },

    /// `429 Too Many Requests` + `Retry-After` header.
    #[error("rate limit exceeded; retry after {retry_after_seconds}s ({message})")]
    RateLimitExceeded { retry_after_seconds: u64, message: String },

    /// `403` + `RISK_ACCEPTANCE_REQUIRED` — policy permits ALLOW but customer
    /// hasn't signed risk-acceptance for this package/version. Customer should
    /// emit a rule via `cleanlib risk-accept` and upload to CDP admin UI.
    #[error("risk acceptance required [{reason_code}]: {message}")]
    RiskAcceptanceRequired {
        reason_code: String,
        message: String,
        guidance: Option<String>,
        docs_url: Option<String>,
    },

    /// `401` or `403` + `KEY_INVALID` / `KEY_EXPIRED` / `KEY_SCOPE_INSUFFICIENT`.
    #[error("authentication failed [{reason_code}]: {message}")]
    Authentication { reason_code: String, message: String },

    /// `403` + `INSUFFICIENT_DATA_FAIL_CLOSED` — verdict unavailable + customer
    /// policy fails closed on missing data.
    #[error("insufficient data [{reason_code}]: {message}")]
    InsufficientData { reason_code: String, message: String },

    /// `404 Not Found` — package not in catalog + ingest declined or unavailable.
    #[error("package not found: {0}")]
    PackageNotFound(String),

    /// `5xx` — server error. `retryable=true` for 502/503/504.
    #[error("server error {status}: {message}")]
    ServerError { status: u16, message: String, retryable: bool },

    /// Network / TLS / timeout / DNS — pre-response transport-layer failure.
    #[error("transport error: {0}")]
    Transport(#[from] TransportError),

    /// Response body parse failure (malformed JSON, schema mismatch).
    #[error("parse error: {0}")]
    Parse(String),
}

#[derive(Debug, Error)]
pub enum TransportError {
    #[error("network: {0}")]
    Network(#[source] reqwest::Error),

    #[error("invalid endpoint URL: {0}")]
    InvalidUrl(String),

    #[error("TLS required; refusing plaintext endpoint {0} (localhost exempt for testing)")]
    TlsRequired(String),

    #[error("timeout")]
    Timeout,
}

impl CleanLibraryError {
    /// True if a transient retry could plausibly succeed. Callers may use
    /// this to gate retry/backoff logic. Per Rev 1 §2.3 + App Rev 4 §9.2.
    pub fn is_retryable(&self) -> bool {
        match self {
            Self::ServerError { retryable, .. } => *retryable,
            Self::Transport(TransportError::Timeout) => true,
            Self::Transport(TransportError::Network(_)) => true,
            Self::RateLimitExceeded { .. } => true,
            _ => false,
        }
    }

    /// Reason code from App's `X-CleanLibrary-Reason` header, if any.
    pub fn reason_code(&self) -> Option<&str> {
        match self {
            Self::PolicyDeny { reason_code, .. }
            | Self::IntegrityFailure { reason_code, .. }
            | Self::RiskAcceptanceRequired { reason_code, .. }
            | Self::Authentication { reason_code, .. }
            | Self::InsufficientData { reason_code, .. } => Some(reason_code),
            _ => None,
        }
    }
}

/// Map HTTP `status` + headers + body into a `CleanLibraryError` variant.
/// Reads `X-CleanLibrary-Reason` header per App Rev 4 §9.2 reason codes.
pub fn from_http(
    status: u16,
    headers: &reqwest::header::HeaderMap,
    body: &str,
) -> CleanLibraryError {
    let reason_code = headers
        .get("X-CleanLibrary-Reason")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("UNKNOWN")
        .to_string();
    let message = if body.is_empty() {
        format!("HTTP {}", status)
    } else {
        body.to_string()
    };

    match (status, reason_code.as_str()) {
        (401, _) => CleanLibraryError::Authentication { reason_code, message },
        (403, "KEY_INVALID" | "KEY_EXPIRED" | "KEY_SCOPE_INSUFFICIENT") => {
            CleanLibraryError::Authentication { reason_code, message }
        }
        (403, "POLICY_DENY_VERDICT" | "POLICY_DENY_RULE_EXPLICIT") | (451, _) => {
            CleanLibraryError::PolicyDeny { reason_code, message }
        }
        (403, "RISK_ACCEPTANCE_REQUIRED") => CleanLibraryError::RiskAcceptanceRequired {
            reason_code,
            message,
            guidance: None,
            docs_url: Some("https://docs.cleanlibrary.io/risk-acceptance".to_string()),
        },
        (403, "INTEGRITY_FAILURE") => CleanLibraryError::IntegrityFailure { reason_code, message },
        (403, "INSUFFICIENT_DATA_FAIL_CLOSED") => {
            CleanLibraryError::InsufficientData { reason_code, message }
        }
        (429, _) => {
            let retry_after_seconds = headers
                .get("Retry-After")
                .and_then(|v| v.to_str().ok())
                .and_then(|s| s.parse().ok())
                .unwrap_or(60);
            CleanLibraryError::RateLimitExceeded { retry_after_seconds, message }
        }
        (404, _) => CleanLibraryError::PackageNotFound(message),
        (500..=599, _) => CleanLibraryError::ServerError {
            status,
            message,
            retryable: matches!(status, 502 | 503 | 504),
        },
        _ => CleanLibraryError::ServerError {
            status,
            message,
            retryable: false,
        },
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use reqwest::header::{HeaderMap, HeaderValue};

    fn headers_with_reason(code: &str) -> HeaderMap {
        let mut h = HeaderMap::new();
        h.insert("X-CleanLibrary-Reason", HeaderValue::from_str(code).unwrap());
        h
    }

    #[test]
    fn maps_401_to_auth() {
        let err = from_http(401, &HeaderMap::new(), "bad token");
        assert!(matches!(err, CleanLibraryError::Authentication { .. }));
    }

    #[test]
    fn maps_403_policy_deny_verdict() {
        let err = from_http(403, &headers_with_reason("POLICY_DENY_VERDICT"), "verdict denies");
        assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
        assert_eq!(err.reason_code(), Some("POLICY_DENY_VERDICT"));
    }

    #[test]
    fn maps_451_npm_legal_alias_to_policy_deny() {
        let err = from_http(451, &HeaderMap::new(), "");
        assert!(matches!(err, CleanLibraryError::PolicyDeny { .. }));
    }

    #[test]
    fn maps_403_risk_acceptance_required() {
        let err = from_http(
            403,
            &headers_with_reason("RISK_ACCEPTANCE_REQUIRED"),
            "needs explicit acceptance",
        );
        match err {
            CleanLibraryError::RiskAcceptanceRequired { docs_url, .. } => {
                assert!(docs_url.is_some());
            }
            other => panic!("expected RiskAcceptanceRequired, got {:?}", other),
        }
    }

    #[test]
    fn maps_429_with_retry_after() {
        let mut h = HeaderMap::new();
        h.insert("Retry-After", HeaderValue::from_static("42"));
        let err = from_http(429, &h, "throttled");
        match err {
            CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
                assert_eq!(retry_after_seconds, 42);
            }
            other => panic!("expected RateLimitExceeded, got {:?}", other),
        }
        assert!(err.is_retryable());
    }

    #[test]
    fn maps_429_default_retry_after() {
        let err = from_http(429, &HeaderMap::new(), "");
        match err {
            CleanLibraryError::RateLimitExceeded { retry_after_seconds, .. } => {
                assert_eq!(retry_after_seconds, 60);
            }
            other => panic!("expected RateLimitExceeded, got {:?}", other),
        }
    }

    #[test]
    fn maps_404_to_package_not_found() {
        let err = from_http(404, &HeaderMap::new(), "not in catalog");
        assert!(matches!(err, CleanLibraryError::PackageNotFound(_)));
    }

    #[test]
    fn maps_500s_retryability() {
        for status in [500, 501, 505] {
            let err = from_http(status, &HeaderMap::new(), "");
            match err {
                CleanLibraryError::ServerError { retryable, .. } => assert!(!retryable),
                _ => panic!("expected ServerError"),
            }
        }
        for status in [502, 503, 504] {
            let err = from_http(status, &HeaderMap::new(), "");
            match err {
                CleanLibraryError::ServerError { retryable, .. } => assert!(retryable),
                _ => panic!("expected ServerError"),
            }
        }
    }

    #[test]
    fn integrity_failure_carries_reason() {
        let err = from_http(403, &headers_with_reason("INTEGRITY_FAILURE"), "hash mismatch");
        match err {
            CleanLibraryError::IntegrityFailure { reason_code, .. } => {
                assert_eq!(reason_code, "INTEGRITY_FAILURE");
            }
            _ => panic!("expected IntegrityFailure"),
        }
    }

    #[test]
    fn insufficient_data_carries_reason() {
        let err = from_http(
            403,
            &headers_with_reason("INSUFFICIENT_DATA_FAIL_CLOSED"),
            "no verdict",
        );
        assert!(matches!(err, CleanLibraryError::InsufficientData { .. }));
    }
}