cleanlib_client/customer_state.rs
1//! CLEANLIB-178 — canonical customer-facing state taxonomy (Rust surfaces).
2//!
3//! Single source of truth for the **8 customer states** that the malicious-vs-
4//! vulnerable cluster (CLEANLIB-176 wire-shape + 177 policy overlay + 178
5//! aesthetic) surfaces. The design contract lives at
6//! `decisions/2026-06-12-cleanlib-178-state-aesthetic-mapping.md`; this module
7//! is its executable form for the Rust render surfaces — `cleanlib-cli`,
8//! `cleanlib-lsp` (CLEANLIB-209), and any consumer of `cleanlib-client`. The
9//! TS extension / Python MCP / JS+Go+Py SDKs mirror this table in their own
10//! languages, each with a contract test asserting the same wire→state map
11//! (anti-drift, sister of the silent-schema-drift discipline).
12//!
13//! ## Why this lives in `cleanlib-client`, not `cleanlib-core`
14//!
15//! `cleanlib-core` is `publish = false`; the customer-state taxonomy is a
16//! *presentation* concern consumed only by published render surfaces (the App
17//! emits the wire `source`; it never renders a customer state). Keying on the
18//! wire string (`Verdict.source: String`) — rather than `cleanlib-core`'s
19//! `VerdictSource` enum — keeps this crate publishable to crates.io and avoids
20//! a render-side dependency on the service-internal core crate.
21//!
22//! ## Design rules baked in (§0 of the spec — non-negotiable)
23//!
24//! 1. **Customer-language only** — wire/engine codenames never appear in
25//! `label()` / `copy()`.
26//! 2. **Soft severity tokens** (dark-theme-safe); brand cyan `#50C0E0` is
27//! reserved for CLEAN.
28//! 3. **Three derive tiers** (block / warn / clean) → exit codes (1 / 2 / 0).
29//!
30//! ## Derive-once + fail-closed
31//!
32//! Derive the state ONCE from the wire `source` ([`CustomerState::from_wire`]).
33//! The App applies severity precedence server-side when it picks the single
34//! `source`, so this map is 1:1 — no precedence is re-implemented here. An
35//! unrecognized / future `source` fails CLOSED to `NotYetAssessed` (warn,
36//! needs-review) — never silently Clean.
37
38/// The three derive tiers — drive exit codes (block=1, warn=2, clean=0) and
39/// the colour family, and map onto the universal ALLOW/WARN/DENY render bucket.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum Tier {
42 /// exit 1 — DENY. Customer must not install / must remove.
43 Block,
44 /// exit 2 — WARN. Review / upgrade (fail-closed for unknown).
45 Warn,
46 /// exit 0 — ALLOW. No known issues.
47 Clean,
48}
49
50impl Tier {
51 /// Process exit code for the CLI gate (block=1, warn=2, clean=0).
52 pub fn exit_code(self) -> i32 {
53 match self {
54 Tier::Block => 1,
55 Tier::Warn => 2,
56 Tier::Clean => 0,
57 }
58 }
59}
60
61/// The 9 canonical customer-facing states, severity order (highest first) per
62/// the BD-ratified ordering in §1: Blocked-by-policy, Compromised, Malicious,
63/// Ransomware-linked, Actively-exploited, Vulnerable, Range-not-resolved,
64/// Not-yet-assessed, Clean. CLEANLIB-518(§3) added Range-not-resolved (8→9).
65// CLEANLIB-657 (CX-8): #[non_exhaustive] forces external consumers to include a
66// wildcard arm, so a future state can never be silently mis-handled — and, with
67// NotYetAssessed/RangeNotResolved as their own variants, a caller can NEVER
68// accidentally treat 'not assessed' as allowed. Uncertainty lives in the type.
69#[non_exhaustive]
70#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
71pub enum CustomerState {
72 /// Customer's OWN policy rule blocked this package (operative gate).
73 BlockedByPolicy,
74 /// Tampered-with in a supply-chain attack (curated compromise list).
75 Compromised,
76 /// Known-malicious package (malware / typosquat).
77 Malicious,
78 /// Vulnerability tied to active ransomware campaigns.
79 RansomwareLinked,
80 /// Vulnerability being exploited in the wild (CISA KEV).
81 ActivelyExploited,
82 /// Has known CVEs (review + upgrade).
83 Vulnerable,
84 /// CLEANLIB-518 (§3): the requested semver range could not be resolved to a
85 /// concrete version (`source_state = RANGE_NOT_RESOLVED`). Distinct from
86 /// `NotYetAssessed` — the package is not un-assessed, the *range* is; the
87 /// customer must pin an exact version. Fail-closed needs-input (Warn).
88 RangeNotResolved,
89 /// No verdict on file yet — fail-closed needs-review.
90 NotYetAssessed,
91 /// No known issues found.
92 Clean,
93}
94
95impl CustomerState {
96 /// Derive the customer state from the wire `source` string (the values in
97 /// `crate::envelope::ALL_VERDICT_SOURCES`). Unknown / future variants fail
98 /// CLOSED to `NotYetAssessed` (warn) — never silently Clean.
99 ///
100 /// Two variants carry a note:
101 /// - `DM_THRESHOLD_BLOCK` → `BlockedByPolicy` (the customer's own rule).
102 /// The spec also routes a `DM_THRESHOLD_BLOCK` from the *curated*
103 /// supply-chain-compromise list (CLEANLIB-177) to `Compromised`, but that
104 /// distinction is carried by which rule fired, not by `source` alone, and
105 /// 177 is not yet shipped. Until 177 lands + the App tags curated-list
106 /// blocks, every `DM_THRESHOLD_BLOCK` is the customer's policy. See
107 /// [`from_block_origin`](CustomerState::from_block_origin).
108 /// - `VECTOR_VERDICT` is the legacy pre-176 full-eval source, preserved for
109 /// backward-compat. Post-176 the App projects findings into the specific
110 /// `CVE_FINDING*` variants, so a bare `VECTOR_VERDICT` reaching a render
111 /// surface is a legacy non-clean signal → `Vulnerable` (fail-safe).
112 pub fn from_wire(source: &str) -> CustomerState {
113 match source {
114 "ALLOWED_NO_FINDINGS" => CustomerState::Clean,
115 "CVE_FINDING" => CustomerState::Vulnerable,
116 "CVE_FINDING_ON_KEV" => CustomerState::ActivelyExploited,
117 "CVE_FINDING_ON_RANSOMWARE" => CustomerState::RansomwareLinked,
118 "MALICIOUS_TRIAGE" => CustomerState::Malicious,
119 "DM_THRESHOLD_BLOCK" => CustomerState::BlockedByPolicy,
120 "INSUFFICIENT_DATA" => CustomerState::NotYetAssessed,
121 // Legacy backward-compat — fail-safe to Vulnerable, never Clean.
122 "VECTOR_VERDICT" => CustomerState::Vulnerable,
123 // Unknown / future variant — fail CLOSED to needs-review.
124 _ => CustomerState::NotYetAssessed,
125 }
126 }
127
128 /// Same as [`from_wire`](CustomerState::from_wire) but lets the caller
129 /// resolve the `DM_THRESHOLD_BLOCK` ambiguity when it knows the block
130 /// origin. Use once CLEANLIB-177 ships and the App tags curated-list
131 /// blocks: `curated_compromise = true` routes `DM_THRESHOLD_BLOCK` to
132 /// `Compromised` instead of `BlockedByPolicy`.
133 pub fn from_block_origin(source: &str, curated_compromise: bool) -> CustomerState {
134 match (source, curated_compromise) {
135 ("DM_THRESHOLD_BLOCK", true) => CustomerState::Compromised,
136 _ => CustomerState::from_wire(source),
137 }
138 }
139
140 /// CLEANLIB-518 (§3): resolve the customer state taking the wire
141 /// `source_state` into account. A `RANGE_NOT_RESOLVED` source_state
142 /// (CLEANLIB-513 — App could not resolve the requested semver range) maps to
143 /// `RangeNotResolved` so the render banner reads "Range not resolved" and
144 /// agrees with the reasoning body, instead of collapsing to the generic
145 /// `NotYetAssessed` banner. All other source_states defer to
146 /// [`from_wire`](CustomerState::from_wire) on `source`.
147 pub fn from_wire_with_source_state(source: &str, source_state: Option<&str>) -> CustomerState {
148 match source_state {
149 // `RANGE_NOT_RESOLVED` (CLEANLIB-513/§3) is a source_state-only value
150 // — not a VerdictSource `from_wire` knows — so it keeps its explicit
151 // arm.
152 Some(s) if s.eq_ignore_ascii_case("RANGE_NOT_RESOLVED") => {
153 CustomerState::RangeNotResolved
154 }
155 // CLEANLIB-543: `source_state` is the envelope-v2 canonical status key
156 // (the v2 rename of `source`, carrying the refined origin —
157 // CVE_FINDING_ON_KEV / CVE_FINDING_ON_RANSOMWARE / MALICIOUS_TRIAGE /
158 // …). When present, derive from IT, not the coarse `source` (which the
159 // App may emit as a flat VECTOR_VERDICT on the v2 shape). This is what
160 // keeps `actively_exploited` / `ransomware_linked` from flattening to
161 // `vulnerable` when the origin rides in source_state. `from_wire`
162 // already maps the full VerdictSource corpus and fail-closes an
163 // unknown value to `not_yet_assessed`, so this stays fail-safe. Note
164 // the App emits `customer_state == from_wire(source_state)` server-side
165 // (§3), so preferring source_state is equivalent to trusting the
166 // pre-resolved customer_state.
167 Some(s) if !s.is_empty() => CustomerState::from_wire(s),
168 // v1 back-compat: no source_state → the origin rides in `source`.
169 _ => CustomerState::from_wire(source),
170 }
171 }
172
173 /// CLEANLIB-855: same as [`from_wire_with_source_state`], PLUS resolves
174 /// the `DM_THRESHOLD_BLOCK` → `Compromised` distinction via
175 /// [`from_block_origin`] when `matched_rule_id` names a curated
176 /// supply-chain-compromise-bridge rule (see
177 /// [`is_curated_supply_chain_compromise`]).
178 ///
179 /// This is the render call sites' entry point once the App threads
180 /// `matched_rule_id` onto the wire (CLEANLIB-855) — before that field
181 /// existed, [`from_wire_with_source_state`] was the only option and
182 /// `Compromised` was permanently unreachable from any real verdict
183 /// (only unit tests ever constructed it directly).
184 pub fn from_wire_with_source_state_and_origin(
185 source: &str,
186 source_state: Option<&str>,
187 matched_rule_id: Option<&str>,
188 ) -> CustomerState {
189 if is_curated_supply_chain_compromise(matched_rule_id) {
190 // `from_block_origin` only special-cases `DM_THRESHOLD_BLOCK` —
191 // resolve against whichever of source_state/source actually
192 // carries that token (same precedence as
193 // `from_wire_with_source_state`), THEN apply the curated-origin
194 // override on top.
195 let effective_source = match source_state {
196 Some(s) if !s.is_empty() => s,
197 _ => source,
198 };
199 return CustomerState::from_block_origin(effective_source, true);
200 }
201 CustomerState::from_wire_with_source_state(source, source_state)
202 }
203
204 /// Stable machine string for `--output json` `state` field + structured
205 /// MCP/SDK fields. snake_case, customer-vocabulary, NOT the wire variant.
206 pub fn as_str(self) -> &'static str {
207 match self {
208 CustomerState::BlockedByPolicy => "blocked_by_policy",
209 CustomerState::Compromised => "compromised",
210 CustomerState::Malicious => "malicious",
211 CustomerState::RansomwareLinked => "ransomware_linked",
212 CustomerState::ActivelyExploited => "actively_exploited",
213 CustomerState::Vulnerable => "vulnerable",
214 CustomerState::RangeNotResolved => "range_not_resolved",
215 CustomerState::NotYetAssessed => "not_yet_assessed",
216 CustomerState::Clean => "clean",
217 }
218 }
219
220 /// Customer-facing display label (Title-case, no codenames). §2 of spec.
221 pub fn label(self) -> &'static str {
222 match self {
223 CustomerState::BlockedByPolicy => "Blocked by policy",
224 CustomerState::Compromised => "Compromised",
225 CustomerState::Malicious => "Malicious",
226 CustomerState::RansomwareLinked => "Ransomware-linked",
227 CustomerState::ActivelyExploited => "Actively exploited",
228 CustomerState::Vulnerable => "Vulnerable",
229 CustomerState::RangeNotResolved => "Range not resolved",
230 CustomerState::NotYetAssessed => "Not yet assessed",
231 CustomerState::Clean => "Clean",
232 }
233 }
234
235 /// One-line, action-first customer copy (no codenames, no raw tier words).
236 /// §2 of spec — verbatim.
237 pub fn copy(self) -> &'static str {
238 match self {
239 CustomerState::BlockedByPolicy => {
240 "Blocked by your organization's policy. Contact your security \
241 owner or request an exception."
242 }
243 CustomerState::Compromised => {
244 "This version was tampered with in a supply-chain attack. Do \
245 not install — use a known-good version."
246 }
247 CustomerState::Malicious => {
248 "Known-malicious package (malware / typosquat). Do not install."
249 }
250 CustomerState::RansomwareLinked => {
251 "Carries a vulnerability tied to active ransomware campaigns. \
252 Upgrade before shipping."
253 }
254 CustomerState::ActivelyExploited => {
255 "Has a vulnerability that's being exploited in the wild (on the \
256 U.S. CISA exploited list). Upgrade urgently."
257 }
258 CustomerState::Vulnerable => {
259 "Has known vulnerabilities (CVEs). Review and upgrade to a \
260 fixed version."
261 }
262 CustomerState::RangeNotResolved => {
263 "The requested version range couldn't be resolved to a concrete \
264 version. Pin an exact version and re-check."
265 }
266 CustomerState::NotYetAssessed => {
267 "No verdict on file yet — treat as needs-review until assessed."
268 }
269 CustomerState::Clean => "No known issues found.",
270 }
271 }
272
273 /// Derive tier (block / warn / clean). `NotYetAssessed` is fail-closed →
274 /// Warn. Per §1 "Tier" column.
275 pub fn tier(self) -> Tier {
276 match self {
277 CustomerState::BlockedByPolicy
278 | CustomerState::Compromised
279 | CustomerState::Malicious
280 | CustomerState::RansomwareLinked
281 | CustomerState::ActivelyExploited => Tier::Block,
282 CustomerState::Vulnerable
283 | CustomerState::RangeNotResolved
284 | CustomerState::NotYetAssessed => Tier::Warn,
285 CustomerState::Clean => Tier::Clean,
286 }
287 }
288
289 /// Process exit code for the CLI gate.
290 pub fn exit_code(self) -> i32 {
291 self.tier().exit_code()
292 }
293
294 /// The "needs-input" family: warn-tier states that are NOT a security
295 /// finding but a gap the customer must resolve themselves — either no
296 /// verdict is on file yet (`NotYetAssessed`) or the requested version range
297 /// could not be resolved to a concrete version (`RangeNotResolved`,
298 /// CLEANLIB-518). Both share the `--cl-unknown` colour token + `❔` glyph
299 /// (§5). Render surfaces that distinguish "we found a problem" from "we
300 /// need input" — e.g. the LSP diagnostic severity, which shows this family
301 /// as INFORMATION rather than a WARNING finding — key on this predicate, so
302 /// a future needs-input state is threaded in ONE canonical place rather than
303 /// re-listed per surface (sister of the silent-drift discipline).
304 pub fn is_needs_input(self) -> bool {
305 matches!(
306 self,
307 CustomerState::NotYetAssessed | CustomerState::RangeNotResolved
308 )
309 }
310
311 /// CSS custom-property name for the soft severity token (extension + SDKs
312 /// consume these for theming). §1 "Soft colour token" column.
313 pub fn color_token(self) -> &'static str {
314 match self {
315 CustomerState::BlockedByPolicy => "--cl-policy",
316 CustomerState::Compromised => "--cl-compromised",
317 CustomerState::Malicious => "--cl-malicious",
318 CustomerState::RansomwareLinked => "--cl-ransomware",
319 CustomerState::ActivelyExploited => "--cl-exploited",
320 CustomerState::Vulnerable => "--cl-vulnerable",
321 // §5 reuse: range-not-resolved shares the needs-input token family;
322 // states differentiate by label, not a bespoke colour.
323 CustomerState::RangeNotResolved => "--cl-unknown",
324 CustomerState::NotYetAssessed => "--cl-unknown",
325 CustomerState::Clean => "--cl-clean",
326 }
327 }
328
329 /// Soft severity hex (draft-ratified working palette, §5). Brand cyan for
330 /// Clean; distinct mauve for policy so "your rule" reads apart from "our
331 /// finding" reds.
332 pub fn color_hex(self) -> &'static str {
333 match self {
334 CustomerState::BlockedByPolicy => "#C77DBB",
335 CustomerState::Compromised => "#D16D6A",
336 CustomerState::Malicious => "#D9534F",
337 CustomerState::RansomwareLinked => "#DB7B57",
338 CustomerState::ActivelyExploited => "#E0934A",
339 CustomerState::Vulnerable => "#D9A441",
340 CustomerState::RangeNotResolved => "#7C8696",
341 CustomerState::NotYetAssessed => "#7C8696",
342 CustomerState::Clean => "#50C0E0",
343 }
344 }
345
346 /// Text-surface emoji for MCP `human_message` + CLI text output. §1
347 /// "MCP/CLI emoji" column. One concept per tier; states differentiate by
348 /// label + colour, not a bespoke glyph family (§5 — reuse, don't cut).
349 pub fn emoji(self) -> &'static str {
350 match self {
351 CustomerState::BlockedByPolicy => "🛑",
352 CustomerState::Compromised => "⛔",
353 CustomerState::Malicious => "⛔",
354 CustomerState::RansomwareLinked => "🔴",
355 CustomerState::ActivelyExploited => "🔴",
356 CustomerState::Vulnerable => "⚠️",
357 CustomerState::RangeNotResolved => "❔",
358 CustomerState::NotYetAssessed => "❔",
359 CustomerState::Clean => "✅",
360 }
361 }
362
363 /// All 9 states, severity order (highest first). For exhaustive contract
364 /// tests + doc generation. CLEANLIB-518(§3) added RangeNotResolved (8→9).
365 pub fn all() -> [CustomerState; 9] {
366 [
367 CustomerState::BlockedByPolicy,
368 CustomerState::Compromised,
369 CustomerState::Malicious,
370 CustomerState::RansomwareLinked,
371 CustomerState::ActivelyExploited,
372 CustomerState::Vulnerable,
373 CustomerState::RangeNotResolved,
374 CustomerState::NotYetAssessed,
375 CustomerState::Clean,
376 ]
377 }
378}
379
380/// CLEANLIB-855: `true` iff `matched_rule_id` names a curated
381/// supply-chain-compromise-bridge policy rule (the App-side prefix
382/// convention for the CLEANLIB-177 curated compromise list), rather than an
383/// ordinary customer policy rule. This is the one place that prefix
384/// convention is checked on the render side — callers should go through this
385/// function (or [`CustomerState::from_wire_with_source_state_and_origin`]
386/// directly) rather than re-implementing the `starts_with` check, so the
387/// convention only needs to change in one place if it ever does.
388pub fn is_curated_supply_chain_compromise(matched_rule_id: Option<&str>) -> bool {
389 matched_rule_id
390 .map(|id| id.starts_with("supply-chain-compromise-bridge-"))
391 .unwrap_or(false)
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use crate::envelope::ALL_VERDICT_SOURCES;
398
399 // ── The wire→state mappings (the cross-surface contract) ────────────────
400 #[test]
401 fn wire_to_state_mapping_is_canonical() {
402 use CustomerState::*;
403 assert_eq!(CustomerState::from_wire("ALLOWED_NO_FINDINGS"), Clean);
404 assert_eq!(CustomerState::from_wire("CVE_FINDING"), Vulnerable);
405 assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_KEV"), ActivelyExploited);
406 assert_eq!(CustomerState::from_wire("CVE_FINDING_ON_RANSOMWARE"), RansomwareLinked);
407 assert_eq!(CustomerState::from_wire("MALICIOUS_TRIAGE"), Malicious);
408 assert_eq!(CustomerState::from_wire("DM_THRESHOLD_BLOCK"), BlockedByPolicy);
409 assert_eq!(CustomerState::from_wire("INSUFFICIENT_DATA"), NotYetAssessed);
410 assert_eq!(CustomerState::from_wire("VECTOR_VERDICT"), Vulnerable);
411 }
412
413 #[test]
414 fn every_known_wire_source_maps_without_falling_through() {
415 // Anti-drift: every value the envelope declares must have an explicit
416 // mapping (not silently hit the fail-closed `_` arm). If 176/177 adds
417 // a wire source, this fails until the map is extended.
418 for &src in ALL_VERDICT_SOURCES {
419 let state = CustomerState::from_wire(src);
420 // ALLOWED_NO_FINDINGS is the only known source that may be Clean.
421 if src != "ALLOWED_NO_FINDINGS" {
422 assert_ne!(
423 state,
424 CustomerState::Clean,
425 "known non-clean source {src} mapped to Clean"
426 );
427 }
428 }
429 }
430
431 #[test]
432 fn cleanlib_518_range_not_resolved_source_state_maps_distinctly() {
433 // §3: a RANGE_NOT_RESOLVED source_state yields the distinct
434 // RangeNotResolved state (not the generic NotYetAssessed), so the CLI
435 // banner label agrees with the reasoning body. Case-insensitive.
436 assert_eq!(
437 CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("RANGE_NOT_RESOLVED")),
438 CustomerState::RangeNotResolved
439 );
440 assert_eq!(
441 CustomerState::from_wire_with_source_state("INSUFFICIENT_DATA", Some("range_not_resolved")),
442 CustomerState::RangeNotResolved
443 );
444 // Banner↔body coherence: the label is no longer "Not yet assessed".
445 assert_eq!(CustomerState::RangeNotResolved.label(), "Range not resolved");
446 assert_ne!(
447 CustomerState::RangeNotResolved.label(),
448 CustomerState::NotYetAssessed.label()
449 );
450 // Fail-closed: an unresolved range is a WARN, never an ALLOW.
451 assert_eq!(CustomerState::RangeNotResolved.exit_code(), 2);
452 // No source_state (or a different one) still defers to `source`.
453 assert_eq!(
454 CustomerState::from_wire_with_source_state("CVE_FINDING", None),
455 CustomerState::Vulnerable
456 );
457 assert_eq!(
458 CustomerState::from_wire_with_source_state("CVE_FINDING", Some("CVE_FINDING")),
459 CustomerState::Vulnerable
460 );
461 }
462
463 #[test]
464 fn unknown_source_fails_closed_to_needs_review() {
465 assert_eq!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::NotYetAssessed);
466 assert_eq!(CustomerState::from_wire(""), CustomerState::NotYetAssessed);
467 assert_ne!(CustomerState::from_wire("SOME_FUTURE_VARIANT"), CustomerState::Clean);
468 }
469
470 #[test]
471 fn curated_compromise_routes_dm_block_to_compromised() {
472 assert_eq!(
473 CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", true),
474 CustomerState::Compromised
475 );
476 assert_eq!(
477 CustomerState::from_block_origin("DM_THRESHOLD_BLOCK", false),
478 CustomerState::BlockedByPolicy
479 );
480 assert_eq!(
481 CustomerState::from_block_origin("CVE_FINDING", true),
482 CustomerState::Vulnerable
483 );
484 }
485
486 #[test]
487 fn tier_and_exit_codes() {
488 assert_eq!(CustomerState::Clean.exit_code(), 0);
489 assert_eq!(CustomerState::Vulnerable.exit_code(), 2);
490 assert_eq!(CustomerState::NotYetAssessed.exit_code(), 2); // fail-closed
491 for s in [
492 CustomerState::ActivelyExploited,
493 CustomerState::RansomwareLinked,
494 CustomerState::Malicious,
495 CustomerState::Compromised,
496 CustomerState::BlockedByPolicy,
497 ] {
498 assert_eq!(s.exit_code(), 1, "{s:?} must be block-tier");
499 }
500 }
501
502 #[test]
503 fn no_internal_codenames_in_customer_strings() {
504 let banned = [
505 "Vector", "VECTOR", "Tricorder", "DmThreshold", "DM_THRESHOLD",
506 "CveFinding", "CVE_FINDING", "MaliciousTriage", "MALICIOUS_TRIAGE",
507 "InsufficientData", "INSUFFICIENT_DATA", "vuln_xref", "mock", "fixture",
508 ];
509 for s in CustomerState::all() {
510 for text in [s.label(), s.copy()] {
511 for b in banned {
512 assert!(
513 !text.contains(b),
514 "codename {b:?} leaked into customer string {text:?} for {s:?}"
515 );
516 }
517 }
518 }
519 }
520
521 #[test]
522 fn no_raw_tier_words_in_customer_copy() {
523 for s in CustomerState::all() {
524 for b in ["DENY", "WARN", "ALLOW"] {
525 assert!(!s.copy().contains(b), "raw tier word {b} in copy for {s:?}");
526 assert!(!s.label().contains(b), "raw tier word {b} in label for {s:?}");
527 }
528 }
529 }
530
531 #[test]
532 fn every_state_has_complete_token_set() {
533 for s in CustomerState::all() {
534 assert!(!s.as_str().is_empty());
535 assert!(!s.label().is_empty());
536 assert!(!s.copy().is_empty());
537 assert!(s.color_token().starts_with("--cl-"));
538 assert!(s.color_hex().starts_with('#') && s.color_hex().len() == 7);
539 assert!(!s.emoji().is_empty());
540 }
541 }
542
543 #[test]
544 fn clean_uses_brand_cyan_others_do_not() {
545 assert_eq!(CustomerState::Clean.color_hex(), "#50C0E0");
546 for s in CustomerState::all() {
547 if s != CustomerState::Clean {
548 assert_ne!(s.color_hex(), "#50C0E0", "{s:?} must not reuse brand cyan");
549 }
550 }
551 }
552
553 #[test]
554 fn needs_input_family_is_exactly_not_yet_assessed_and_range_not_resolved() {
555 // The needs-input predicate is the single source of truth for "not a
556 // finding, needs customer input". It must cover exactly the two states
557 // that share the `--cl-unknown` token + `❔` glyph — no more, no less.
558 // A future state added to this family updates this test + the predicate
559 // together; a finding state accidentally landing here fails loudly.
560 for s in CustomerState::all() {
561 let expected = matches!(
562 s,
563 CustomerState::NotYetAssessed | CustomerState::RangeNotResolved
564 );
565 assert_eq!(s.is_needs_input(), expected, "{s:?}");
566 if s.is_needs_input() {
567 // Needs-input states are warn-tier (never block/clean) and carry
568 // the shared unknown visual family — the invariant the LSP and
569 // other surfaces rely on.
570 assert_eq!(s.tier(), Tier::Warn, "{s:?} needs-input must be warn-tier");
571 assert_eq!(s.color_token(), "--cl-unknown", "{s:?}");
572 assert_eq!(s.emoji(), "❔", "{s:?}");
573 }
574 }
575 }
576
577 #[test]
578 fn machine_strings_are_unique() {
579 let mut seen = std::collections::HashSet::new();
580 for s in CustomerState::all() {
581 assert!(seen.insert(s.as_str()), "duplicate state string {}", s.as_str());
582 }
583 }
584 #[test]
585 fn cleanlib_657_cx8_only_clean_is_allowed_tier() {
586 // CX-8 type invariant: `Clean` is the ONLY state at the allowed (Clean)
587 // tier. Every other state — crucially NotYetAssessed and RangeNotResolved
588 // (the "we did not / could not assess" states) — is Warn or Block tier,
589 // so a caller can NEVER accidentally treat "not assessed" as allowed.
590 // [Absence≠safe] enforced by the type, not by convention.
591 use CustomerState::*;
592 let all = [
593 BlockedByPolicy, Compromised, Malicious, RansomwareLinked,
594 ActivelyExploited, Vulnerable, RangeNotResolved, NotYetAssessed, Clean,
595 ];
596 for s in all {
597 if matches!(s, Clean) {
598 assert_eq!(s.tier(), Tier::Clean, "Clean must be allowed-tier");
599 } else {
600 assert_ne!(s.tier(), Tier::Clean, "{s:?} must NEVER be allowed (Clean) tier");
601 }
602 }
603 }
604
605 // ─── CLEANLIB-855: from_block_origin finally has a real call path ────────
606
607 #[test]
608 fn cleanlib_855_is_curated_supply_chain_compromise_matches_the_real_bridge_prefix() {
609 assert!(is_curated_supply_chain_compromise(Some(
610 "supply-chain-compromise-bridge-event-stream-2018-mh6f-8j2x-4483"
611 )));
612 assert!(is_curated_supply_chain_compromise(Some(
613 "supply-chain-compromise-bridge-ctx-2022-67r3-h899-9w95-compromised"
614 )));
615 // An ordinary customer policy rule is NOT a curated compromise match,
616 // even if it happens to deny the same package.
617 assert!(!is_curated_supply_chain_compromise(Some("rule_cors_deny_old")));
618 assert!(!is_curated_supply_chain_compromise(None));
619 assert!(!is_curated_supply_chain_compromise(Some("")));
620 }
621
622 #[test]
623 fn cleanlib_855_from_wire_with_source_state_and_origin_renders_compromised() {
624 // The exact counterexample the ticket asks for: a curated bridge rule
625 // match must render Compromised, not the generic BlockedByPolicy.
626 let state = CustomerState::from_wire_with_source_state_and_origin(
627 "DM_THRESHOLD_BLOCK",
628 None,
629 Some("supply-chain-compromise-bridge-event-stream-2018-mh6f-8j2x-4483"),
630 );
631 assert_eq!(state, CustomerState::Compromised);
632 assert_eq!(state.label(), "Compromised");
633 }
634
635 #[test]
636 fn cleanlib_855_from_wire_with_source_state_and_origin_ordinary_policy_denies_unaffected() {
637 // Counterexample's other pole: an ordinary customer DENY rule must
638 // still render BlockedByPolicy, not spuriously become Compromised.
639 let state = CustomerState::from_wire_with_source_state_and_origin(
640 "DM_THRESHOLD_BLOCK",
641 None,
642 Some("rule_cors_deny_old"),
643 );
644 assert_eq!(state, CustomerState::BlockedByPolicy);
645 }
646
647 #[test]
648 fn cleanlib_855_from_wire_with_source_state_and_origin_none_matches_plain_from_wire() {
649 // No matched_rule_id at all (pre-855 App, or a non-policy source) must
650 // behave IDENTICALLY to the pre-855 from_wire_with_source_state --
651 // this is the byte-identical-on-the-common-path guarantee.
652 for src in ALL_VERDICT_SOURCES {
653 assert_eq!(
654 CustomerState::from_wire_with_source_state_and_origin(src, None, None),
655 CustomerState::from_wire_with_source_state(src, None),
656 "no matched_rule_id must never change behavior for source {src}"
657 );
658 }
659 }
660
661 #[test]
662 fn cleanlib_855_curated_compromise_never_fires_on_a_non_block_source() {
663 // A curated-looking rule id paired with a NON-DM_THRESHOLD_BLOCK
664 // source (shouldn't happen in practice, but defensive) must not
665 // spuriously produce Compromised -- from_block_origin only
666 // special-cases the exact ("DM_THRESHOLD_BLOCK", true) pair.
667 let state = CustomerState::from_wire_with_source_state_and_origin(
668 "CVE_FINDING",
669 None,
670 Some("supply-chain-compromise-bridge-whatever"),
671 );
672 assert_eq!(state, CustomerState::Vulnerable);
673 }
674
675 #[test]
676 fn cleanlib_855_source_state_takes_precedence_over_source_for_origin_resolution() {
677 // Mirrors from_wire_with_source_state's own precedence: when
678 // source_state is present and non-empty, it -- not the legacy
679 // `source` -- is what the block-origin override resolves against.
680 let state = CustomerState::from_wire_with_source_state_and_origin(
681 "VECTOR_VERDICT", // legacy source, would NOT match DM_THRESHOLD_BLOCK
682 Some("DM_THRESHOLD_BLOCK"),
683 Some("supply-chain-compromise-bridge-event-stream-2018-mh6f-8j2x-4483"),
684 );
685 assert_eq!(state, CustomerState::Compromised);
686 }
687}