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}