cleanlib-client 0.1.3

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
//! `derive_status` — canonical algorithm per App dispatch §4 binding contract.
//!
//! Each CleanLibrary SDK (sdk-js, sdk-py, sdk-go, cleanlib-client Rust)
//! implements this algorithm INDEPENDENTLY from the spec; the cross-SDK
//! contract test (`tests/contract.rs`) verifies byte-identical
//! `(status, reason_code)` across all 4 implementations on every fixture
//! in `cleanlib-contract-fixtures` v1.0.0.
//!
//! Precedence:
//!
//! 1. **Substance** (signals top-to-bottom; `unavailable` substrate falls through):
//!    - `exploitability.exploitation_likelihood == "CRITICAL"`
//!      → DENY + `VERDICT_EXPLOITATION_CRITICAL`
//!    - `exploitability.in_kev` AND `availability.kev == "available"` AND
//!      `exploitability.exploit_risk_score >= 70`
//!      → DENY + `VERDICT_KEV_LISTED`
//!    - `rich_data.has_obfuscation`
//!      → DENY + `VERDICT_OBFUSCATED`
//!    - `remediation.fix` OR `remediation.recommended_version` present
//!      → WARN + `VERDICT_HAS_REMEDIATION`
//!    - `rich_data.abandonment_score >= 0.7`
//!      → WARN + `VERDICT_ABANDONED`
//!    - `rich_data.recommended_version` present (under `rich_data`,
//!       not `remediation`)
//!      → ALLOW + `VERDICT_RECOMMENDED_VERSION_NEWER`
//!    - default
//!      → ALLOW + `VERDICT_CLEAN`
//!
//! 2. **Freshness override**: when the substance-driving signal's
//!    `availability == "degraded_stale"`, the substance-derived status
//!    tier is PRESERVED and the `reason_code` overrides to
//!    `VERDICT_DEGRADED_STALE`.
//!
//! 3. **Block tie-break in remediation**: `fix` > `recommended_version`
//!    > other blocks.

use serde_json::Value;

use crate::envelope::{ReasonCode, Status, VerdictEnvelopeV1};

/// Return value pair — sister of sdk-py `StatusResult` + sdk-go `StatusResult`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct DerivedStatus {
    pub status: Status,
    pub reason_code: ReasonCode,
}

/// Apply the substance-precedence + freshness-override rule to a parsed
/// `VerdictEnvelopeV1`. Returns `(status, reason_code)` pair.
///
/// Cross-SDK contract: identical output across all 4 SDK implementations
/// for every fixture in `cleanlib-contract-fixtures` v1.0.0.
pub fn derive_status(envelope: &VerdictEnvelopeV1) -> DerivedStatus {
    let rich = envelope.rich_data.as_ref();
    let rem = envelope.remediation.as_ref();
    let exp = envelope.exploitability.as_ref();
    let avail = envelope.availability.as_ref();

    let mut status: Status;
    let mut reason: ReasonCode;
    // `freshness_signal` is the substrate availability tag for the driving
    // signal; the freshness-override step rewrites the reason_code without
    // disturbing the status tier.
    let mut freshness_signal: Option<String> = None;

    if as_str(exp, "exploitation_likelihood") == Some("CRITICAL") {
        status = Status::Deny;
        reason = ReasonCode::VerdictExploitationCritical;
        freshness_signal = as_str(avail, "exploitation_fusion").map(str::to_string);
    } else if as_bool(exp, "in_kev")
        && as_str(avail, "kev") == Some("available")
        && as_f64(exp, "exploit_risk_score") >= 70.0
    {
        status = Status::Deny;
        reason = ReasonCode::VerdictKevListed;
        freshness_signal = as_str(avail, "kev").map(str::to_string);
    } else if as_bool(rich, "has_obfuscation") {
        status = Status::Deny;
        reason = ReasonCode::VerdictObfuscated;
    } else if get(rem, "fix").is_some() || get(rem, "recommended_version").is_some() {
        status = Status::Warn;
        reason = ReasonCode::VerdictHasRemediation;
        // Block tie-break: `fix` > `recommended_version` > other blocks.
        let block = get(rem, "fix").or_else(|| get(rem, "recommended_version"));
        if let Some(b) = block {
            if let Some(s) = b.get("availability").and_then(Value::as_str) {
                freshness_signal = Some(s.to_string());
            }
        }
    } else if as_f64(rich, "abandonment_score") >= 0.7 {
        status = Status::Warn;
        reason = ReasonCode::VerdictAbandoned;
    } else if get(rich, "recommended_version").is_some() {
        status = Status::Allow;
        reason = ReasonCode::VerdictRecommendedVersionNewer;
    } else {
        status = Status::Allow;
        reason = ReasonCode::VerdictClean;
    }

    // Freshness override: keep status tier, rewrite reason_code.
    if freshness_signal.as_deref() == Some("degraded_stale") {
        reason = ReasonCode::VerdictDegradedStale;
    }

    // Suppress unused-mut warning for `status` on paths that never reassign
    // — `_ = &mut status;` keeps the let-mut binding load-bearing for
    // future-rule additions without triggering a clippy alarm here.
    let _ = &mut status;
    let _ = &mut reason;

    DerivedStatus { status, reason_code: reason }
}

// ─── small json-projection helpers (tolerate Option<Value> + type-skew) ─────

fn get<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a Value> {
    let v = obj?.get(key)?;
    if v.is_null() {
        None
    } else {
        Some(v)
    }
}

fn as_str<'a>(obj: Option<&'a Value>, key: &str) -> Option<&'a str> {
    get(obj, key).and_then(Value::as_str)
}

fn as_bool(obj: Option<&Value>, key: &str) -> bool {
    get(obj, key).and_then(Value::as_bool).unwrap_or(false)
}

fn as_f64(obj: Option<&Value>, key: &str) -> f64 {
    get(obj, key).and_then(Value::as_f64).unwrap_or(0.0)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn parse(json: &str) -> VerdictEnvelopeV1 {
        serde_json::from_str(json).unwrap()
    }

    #[test]
    fn clean_envelope_yields_allow_clean() {
        let env = parse(
            r#"{
                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
                "human_message":"ok","as_of":"2026-05-28"
            }"#,
        );
        let d = derive_status(&env);
        assert_eq!(d.status, Status::Allow);
        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
    }

    #[test]
    fn exploitation_critical_wins_over_kev_and_remediation() {
        // EXPLOITATION_CRITICAL is the topmost precedence rule.
        let env = parse(
            r#"{
                "status":"DENY","reason_code":"VERDICT_EXPLOITATION_CRITICAL",
                "human_message":"e","as_of":"2026-05-28",
                "exploitability":{
                  "exploitation_likelihood":"CRITICAL",
                  "in_kev":true,
                  "exploit_risk_score":99
                },
                "availability":{"kev":"available","exploitation_fusion":"available"},
                "remediation":{"fix":{"availability":"available"}}
            }"#,
        );
        let d = derive_status(&env);
        assert_eq!(d.status, Status::Deny);
        assert_eq!(d.reason_code, ReasonCode::VerdictExploitationCritical);
    }

    #[test]
    fn freshness_override_preserves_tier_only_rewrites_reason() {
        // remediation.fix.availability="degraded_stale" → WARN preserved,
        // reason rewritten to VERDICT_DEGRADED_STALE.
        let env = parse(
            r#"{
                "status":"WARN","reason_code":"VERDICT_DEGRADED_STALE",
                "human_message":"stale","as_of":"2026-05-28",
                "remediation":{"fix":{"availability":"degraded_stale"}}
            }"#,
        );
        let d = derive_status(&env);
        assert_eq!(d.status, Status::Warn);
        assert_eq!(d.reason_code, ReasonCode::VerdictDegradedStale);
    }

    #[test]
    fn kev_requires_all_three_conditions() {
        // in_kev=true alone is not enough; need kev=available AND score>=70.
        let env = parse(
            r#"{
                "status":"ALLOW","reason_code":"VERDICT_CLEAN",
                "human_message":"x","as_of":"2026-05-28",
                "exploitability":{"in_kev":true,"exploit_risk_score":50},
                "availability":{"kev":"available"}
            }"#,
        );
        let d = derive_status(&env);
        assert_eq!(d.status, Status::Allow);
        assert_eq!(d.reason_code, ReasonCode::VerdictClean);
    }
}