cleanlib-client 0.3.0

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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! CLEANLIB-657 (CX-8 P1-b) — the `verdict()` / `enforce()` dual consumption API.
//!
//! CX-8 thesis: **a blocked verdict is a SUCCESSFUL assessment, not an error.**
//! Modelling "blocked" as an exception conflates a policy/security STOP (we
//! assessed it and the answer is "do not proceed") with a SYSTEM FAILURE (we
//! could not assess it at all). The two must stay distinguishable, because a
//! caller that treats a couldn't-assess as "no block seen → proceed" has built
//! exactly the [Absence≠safe] false-clear the taxonomy exists to prevent.
//!
//! So this module offers the same assessment through two ergonomics over one
//! raw acquisition outcome (`Result<CustomerState, CleanLibraryError>` —
//! `Ok(state)` = we got a verdict of *some* tier; `Err(e)` = we could not):
//!
//! - [`verdict`] — RETURNS style. A completed assessment of ANY tier (Block
//!   included) passes through as an [`Assessment`] value; `Err` is reserved
//!   strictly for "couldn't get a verdict".
//! - [`enforce`] — RAISES style for gate use. `Ok(())` **iff** clean-to-proceed;
//!   a non-clean completed assessment becomes [`GateError::Blocked`], and a
//!   couldn't-assess propagates as [`GateError::NotAssessed`]. Fail-closed: a
//!   `NotYetAssessed` / `RangeNotResolved` (Warn tier) REFUSES — it never passes
//!   the gate, so "not assessed" can never read as "allowed".
//!
//! The three other SDKs (py `StrEnum`, js discriminated union, go typed const +
//! `Valid()`) mirror this contract against the shared conformance fixture
//! (CX-8 P2), so the return-vs-raise split is byte-for-byte identical across
//! surfaces.
//!
//! # Quickstart (CX-8 P3)
//!
//! Two ways to consume one assessment. **A blocked verdict is a successful
//! assessment, not an error** — only *couldn't get a verdict* is an `Err`, so a
//! transport / coverage failure can never be mistaken for "no findings,
//! proceed" ([Absence≠safe]). This example runs as a doctest.
//!
//! ```
//! use cleanlib_client::{verdict, enforce, CustomerState, Tier, GateError, CleanLibraryError};
//!
//! // `outcome` is what your acquisition produced: `Ok(state)` (a verdict of
//! // some tier) or `Err(e)` (you could not get one at all).
//!
//! // 1. verdict() — RETURNS style. A Block is a VALUE, never an `Err`.
//! let a = verdict(Ok(CustomerState::Malicious)).unwrap();
//! assert_eq!(a.tier(), Tier::Block);
//! assert_eq!(a.exit_code(), 1);
//! assert!(!a.is_allowed());
//!
//! // The "not assessed" path is still a returned VALUE — and it is NOT clean:
//! let na = verdict(Ok(CustomerState::NotYetAssessed)).unwrap();
//! assert!(!na.is_allowed());
//! assert_eq!(na.exit_code(), 2);            // warn-tier, fail-closed
//!
//! // A couldn't-get-a-verdict is the ONLY thing verdict() yields as `Err`:
//! let acq = Err(CleanLibraryError::CoverageIncomplete {
//!     reason_code: "SCAN_ABORTED".into(),
//!     message: "3 of 40 coordinates unreachable".into(),
//! });
//! assert!(verdict(acq).is_err());           // a real failure — never a clean result
//!
//! // 2. enforce() — RAISES style for CI gates. `Ok(())` ONLY when clean.
//! assert!(enforce(Ok(CustomerState::Clean)).is_ok());         // proceed
//!
//! match enforce(Ok(CustomerState::NotYetAssessed)) {
//!     Err(GateError::Blocked { exit_code, .. }) => assert_eq!(exit_code, 2),
//!     other => panic!("not-yet-assessed must block, got {other:?}"),
//! }
//!
//! // A couldn't-assess fails CLOSED to the block exit code (1) — never 0:
//! let gate = enforce(Err(CleanLibraryError::CoverageIncomplete {
//!     reason_code: "SCAN_ABORTED".into(),
//!     message: "coverage failure".into(),
//! }));
//! assert!(matches!(gate, Err(GateError::NotAssessed(_))));
//! assert_eq!(gate.unwrap_err().exit_code(), 1);
//! ```

use thiserror::Error;

use crate::customer_state::{CustomerState, Tier};
use crate::errors::CleanLibraryError;

/// A COMPLETED assessment of a coordinate. A Block is a normal `Assessment`
/// value — never an error. "Couldn't get a verdict" is the `Err` arm of the
/// acquisition `Result`, kept strictly distinct from any `Assessment`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Assessment {
    state: CustomerState,
}

impl Assessment {
    /// Wrap an already-derived [`CustomerState`].
    pub fn new(state: CustomerState) -> Self {
        Self { state }
    }

    /// Derive from the wire `source` (see [`CustomerState::from_wire`]).
    pub fn from_wire(source: &str) -> Self {
        Self::new(CustomerState::from_wire(source))
    }

    /// Derive taking the envelope-v2 `source_state` into account
    /// (see [`CustomerState::from_wire_with_source_state`]).
    pub fn from_wire_with_source_state(source: &str, source_state: Option<&str>) -> Self {
        Self::new(CustomerState::from_wire_with_source_state(source, source_state))
    }

    /// CLEANLIB-855: same as [`from_wire_with_source_state`], plus resolves
    /// `Compromised` when `matched_rule_id` names a curated
    /// supply-chain-compromise-bridge rule (see
    /// [`CustomerState::from_wire_with_source_state_and_origin`]).
    pub fn from_wire_with_source_state_and_origin(
        source: &str,
        source_state: Option<&str>,
        matched_rule_id: Option<&str>,
    ) -> Self {
        Self::new(CustomerState::from_wire_with_source_state_and_origin(
            source,
            source_state,
            matched_rule_id,
        ))
    }

    /// The underlying customer state.
    pub fn state(&self) -> CustomerState {
        self.state
    }

    /// Derive tier (block / warn / clean).
    pub fn tier(&self) -> Tier {
        self.state.tier()
    }

    /// Process exit code for the CLI gate (block=1, warn=2, clean=0).
    pub fn exit_code(&self) -> i32 {
        self.state.exit_code()
    }

    /// Clean-to-proceed? `true` **only** for the Clean tier. Fail-closed: a Warn
    /// (including `NotYetAssessed` / `RangeNotResolved`) is NOT allowed. This is
    /// the single predicate [`enforce`] gates on, so the "only Clean proceeds"
    /// invariant lives in exactly one place.
    pub fn is_allowed(&self) -> bool {
        matches!(self.tier(), Tier::Clean)
    }

    /// [`enforce`] as a method on an already-obtained assessment: `Ok(())` iff
    /// clean-to-proceed, else [`GateError::Blocked`]. (The free [`enforce`]
    /// function additionally folds a couldn't-assess `Err` into
    /// [`GateError::NotAssessed`].)
    pub fn enforce(&self) -> Result<(), GateError> {
        if self.is_allowed() {
            Ok(())
        } else {
            Err(GateError::Blocked {
                state: self.state,
                exit_code: self.exit_code(),
            })
        }
    }
}

/// `verdict()` — RETURNS style. Passes a completed assessment through as an
/// [`Assessment`] value (a Block is a value, NOT an `Err`) and reserves `Err`
/// strictly for "couldn't get a verdict" (coverage incomplete / attestation
/// invalid / transport / server — whatever the acquisition surfaced).
///
/// `outcome` is the raw per-coordinate acquisition result: `Ok(state)` once a
/// verdict of some tier was derived, `Err(e)` when none could be.
pub fn verdict(
    outcome: Result<CustomerState, CleanLibraryError>,
) -> Result<Assessment, CleanLibraryError> {
    outcome.map(Assessment::new)
}

/// `enforce()` — RAISES style for gate use. `Ok(())` **iff** clean-to-proceed.
///
/// - a non-clean COMPLETED assessment → `Err(`[`GateError::Blocked`]`)` (the
///   same outcome [`verdict`] returns as a value, re-expressed as a raise so a
///   gate can `?`-propagate it);
/// - a couldn't-assess → `Err(`[`GateError::NotAssessed`]`)`, carrying the
///   original error.
///
/// Fail-closed by construction: because [`Assessment::is_allowed`] is true only
/// for the Clean tier, a `NotYetAssessed` / `RangeNotResolved` refuses here just
/// like a `Malicious` does — "not assessed" can never pass the gate.
pub fn enforce(outcome: Result<CustomerState, CleanLibraryError>) -> Result<(), GateError> {
    match verdict(outcome) {
        Ok(assessment) => assessment.enforce(),
        Err(e) => Err(GateError::NotAssessed(e)),
    }
}

/// Why a gate refused. The two arms preserve the CX-8 distinction the whole
/// module exists to keep:
///
/// - [`Blocked`](GateError::Blocked) — a COMPLETED assessment that is not
///   clean-to-proceed. The gate ran; the answer is "do not proceed". This is the
///   same outcome [`verdict`] hands back as a value.
/// - [`NotAssessed`](GateError::NotAssessed) — a verdict could NOT be obtained.
///   The gate did not fully run; fail-closed, never a clean signal.
#[derive(Debug, Error)]
pub enum GateError {
    /// A completed assessment refuses the gate (tier is Block or Warn).
    #[error("gate refused: {} (exit {exit_code})", .state.as_str())]
    Blocked { state: CustomerState, exit_code: i32 },

    /// No verdict could be obtained — the gate did not fully run (fail-closed).
    #[error("gate could not evaluate: {0}")]
    NotAssessed(#[from] CleanLibraryError),
}

impl GateError {
    /// Process exit code for the gate refusal. A [`Blocked`](GateError::Blocked)
    /// carries the assessed tier's exit code (block=1 / warn=2); a
    /// [`NotAssessed`](GateError::NotAssessed) is a coverage failure that must
    /// NEVER read as clean — it fails closed to the block exit code (1), never 0.
    pub fn exit_code(&self) -> i32 {
        match self {
            GateError::Blocked { exit_code, .. } => *exit_code,
            GateError::NotAssessed(_) => Tier::Block.exit_code(),
        }
    }
}

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

    // ── verdict() — a Block is a VALUE, not an error ────────────────────────
    #[test]
    fn verdict_returns_block_tier_as_value_not_error() {
        // The crux of CX-8: a malicious/blocked coordinate is a SUCCESSFUL
        // assessment. verdict() must hand it back as an Ok(Assessment), never
        // an Err — an Err is reserved for couldn't-assess.
        for state in [
            CustomerState::Malicious,
            CustomerState::Compromised,
            CustomerState::BlockedByPolicy,
            CustomerState::ActivelyExploited,
            CustomerState::RansomwareLinked,
        ] {
            let a = verdict(Ok(state)).expect("a completed block is Ok, not Err");
            assert_eq!(a.state(), state);
            assert_eq!(a.tier(), Tier::Block);
            assert!(!a.is_allowed());
        }
    }

    #[test]
    fn verdict_returns_clean_as_allowed_value() {
        let a = verdict(Ok(CustomerState::Clean)).unwrap();
        assert!(a.is_allowed());
        assert_eq!(a.exit_code(), 0);
    }

    #[test]
    fn verdict_propagates_couldnt_assess_as_err() {
        // A couldn't-get-a-verdict (coverage incomplete) stays an Err — it is
        // NEVER coerced into a clean/any Assessment value.
        let outcome = Err(CleanLibraryError::CoverageIncomplete {
            reason_code: "SCAN_ABORTED".into(),
            message: "3 of 40 coordinates unreachable".into(),
        });
        assert!(verdict(outcome).is_err());
    }

    // ── enforce() — Ok ONLY for Clean; fail-closed everywhere else ──────────
    #[test]
    fn enforce_ok_only_for_clean() {
        assert!(enforce(Ok(CustomerState::Clean)).is_ok());
    }

    #[test]
    fn enforce_refuses_every_non_clean_completed_state() {
        // Fail-closed invariant: EVERY non-Clean state refuses the gate —
        // including the Warn-tier NotYetAssessed / RangeNotResolved. This is the
        // guard against "not assessed" reading as "allowed".
        for state in CustomerState::all() {
            if matches!(state, CustomerState::Clean) {
                continue;
            }
            let err = enforce(Ok(state)).expect_err("non-clean must refuse the gate");
            match err {
                GateError::Blocked { state: s, .. } => assert_eq!(s, state),
                other => panic!("expected Blocked for {state:?}, got {other:?}"),
            }
        }
    }

    #[test]
    fn enforce_not_yet_assessed_refuses_and_never_exits_zero() {
        // The headline CX-8 counterexample: a not-yet-assessed coordinate must
        // NOT pass the gate and must NOT produce a clean (0) exit code.
        let err = enforce(Ok(CustomerState::NotYetAssessed)).unwrap_err();
        assert!(matches!(err, GateError::Blocked { .. }));
        assert_ne!(err.exit_code(), 0, "not-assessed must never exit clean");
        assert_eq!(err.exit_code(), 2, "not-assessed is warn-tier (exit 2)");
    }

    #[test]
    fn enforce_couldnt_assess_fails_closed_to_block_exit() {
        // A couldn't-assess is distinct from a Blocked refusal (NotAssessed arm)
        // AND fails closed to the block exit code — never 0.
        let outcome = Err(CleanLibraryError::AttestationInvalid {
            reason_code: "SIG_MISMATCH".into(),
            message: "attestation signature did not verify".into(),
        });
        let err = enforce(outcome).unwrap_err();
        assert!(matches!(err, GateError::NotAssessed(_)));
        assert_eq!(err.exit_code(), 1, "couldn't-assess fails closed to block exit");
    }

    #[test]
    fn blocked_and_not_assessed_are_distinguishable() {
        // The distinction the module exists to preserve: a policy STOP and a
        // system FAILURE are different arms, so a caller can tell "assessed →
        // do not proceed" from "could not assess".
        let blocked = enforce(Ok(CustomerState::Malicious)).unwrap_err();
        let not_assessed = enforce(Err(CleanLibraryError::CoverageIncomplete {
            reason_code: "X".into(),
            message: "y".into(),
        }))
        .unwrap_err();
        assert!(matches!(blocked, GateError::Blocked { .. }));
        assert!(matches!(not_assessed, GateError::NotAssessed(_)));
    }

    #[test]
    fn assessment_method_enforce_matches_free_fn_on_completed() {
        // The Assessment::enforce method and the free enforce() agree on every
        // completed assessment (the free fn only adds the NotAssessed folding).
        for state in CustomerState::all() {
            let a = Assessment::new(state);
            assert_eq!(a.enforce().is_ok(), enforce(Ok(state)).is_ok(), "{state:?}");
        }
    }

    // ── CX-8 P2 cross-SDK conformance — assert against the shared fixture ────
    // The Rust reference is the FIRST conformant implementer of the
    // verdict()/enforce() contract. sdk-py / sdk-js / sdk-go each vendor the
    // SAME CX8_GATE_EXPECTED.json and run the equivalent assertion in their
    // repos, so the return-vs-raise split is byte-identical across all four.
    // Kept here (a same-crate unit test) rather than in tests/ because the
    // couldn't-assess arms construct #[non_exhaustive] CleanLibraryError
    // variants, which an external integration-test crate cannot build.
    #[test]
    fn matches_cx8_gate_conformance_fixture() {
        use std::path::PathBuf;

        let tier_str = |t: Tier| match t {
            Tier::Block => "block",
            Tier::Warn => "warn",
            Tier::Clean => "clean",
        };

        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/contract-fixtures/CX8_GATE_EXPECTED.json");
        let raw = std::fs::read_to_string(&path)
            .unwrap_or_else(|e| panic!("read {path:?}: {e}"));
        let g: serde_json::Value =
            serde_json::from_str(&raw).expect("CX8_GATE_EXPECTED.json must parse");

        // completed: derived via from_wire, then run through verdict()/enforce().
        let completed = g["completed"].as_object().expect("completed object");
        assert!(!completed.is_empty(), "fixture has no completed cases");
        for (wire, row) in completed {
            // __UNKNOWN__ is a sentinel exercising the fail-closed arm.
            let source = if wire == "__UNKNOWN__" {
                "SOME_FUTURE_VARIANT"
            } else {
                wire.as_str()
            };
            let state = CustomerState::from_wire(source);

            // verdict() must hand a completed assessment back as a VALUE.
            let a = verdict(Ok(state)).expect("a completed assessment is Ok, never Err");
            assert_eq!(a.state().as_str(), row["state"].as_str().unwrap(), "{wire} state");
            assert_eq!(tier_str(a.tier()), row["tier"].as_str().unwrap(), "{wire} tier");
            assert_eq!(
                i64::from(a.exit_code()),
                row["exit_code"].as_i64().unwrap(),
                "{wire} exit_code"
            );
            assert_eq!(a.is_allowed(), row["is_allowed"].as_bool().unwrap(), "{wire} is_allowed");
            assert!(row["verdict_ok"].as_bool().unwrap(), "{wire} completed verdict_ok must be true");

            // enforce() — Ok iff clean; else the labelled error arm.
            let en = enforce(Ok(state));
            assert_eq!(en.is_ok(), row["enforce_ok"].as_bool().unwrap(), "{wire} enforce_ok");
            match (&en, row["enforce_error"].as_str()) {
                (Ok(()), None) => {}
                (Err(GateError::Blocked { .. }), Some("blocked")) => {}
                (got, want) => {
                    panic!("{wire} enforce_error mismatch: got {got:?}, want {want:?}")
                }
            }
        }

        // couldnt_assess: constructed error kinds — verdict() AND enforce() must
        // both raise, and enforce() must fail closed (never exit 0).
        let mk = |kind: &str| -> CleanLibraryError {
            match kind {
                "coverage_incomplete" => CleanLibraryError::CoverageIncomplete {
                    reason_code: "SCAN_ABORTED".into(),
                    message: "coverage incomplete".into(),
                },
                "attestation_invalid" => CleanLibraryError::AttestationInvalid {
                    reason_code: "SIG_MISMATCH".into(),
                    message: "attestation invalid".into(),
                },
                other => panic!("unknown couldnt_assess kind in fixture: {other}"),
            }
        };
        let couldnt = g["couldnt_assess"].as_object().expect("couldnt_assess object");
        assert!(!couldnt.is_empty(), "fixture has no couldnt_assess cases");
        for (kind, row) in couldnt {
            // verdict() must stay Err — a couldn't-assess is NEVER coerced to a value.
            assert_eq!(
                verdict(Err(mk(kind))).is_ok(),
                row["verdict_ok"].as_bool().unwrap(),
                "{kind} verdict_ok"
            );
            assert!(!row["verdict_ok"].as_bool().unwrap(), "{kind} couldnt_assess verdict_ok must be false");

            let en = enforce(Err(mk(kind)));
            assert_eq!(en.is_ok(), row["enforce_ok"].as_bool().unwrap(), "{kind} enforce_ok");
            let err = en.expect_err("couldnt_assess must refuse the gate");
            assert!(matches!(err, GateError::NotAssessed(_)), "{kind} must be NotAssessed");
            assert_eq!(row["enforce_error"].as_str(), Some("not_assessed"), "{kind} fixture arm");
            assert_eq!(
                i64::from(err.exit_code()),
                row["exit_code"].as_i64().unwrap(),
                "{kind} fail-closed exit"
            );
            assert_ne!(err.exit_code(), 0, "{kind} couldnt-assess must never exit clean");
        }
    }
}