Skip to main content

cleanlib_client/
gate.rs

1//! CLEANLIB-657 (CX-8 P1-b) — the `verdict()` / `enforce()` dual consumption API.
2//!
3//! CX-8 thesis: **a blocked verdict is a SUCCESSFUL assessment, not an error.**
4//! Modelling "blocked" as an exception conflates a policy/security STOP (we
5//! assessed it and the answer is "do not proceed") with a SYSTEM FAILURE (we
6//! could not assess it at all). The two must stay distinguishable, because a
7//! caller that treats a couldn't-assess as "no block seen → proceed" has built
8//! exactly the [Absence≠safe] false-clear the taxonomy exists to prevent.
9//!
10//! So this module offers the same assessment through two ergonomics over one
11//! raw acquisition outcome (`Result<CustomerState, CleanLibraryError>` —
12//! `Ok(state)` = we got a verdict of *some* tier; `Err(e)` = we could not):
13//!
14//! - [`verdict`] — RETURNS style. A completed assessment of ANY tier (Block
15//!   included) passes through as an [`Assessment`] value; `Err` is reserved
16//!   strictly for "couldn't get a verdict".
17//! - [`enforce`] — RAISES style for gate use. `Ok(())` **iff** clean-to-proceed;
18//!   a non-clean completed assessment becomes [`GateError::Blocked`], and a
19//!   couldn't-assess propagates as [`GateError::NotAssessed`]. Fail-closed: a
20//!   `NotYetAssessed` / `RangeNotResolved` (Warn tier) REFUSES — it never passes
21//!   the gate, so "not assessed" can never read as "allowed".
22//!
23//! The three other SDKs (py `StrEnum`, js discriminated union, go typed const +
24//! `Valid()`) mirror this contract against the shared conformance fixture
25//! (CX-8 P2), so the return-vs-raise split is byte-for-byte identical across
26//! surfaces.
27//!
28//! # Quickstart (CX-8 P3)
29//!
30//! Two ways to consume one assessment. **A blocked verdict is a successful
31//! assessment, not an error** — only *couldn't get a verdict* is an `Err`, so a
32//! transport / coverage failure can never be mistaken for "no findings,
33//! proceed" ([Absence≠safe]). This example runs as a doctest.
34//!
35//! ```
36//! use cleanlib_client::{verdict, enforce, CustomerState, Tier, GateError, CleanLibraryError};
37//!
38//! // `outcome` is what your acquisition produced: `Ok(state)` (a verdict of
39//! // some tier) or `Err(e)` (you could not get one at all).
40//!
41//! // 1. verdict() — RETURNS style. A Block is a VALUE, never an `Err`.
42//! let a = verdict(Ok(CustomerState::Malicious)).unwrap();
43//! assert_eq!(a.tier(), Tier::Block);
44//! assert_eq!(a.exit_code(), 1);
45//! assert!(!a.is_allowed());
46//!
47//! // The "not assessed" path is still a returned VALUE — and it is NOT clean:
48//! let na = verdict(Ok(CustomerState::NotYetAssessed)).unwrap();
49//! assert!(!na.is_allowed());
50//! assert_eq!(na.exit_code(), 2);            // warn-tier, fail-closed
51//!
52//! // A couldn't-get-a-verdict is the ONLY thing verdict() yields as `Err`:
53//! let acq = Err(CleanLibraryError::CoverageIncomplete {
54//!     reason_code: "SCAN_ABORTED".into(),
55//!     message: "3 of 40 coordinates unreachable".into(),
56//! });
57//! assert!(verdict(acq).is_err());           // a real failure — never a clean result
58//!
59//! // 2. enforce() — RAISES style for CI gates. `Ok(())` ONLY when clean.
60//! assert!(enforce(Ok(CustomerState::Clean)).is_ok());         // proceed
61//!
62//! match enforce(Ok(CustomerState::NotYetAssessed)) {
63//!     Err(GateError::Blocked { exit_code, .. }) => assert_eq!(exit_code, 2),
64//!     other => panic!("not-yet-assessed must block, got {other:?}"),
65//! }
66//!
67//! // A couldn't-assess fails CLOSED to the block exit code (1) — never 0:
68//! let gate = enforce(Err(CleanLibraryError::CoverageIncomplete {
69//!     reason_code: "SCAN_ABORTED".into(),
70//!     message: "coverage failure".into(),
71//! }));
72//! assert!(matches!(gate, Err(GateError::NotAssessed(_))));
73//! assert_eq!(gate.unwrap_err().exit_code(), 1);
74//! ```
75
76use thiserror::Error;
77
78use crate::customer_state::{CustomerState, Tier};
79use crate::errors::CleanLibraryError;
80
81/// A COMPLETED assessment of a coordinate. A Block is a normal `Assessment`
82/// value — never an error. "Couldn't get a verdict" is the `Err` arm of the
83/// acquisition `Result`, kept strictly distinct from any `Assessment`.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
85pub struct Assessment {
86    state: CustomerState,
87}
88
89impl Assessment {
90    /// Wrap an already-derived [`CustomerState`].
91    pub fn new(state: CustomerState) -> Self {
92        Self { state }
93    }
94
95    /// Derive from the wire `source` (see [`CustomerState::from_wire`]).
96    pub fn from_wire(source: &str) -> Self {
97        Self::new(CustomerState::from_wire(source))
98    }
99
100    /// Derive taking the envelope-v2 `source_state` into account
101    /// (see [`CustomerState::from_wire_with_source_state`]).
102    pub fn from_wire_with_source_state(source: &str, source_state: Option<&str>) -> Self {
103        Self::new(CustomerState::from_wire_with_source_state(source, source_state))
104    }
105
106    /// CLEANLIB-855: same as [`from_wire_with_source_state`], plus resolves
107    /// `Compromised` when `matched_rule_id` names a curated
108    /// supply-chain-compromise-bridge rule (see
109    /// [`CustomerState::from_wire_with_source_state_and_origin`]).
110    pub fn from_wire_with_source_state_and_origin(
111        source: &str,
112        source_state: Option<&str>,
113        matched_rule_id: Option<&str>,
114    ) -> Self {
115        Self::new(CustomerState::from_wire_with_source_state_and_origin(
116            source,
117            source_state,
118            matched_rule_id,
119        ))
120    }
121
122    /// The underlying customer state.
123    pub fn state(&self) -> CustomerState {
124        self.state
125    }
126
127    /// Derive tier (block / warn / clean).
128    pub fn tier(&self) -> Tier {
129        self.state.tier()
130    }
131
132    /// Process exit code for the CLI gate (block=1, warn=2, clean=0).
133    pub fn exit_code(&self) -> i32 {
134        self.state.exit_code()
135    }
136
137    /// Clean-to-proceed? `true` **only** for the Clean tier. Fail-closed: a Warn
138    /// (including `NotYetAssessed` / `RangeNotResolved`) is NOT allowed. This is
139    /// the single predicate [`enforce`] gates on, so the "only Clean proceeds"
140    /// invariant lives in exactly one place.
141    pub fn is_allowed(&self) -> bool {
142        matches!(self.tier(), Tier::Clean)
143    }
144
145    /// [`enforce`] as a method on an already-obtained assessment: `Ok(())` iff
146    /// clean-to-proceed, else [`GateError::Blocked`]. (The free [`enforce`]
147    /// function additionally folds a couldn't-assess `Err` into
148    /// [`GateError::NotAssessed`].)
149    pub fn enforce(&self) -> Result<(), GateError> {
150        if self.is_allowed() {
151            Ok(())
152        } else {
153            Err(GateError::Blocked {
154                state: self.state,
155                exit_code: self.exit_code(),
156            })
157        }
158    }
159}
160
161/// `verdict()` — RETURNS style. Passes a completed assessment through as an
162/// [`Assessment`] value (a Block is a value, NOT an `Err`) and reserves `Err`
163/// strictly for "couldn't get a verdict" (coverage incomplete / attestation
164/// invalid / transport / server — whatever the acquisition surfaced).
165///
166/// `outcome` is the raw per-coordinate acquisition result: `Ok(state)` once a
167/// verdict of some tier was derived, `Err(e)` when none could be.
168pub fn verdict(
169    outcome: Result<CustomerState, CleanLibraryError>,
170) -> Result<Assessment, CleanLibraryError> {
171    outcome.map(Assessment::new)
172}
173
174/// `enforce()` — RAISES style for gate use. `Ok(())` **iff** clean-to-proceed.
175///
176/// - a non-clean COMPLETED assessment → `Err(`[`GateError::Blocked`]`)` (the
177///   same outcome [`verdict`] returns as a value, re-expressed as a raise so a
178///   gate can `?`-propagate it);
179/// - a couldn't-assess → `Err(`[`GateError::NotAssessed`]`)`, carrying the
180///   original error.
181///
182/// Fail-closed by construction: because [`Assessment::is_allowed`] is true only
183/// for the Clean tier, a `NotYetAssessed` / `RangeNotResolved` refuses here just
184/// like a `Malicious` does — "not assessed" can never pass the gate.
185pub fn enforce(outcome: Result<CustomerState, CleanLibraryError>) -> Result<(), GateError> {
186    match verdict(outcome) {
187        Ok(assessment) => assessment.enforce(),
188        Err(e) => Err(GateError::NotAssessed(e)),
189    }
190}
191
192/// Why a gate refused. The two arms preserve the CX-8 distinction the whole
193/// module exists to keep:
194///
195/// - [`Blocked`](GateError::Blocked) — a COMPLETED assessment that is not
196///   clean-to-proceed. The gate ran; the answer is "do not proceed". This is the
197///   same outcome [`verdict`] hands back as a value.
198/// - [`NotAssessed`](GateError::NotAssessed) — a verdict could NOT be obtained.
199///   The gate did not fully run; fail-closed, never a clean signal.
200#[derive(Debug, Error)]
201pub enum GateError {
202    /// A completed assessment refuses the gate (tier is Block or Warn).
203    #[error("gate refused: {} (exit {exit_code})", .state.as_str())]
204    Blocked { state: CustomerState, exit_code: i32 },
205
206    /// No verdict could be obtained — the gate did not fully run (fail-closed).
207    #[error("gate could not evaluate: {0}")]
208    NotAssessed(#[from] CleanLibraryError),
209}
210
211impl GateError {
212    /// Process exit code for the gate refusal. A [`Blocked`](GateError::Blocked)
213    /// carries the assessed tier's exit code (block=1 / warn=2); a
214    /// [`NotAssessed`](GateError::NotAssessed) is a coverage failure that must
215    /// NEVER read as clean — it fails closed to the block exit code (1), never 0.
216    pub fn exit_code(&self) -> i32 {
217        match self {
218            GateError::Blocked { exit_code, .. } => *exit_code,
219            GateError::NotAssessed(_) => Tier::Block.exit_code(),
220        }
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    // ── verdict() — a Block is a VALUE, not an error ────────────────────────
229    #[test]
230    fn verdict_returns_block_tier_as_value_not_error() {
231        // The crux of CX-8: a malicious/blocked coordinate is a SUCCESSFUL
232        // assessment. verdict() must hand it back as an Ok(Assessment), never
233        // an Err — an Err is reserved for couldn't-assess.
234        for state in [
235            CustomerState::Malicious,
236            CustomerState::Compromised,
237            CustomerState::BlockedByPolicy,
238            CustomerState::ActivelyExploited,
239            CustomerState::RansomwareLinked,
240        ] {
241            let a = verdict(Ok(state)).expect("a completed block is Ok, not Err");
242            assert_eq!(a.state(), state);
243            assert_eq!(a.tier(), Tier::Block);
244            assert!(!a.is_allowed());
245        }
246    }
247
248    #[test]
249    fn verdict_returns_clean_as_allowed_value() {
250        let a = verdict(Ok(CustomerState::Clean)).unwrap();
251        assert!(a.is_allowed());
252        assert_eq!(a.exit_code(), 0);
253    }
254
255    #[test]
256    fn verdict_propagates_couldnt_assess_as_err() {
257        // A couldn't-get-a-verdict (coverage incomplete) stays an Err — it is
258        // NEVER coerced into a clean/any Assessment value.
259        let outcome = Err(CleanLibraryError::CoverageIncomplete {
260            reason_code: "SCAN_ABORTED".into(),
261            message: "3 of 40 coordinates unreachable".into(),
262        });
263        assert!(verdict(outcome).is_err());
264    }
265
266    // ── enforce() — Ok ONLY for Clean; fail-closed everywhere else ──────────
267    #[test]
268    fn enforce_ok_only_for_clean() {
269        assert!(enforce(Ok(CustomerState::Clean)).is_ok());
270    }
271
272    #[test]
273    fn enforce_refuses_every_non_clean_completed_state() {
274        // Fail-closed invariant: EVERY non-Clean state refuses the gate —
275        // including the Warn-tier NotYetAssessed / RangeNotResolved. This is the
276        // guard against "not assessed" reading as "allowed".
277        for state in CustomerState::all() {
278            if matches!(state, CustomerState::Clean) {
279                continue;
280            }
281            let err = enforce(Ok(state)).expect_err("non-clean must refuse the gate");
282            match err {
283                GateError::Blocked { state: s, .. } => assert_eq!(s, state),
284                other => panic!("expected Blocked for {state:?}, got {other:?}"),
285            }
286        }
287    }
288
289    #[test]
290    fn enforce_not_yet_assessed_refuses_and_never_exits_zero() {
291        // The headline CX-8 counterexample: a not-yet-assessed coordinate must
292        // NOT pass the gate and must NOT produce a clean (0) exit code.
293        let err = enforce(Ok(CustomerState::NotYetAssessed)).unwrap_err();
294        assert!(matches!(err, GateError::Blocked { .. }));
295        assert_ne!(err.exit_code(), 0, "not-assessed must never exit clean");
296        assert_eq!(err.exit_code(), 2, "not-assessed is warn-tier (exit 2)");
297    }
298
299    #[test]
300    fn enforce_couldnt_assess_fails_closed_to_block_exit() {
301        // A couldn't-assess is distinct from a Blocked refusal (NotAssessed arm)
302        // AND fails closed to the block exit code — never 0.
303        let outcome = Err(CleanLibraryError::AttestationInvalid {
304            reason_code: "SIG_MISMATCH".into(),
305            message: "attestation signature did not verify".into(),
306        });
307        let err = enforce(outcome).unwrap_err();
308        assert!(matches!(err, GateError::NotAssessed(_)));
309        assert_eq!(err.exit_code(), 1, "couldn't-assess fails closed to block exit");
310    }
311
312    #[test]
313    fn blocked_and_not_assessed_are_distinguishable() {
314        // The distinction the module exists to preserve: a policy STOP and a
315        // system FAILURE are different arms, so a caller can tell "assessed →
316        // do not proceed" from "could not assess".
317        let blocked = enforce(Ok(CustomerState::Malicious)).unwrap_err();
318        let not_assessed = enforce(Err(CleanLibraryError::CoverageIncomplete {
319            reason_code: "X".into(),
320            message: "y".into(),
321        }))
322        .unwrap_err();
323        assert!(matches!(blocked, GateError::Blocked { .. }));
324        assert!(matches!(not_assessed, GateError::NotAssessed(_)));
325    }
326
327    #[test]
328    fn assessment_method_enforce_matches_free_fn_on_completed() {
329        // The Assessment::enforce method and the free enforce() agree on every
330        // completed assessment (the free fn only adds the NotAssessed folding).
331        for state in CustomerState::all() {
332            let a = Assessment::new(state);
333            assert_eq!(a.enforce().is_ok(), enforce(Ok(state)).is_ok(), "{state:?}");
334        }
335    }
336
337    // ── CX-8 P2 cross-SDK conformance — assert against the shared fixture ────
338    // The Rust reference is the FIRST conformant implementer of the
339    // verdict()/enforce() contract. sdk-py / sdk-js / sdk-go each vendor the
340    // SAME CX8_GATE_EXPECTED.json and run the equivalent assertion in their
341    // repos, so the return-vs-raise split is byte-identical across all four.
342    // Kept here (a same-crate unit test) rather than in tests/ because the
343    // couldn't-assess arms construct #[non_exhaustive] CleanLibraryError
344    // variants, which an external integration-test crate cannot build.
345    #[test]
346    fn matches_cx8_gate_conformance_fixture() {
347        use std::path::PathBuf;
348
349        let tier_str = |t: Tier| match t {
350            Tier::Block => "block",
351            Tier::Warn => "warn",
352            Tier::Clean => "clean",
353        };
354
355        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
356            .join("tests/fixtures/contract-fixtures/CX8_GATE_EXPECTED.json");
357        let raw = std::fs::read_to_string(&path)
358            .unwrap_or_else(|e| panic!("read {path:?}: {e}"));
359        let g: serde_json::Value =
360            serde_json::from_str(&raw).expect("CX8_GATE_EXPECTED.json must parse");
361
362        // completed: derived via from_wire, then run through verdict()/enforce().
363        let completed = g["completed"].as_object().expect("completed object");
364        assert!(!completed.is_empty(), "fixture has no completed cases");
365        for (wire, row) in completed {
366            // __UNKNOWN__ is a sentinel exercising the fail-closed arm.
367            let source = if wire == "__UNKNOWN__" {
368                "SOME_FUTURE_VARIANT"
369            } else {
370                wire.as_str()
371            };
372            let state = CustomerState::from_wire(source);
373
374            // verdict() must hand a completed assessment back as a VALUE.
375            let a = verdict(Ok(state)).expect("a completed assessment is Ok, never Err");
376            assert_eq!(a.state().as_str(), row["state"].as_str().unwrap(), "{wire} state");
377            assert_eq!(tier_str(a.tier()), row["tier"].as_str().unwrap(), "{wire} tier");
378            assert_eq!(
379                i64::from(a.exit_code()),
380                row["exit_code"].as_i64().unwrap(),
381                "{wire} exit_code"
382            );
383            assert_eq!(a.is_allowed(), row["is_allowed"].as_bool().unwrap(), "{wire} is_allowed");
384            assert!(row["verdict_ok"].as_bool().unwrap(), "{wire} completed verdict_ok must be true");
385
386            // enforce() — Ok iff clean; else the labelled error arm.
387            let en = enforce(Ok(state));
388            assert_eq!(en.is_ok(), row["enforce_ok"].as_bool().unwrap(), "{wire} enforce_ok");
389            match (&en, row["enforce_error"].as_str()) {
390                (Ok(()), None) => {}
391                (Err(GateError::Blocked { .. }), Some("blocked")) => {}
392                (got, want) => {
393                    panic!("{wire} enforce_error mismatch: got {got:?}, want {want:?}")
394                }
395            }
396        }
397
398        // couldnt_assess: constructed error kinds — verdict() AND enforce() must
399        // both raise, and enforce() must fail closed (never exit 0).
400        let mk = |kind: &str| -> CleanLibraryError {
401            match kind {
402                "coverage_incomplete" => CleanLibraryError::CoverageIncomplete {
403                    reason_code: "SCAN_ABORTED".into(),
404                    message: "coverage incomplete".into(),
405                },
406                "attestation_invalid" => CleanLibraryError::AttestationInvalid {
407                    reason_code: "SIG_MISMATCH".into(),
408                    message: "attestation invalid".into(),
409                },
410                other => panic!("unknown couldnt_assess kind in fixture: {other}"),
411            }
412        };
413        let couldnt = g["couldnt_assess"].as_object().expect("couldnt_assess object");
414        assert!(!couldnt.is_empty(), "fixture has no couldnt_assess cases");
415        for (kind, row) in couldnt {
416            // verdict() must stay Err — a couldn't-assess is NEVER coerced to a value.
417            assert_eq!(
418                verdict(Err(mk(kind))).is_ok(),
419                row["verdict_ok"].as_bool().unwrap(),
420                "{kind} verdict_ok"
421            );
422            assert!(!row["verdict_ok"].as_bool().unwrap(), "{kind} couldnt_assess verdict_ok must be false");
423
424            let en = enforce(Err(mk(kind)));
425            assert_eq!(en.is_ok(), row["enforce_ok"].as_bool().unwrap(), "{kind} enforce_ok");
426            let err = en.expect_err("couldnt_assess must refuse the gate");
427            assert!(matches!(err, GateError::NotAssessed(_)), "{kind} must be NotAssessed");
428            assert_eq!(row["enforce_error"].as_str(), Some("not_assessed"), "{kind} fixture arm");
429            assert_eq!(
430                i64::from(err.exit_code()),
431                row["exit_code"].as_i64().unwrap(),
432                "{kind} fail-closed exit"
433            );
434            assert_ne!(err.exit_code(), 0, "{kind} couldnt-assess must never exit clean");
435        }
436    }
437}