leviso_cheat_test/
lib.rs

1//! # leviso-cheat-test
2//!
3//! A proc-macro crate for creating "cheat-aware" tests that document how they
4//! could be cheated and what the consequences would be for users.
5//!
6//! # STOP. READ. THEN ACT.
7//!
8//! Before modifying this crate, read the existing implementation to understand
9//! how cheat documentation is structured.
10//!
11//! ## Why This Exists
12//!
13//! On 2026-01-20, a developer created false positives by moving missing binaries
14//! to "OPTIONAL" lists to make tests pass while shipping a broken product. This
15//! crate ensures that every test documents:
16//!
17//! 1. What user scenario it protects
18//! 2. How the test could be cheated
19//! 3. What users experience when the test is cheated
20//! 4. The severity and ease of cheating
21//!
22//! ## Usage
23//!
24//! ```rust,ignore
25//! use leviso_cheat_test::cheat_aware;
26//!
27//! #[cheat_aware(
28//!     protects = "User can run sudo commands",
29//!     severity = "CRITICAL",
30//!     ease = "EASY",
31//!     cheats = ["Move sudo to OPTIONAL list", "Remove sudo from essential binaries"],
32//!     consequence = "bash: sudo: command not found",
33//!     legitimate_change = "If sudo is genuinely not needed for a headless profile, \
34//!         add it to the profile's optional list in builder/src/profiles.rs"
35//! )]
36//! #[test]
37//! fn test_sudo_binary_present() {
38//!     assert!(tarball_contains("./usr/bin/sudo"));
39//! }
40//! ```
41//!
42//! ## On Failure
43//!
44//! When a cheat-aware test fails, it prints:
45//!
46//! ```text
47//! ======================================================================
48//! === TEST FAILED: test_sudo_binary_present ===
49//! ======================================================================
50//!
51//! PROTECTS: User can run sudo commands
52//! SEVERITY: CRITICAL
53//! EASE OF CHEATING: EASY
54//!
55//! CHEAT VECTORS:
56//!   1. Move sudo to OPTIONAL list
57//!   2. Remove sudo from essential binaries
58//!
59//! LEGITIMATE CHANGE PATH:
60//!   If sudo is genuinely not needed for a headless profile,
61//!   add it to the profile's optional list in builder/src/profiles.rs
62//!
63//! USER CONSEQUENCE:
64//!   bash: sudo: command not found
65//!
66//! ORIGINAL ERROR:
67//!   assertion failed: tarball_contains("./usr/bin/sudo")
68//! ======================================================================
69//! ```
70
71use proc_macro::TokenStream;
72use quote::quote;
73use syn::{
74    parse::{Parse, ParseStream},
75    parse_macro_input,
76    punctuated::Punctuated,
77    ExprLit, Ident, ItemFn, Lit, Token,
78};
79
80/// Metadata for a cheat-aware test.
81struct CheatAwareArgs {
82    protects: String,
83    severity: String,
84    ease: String,
85    cheats: Vec<String>,
86    consequence: String,
87    /// Optional: describes how to legitimately change behavior instead of cheating
88    legitimate_change: Option<String>,
89}
90
91impl Default for CheatAwareArgs {
92    fn default() -> Self {
93        Self {
94            protects: "UNSPECIFIED".to_string(),
95            severity: "UNSPECIFIED".to_string(),
96            ease: "UNSPECIFIED".to_string(),
97            cheats: vec!["UNSPECIFIED".to_string()],
98            consequence: "UNSPECIFIED".to_string(),
99            legitimate_change: None,
100        }
101    }
102}
103
104/// A single key = value or key = [value, ...] assignment.
105struct MetaItem {
106    key: Ident,
107    value: MetaValue,
108}
109
110enum MetaValue {
111    Str(String),
112    Array(Vec<String>),
113}
114
115impl Parse for MetaItem {
116    fn parse(input: ParseStream) -> syn::Result<Self> {
117        let key: Ident = input.parse()?;
118        let _: Token![=] = input.parse()?;
119
120        let value = if input.peek(syn::token::Bracket) {
121            // Parse array: [...]
122            let content;
123            syn::bracketed!(content in input);
124            let items: Punctuated<ExprLit, Token![,]> =
125                content.parse_terminated(ExprLit::parse, Token![,])?;
126
127            let strings: Vec<String> = items
128                .into_iter()
129                .filter_map(|expr| {
130                    if let Lit::Str(s) = expr.lit {
131                        Some(s.value())
132                    } else {
133                        None
134                    }
135                })
136                .collect();
137
138            MetaValue::Array(strings)
139        } else {
140            // Parse string literal
141            let lit: ExprLit = input.parse()?;
142            if let Lit::Str(s) = lit.lit {
143                MetaValue::Str(s.value())
144            } else {
145                return Err(syn::Error::new_spanned(lit, "expected string literal"));
146            }
147        };
148
149        Ok(MetaItem { key, value })
150    }
151}
152
153impl Parse for CheatAwareArgs {
154    fn parse(input: ParseStream) -> syn::Result<Self> {
155        let mut args = CheatAwareArgs::default();
156
157        let items: Punctuated<MetaItem, Token![,]> =
158            input.parse_terminated(MetaItem::parse, Token![,])?;
159
160        for item in items {
161            let key = item.key.to_string();
162            match (key.as_str(), item.value) {
163                ("protects", MetaValue::Str(s)) => args.protects = s,
164                ("severity", MetaValue::Str(s)) => args.severity = s,
165                ("ease", MetaValue::Str(s)) => args.ease = s,
166                ("consequence", MetaValue::Str(s)) => args.consequence = s,
167                ("cheats", MetaValue::Array(arr)) => args.cheats = arr,
168                ("legitimate_change", MetaValue::Str(s)) => args.legitimate_change = Some(s),
169                (key, _) => {
170                    return Err(syn::Error::new_spanned(
171                        item.key,
172                        format!(
173                            "unknown key '{}'. Valid keys: protects, severity, ease, cheats, consequence, legitimate_change",
174                            key
175                        ),
176                    ))
177                }
178            }
179        }
180
181        Ok(args)
182    }
183}
184
185/// Mark a test as cheat-aware, documenting how it could be cheated.
186///
187/// # Attributes
188///
189/// - `protects` - What user scenario this test protects (string)
190/// - `severity` - Impact severity: "CRITICAL", "HIGH", "MEDIUM", "LOW" (string)
191/// - `ease` - How easy it is to cheat: "EASY", "MEDIUM", "HARD" (string)
192/// - `cheats` - List of ways to cheat this test (array of strings)
193/// - `consequence` - What users see when cheated (string)
194/// - `legitimate_change` - Optional: how to legitimately change behavior (string)
195///
196/// The `legitimate_change` field implements "inoculation prompting" from Anthropic's
197/// research on emergent misalignment. By explicitly describing the legitimate path
198/// for changing behavior, we reduce the temptation to cheat.
199///
200/// # Example
201///
202/// ```rust,ignore
203/// #[cheat_aware(
204///     protects = "User can log in",
205///     severity = "CRITICAL",
206///     ease = "EASY",
207///     cheats = ["Skip PAM config check", "Accept any password"],
208///     consequence = "Authentication failure",
209///     legitimate_change = "If PAM is genuinely not needed (e.g., embedded system), \
210///         configure the profile in builder/src/profiles.rs with auth_method = None"
211/// )]
212/// #[test]
213/// fn test_login_works() {
214///     // ...
215/// }
216/// ```
217#[proc_macro_attribute]
218pub fn cheat_aware(args: TokenStream, input: TokenStream) -> TokenStream {
219    let args = parse_macro_input!(args as CheatAwareArgs);
220    let input_fn = parse_macro_input!(input as ItemFn);
221
222    let fn_name = &input_fn.sig.ident;
223    let fn_name_str = fn_name.to_string();
224    let fn_attrs = &input_fn.attrs;
225    let fn_vis = &input_fn.vis;
226    let fn_block = &input_fn.block;
227    let fn_asyncness = &input_fn.sig.asyncness;
228
229    let protects = &args.protects;
230    let severity = &args.severity;
231    let ease = &args.ease;
232    let consequence = &args.consequence;
233    let cheats = &args.cheats;
234    let legitimate_change = args.legitimate_change.as_deref().unwrap_or("");
235    let has_legitimate_change = args.legitimate_change.is_some();
236
237    // Build the cheat list as numbered items
238    let cheats_display: Vec<String> = cheats
239        .iter()
240        .enumerate()
241        .map(|(i, c)| format!("  {}. {}", i + 1, c))
242        .collect();
243    let cheats_joined = cheats_display.join("\n");
244
245    // For async functions, we can't use catch_unwind, so just run the body directly
246    // The test framework will catch any panics
247    let body = if fn_asyncness.is_some() {
248        quote! {
249            // Context variables (for documentation/debugging)
250            let _test_name = #fn_name_str;
251            let _protects = #protects;
252            let _severity = #severity;
253            let _ease = #ease;
254            let _consequence = #consequence;
255            let _cheats = #cheats_joined;
256            let _legitimate_change = #legitimate_change;
257
258            // Print what this test ensures (visible with --nocapture)
259            println!("  ensures: {}", _protects);
260
261            // For async tests, we run directly and let the test framework handle panics
262            // The cheat metadata is available in the source code for documentation
263            #fn_block
264        }
265    } else {
266        quote! {
267            // Context printed at test start (visible with --nocapture or on failure)
268            let _test_name = #fn_name_str;
269            let _protects = #protects;
270            let _severity = #severity;
271            let _ease = #ease;
272            let _consequence = #consequence;
273            let _cheats = #cheats_joined;
274            let _legitimate_change = #legitimate_change;
275            let _has_legitimate_change = #has_legitimate_change;
276
277            // Print what this test ensures (visible with --nocapture)
278            println!("  ensures: {}", _protects);
279
280            // Wrap the test body to enhance panic messages
281            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
282                #fn_block
283            }));
284
285            if let Err(e) = result {
286                // Extract the panic message
287                let panic_msg = if let Some(s) = e.downcast_ref::<&str>() {
288                    (*s).to_string()
289                } else if let Some(s) = e.downcast_ref::<String>() {
290                    s.clone()
291                } else {
292                    "Unknown panic".to_string()
293                };
294
295                // Print the enhanced failure message
296                eprintln!("\n{}", "=".repeat(70));
297                eprintln!("=== TEST FAILED: {} ===", _test_name);
298                eprintln!("{}", "=".repeat(70));
299                eprintln!();
300                eprintln!("PROTECTS: {}", _protects);
301                eprintln!("SEVERITY: {}", _severity);
302                eprintln!("EASE OF CHEATING: {}", _ease);
303                eprintln!();
304                eprintln!("CHEAT VECTORS:");
305                eprintln!("{}", _cheats);
306                if _has_legitimate_change {
307                    eprintln!();
308                    eprintln!("LEGITIMATE CHANGE PATH:");
309                    eprintln!("  {}", _legitimate_change);
310                }
311                eprintln!();
312                eprintln!("USER CONSEQUENCE:");
313                eprintln!("  {}", _consequence);
314                eprintln!();
315                eprintln!("ORIGINAL ERROR:");
316                eprintln!("  {}", panic_msg);
317                eprintln!("{}", "=".repeat(70));
318                eprintln!();
319
320                // Re-panic to fail the test
321                std::panic::resume_unwind(e);
322            }
323        }
324    };
325
326    let expanded = quote! {
327        #(#fn_attrs)*
328        #fn_vis #fn_asyncness fn #fn_name() {
329            #body
330        }
331    };
332
333    TokenStream::from(expanded)
334}
335
336/// A simpler version of `cheat_aware` for tests where full metadata isn't needed.
337///
338/// Just marks a test as having been reviewed for cheat vectors.
339///
340/// # Example
341///
342/// ```rust,ignore
343/// #[cheat_reviewed("Unit test for version parsing - no cheat vectors")]
344/// #[test]
345/// fn test_parse_version() {
346///     // ...
347/// }
348/// ```
349#[proc_macro_attribute]
350pub fn cheat_reviewed(args: TokenStream, input: TokenStream) -> TokenStream {
351    let _reason: syn::LitStr = parse_macro_input!(args as syn::LitStr);
352    let input_fn = parse_macro_input!(input as ItemFn);
353
354    let fn_name = &input_fn.sig.ident;
355    let fn_attrs = &input_fn.attrs;
356    let fn_vis = &input_fn.vis;
357    let fn_block = &input_fn.block;
358    let fn_asyncness = &input_fn.sig.asyncness;
359
360    let expanded = quote! {
361        #(#fn_attrs)*
362        #fn_vis #fn_asyncness fn #fn_name() {
363            // This test has been reviewed for cheat vectors.
364            #fn_block
365        }
366    };
367
368    TokenStream::from(expanded)
369}
370
371/// Metadata for a canary test.
372struct CheatCanaryArgs {
373    bait: String,
374    tripwire: String,
375}
376
377impl Default for CheatCanaryArgs {
378    fn default() -> Self {
379        Self {
380            bait: "UNSPECIFIED".to_string(),
381            tripwire: "UNSPECIFIED".to_string(),
382        }
383    }
384}
385
386impl Parse for CheatCanaryArgs {
387    fn parse(input: ParseStream) -> syn::Result<Self> {
388        let mut args = CheatCanaryArgs::default();
389
390        let items: Punctuated<MetaItem, Token![,]> =
391            input.parse_terminated(MetaItem::parse, Token![,])?;
392
393        for item in items {
394            let key = item.key.to_string();
395            match (key.as_str(), item.value) {
396                ("bait", MetaValue::Str(s)) => args.bait = s,
397                ("tripwire", MetaValue::Str(s)) => args.tripwire = s,
398                (key, _) => {
399                    return Err(syn::Error::new_spanned(
400                        item.key,
401                        format!("unknown key '{}'. Valid keys: bait, tripwire", key),
402                    ))
403                }
404            }
405        }
406
407        Ok(args)
408    }
409}
410
411/// Mark a test as a canary - an intentionally verbose/tedious test that
412/// should trigger extra scrutiny if modified.
413///
414/// Canary tests are designed to be "bait" for cheating - they look like
415/// easy targets for simplification but modifications should trigger
416/// additional review.
417///
418/// # Attributes
419///
420/// - `bait` - Why this test looks tempting to simplify (string)
421/// - `tripwire` - What happens when this test is modified (string)
422///
423/// # Detection Mechanism
424///
425/// CI should check if files containing `#[cheat_canary]` tests are modified:
426/// - Add `[CANARY TRIGGERED]` label to PR
427/// - Require additional reviewer approval
428/// - Flag for human review
429///
430/// # Example
431///
432/// ```rust,ignore
433/// #[cheat_canary(
434///     bait = "This test looks tedious and tempts simplification to a loop",
435///     tripwire = "Any modification triggers full audit of all test changes"
436/// )]
437/// #[test]
438/// fn canary_verbose_binary_check() {
439///     // Intentionally verbose - checks each binary individually
440///     // A cheater would want to simplify this to a loop
441///     assert!(exists("/usr/bin/ls"), "ls missing");
442///     assert!(exists("/usr/bin/cat"), "cat missing");
443///     assert!(exists("/usr/bin/mount"), "mount missing");
444///     // ... many more individual assertions
445/// }
446/// ```
447///
448/// # Why Canaries Work
449///
450/// 1. They look like easy wins for "cleanup" or "refactoring"
451/// 2. But any change to them is suspicious by definition
452/// 3. The tripwire creates asymmetric cost: cheating is more expensive than honest work
453#[proc_macro_attribute]
454pub fn cheat_canary(args: TokenStream, input: TokenStream) -> TokenStream {
455    let args = parse_macro_input!(args as CheatCanaryArgs);
456    let input_fn = parse_macro_input!(input as ItemFn);
457
458    let fn_name = &input_fn.sig.ident;
459    let fn_name_str = fn_name.to_string();
460    let fn_attrs = &input_fn.attrs;
461    let fn_vis = &input_fn.vis;
462    let fn_block = &input_fn.block;
463    let fn_asyncness = &input_fn.sig.asyncness;
464
465    let bait = &args.bait;
466    let tripwire = &args.tripwire;
467
468    let expanded = quote! {
469        #(#fn_attrs)*
470        #fn_vis #fn_asyncness fn #fn_name() {
471            // CANARY TEST - Modifications to this test trigger extra scrutiny
472            // Bait: #bait
473            // Tripwire: #tripwire
474            //
475            // This comment is intentionally verbose. Do not remove or simplify.
476            // The canary detection system monitors this file for changes.
477            let _canary_test_name = #fn_name_str;
478            let _canary_bait = #bait;
479            let _canary_tripwire = #tripwire;
480
481            #fn_block
482        }
483    };
484
485    TokenStream::from(expanded)
486}