Skip to main content

assay_sim/
suite.rs

1use crate::attacks;
2use crate::differential;
3use crate::report::{AttackResult, AttackStatus, SimReport};
4use anyhow::Result;
5use assay_evidence::VerifyLimits;
6use std::path::PathBuf;
7use std::time::{Duration, Instant};
8
9#[derive(Debug, Clone)]
10pub enum SuiteTier {
11    Quick,
12    Nightly,
13    Stress,
14    Chaos,
15}
16
17#[derive(Debug, Clone)]
18pub struct SuiteConfig {
19    pub tier: SuiteTier,
20    pub target_bundle: PathBuf,
21    pub seed: u64,
22    pub verify_limits: Option<VerifyLimits>,
23    /// Time budget in seconds (default 60). Used to create TimeBudget.
24    pub time_budget_secs: u64,
25}
26
27/// Time budget for an entire suite run.
28///
29/// If the elapsed time exceeds the budget, remaining phases are skipped and
30/// the runner reports `AttackStatus::Error` with "time budget exceeded".
31#[derive(Debug, Clone)]
32pub struct TimeBudget {
33    start: Instant,
34    limit: Duration,
35}
36
37/// Tier-specific default limits (ADR-024: Quick stays small so the suite fits its budget).
38/// Single source of truth for tier defaults; used by CLI and suite.
39/// Input is normalized (trim + lowercase) for case-insensitive matching.
40pub fn tier_default_limits(tier: &str) -> VerifyLimits {
41    let mut defaults = VerifyLimits::default();
42    if tier.trim().to_lowercase() == "quick" {
43        defaults.max_bundle_bytes = 5 * 1024 * 1024; // 5 MB
44                                                     // The default decode ceiling is 1 GiB. A bomb sized from that value is refused as
45                                                     // LimitDecodeBytes, but debug verify of 1 GiB of padding was measured at ~70s, which
46                                                     // exceeds the 60s Quick budget. 32 MiB still exercises the decode axis and fits.
47        defaults.max_decode_bytes = 32 * 1024 * 1024;
48    }
49    defaults
50}
51
52impl TimeBudget {
53    pub fn new(limit: Duration) -> Self {
54        Self {
55            start: Instant::now(),
56            limit,
57        }
58    }
59
60    /// Default suite budget: 60 seconds.
61    /// Note: Raised from 30s because a decode bomb that actually reaches LimitDecodeBytes
62    /// has to expand past the Quick decode ceiling, and slower CI runners need headroom.
63    pub fn default_suite() -> Self {
64        Self::new(Duration::from_secs(60))
65    }
66
67    pub fn exceeded(&self) -> bool {
68        self.start.elapsed() > self.limit
69    }
70
71    pub fn elapsed(&self) -> Duration {
72        self.start.elapsed()
73    }
74
75    pub fn remaining(&self) -> Duration {
76        self.limit.saturating_sub(self.start.elapsed())
77    }
78}
79
80/// The phases a tier never runs.
81///
82/// Kept beside `run_suite`'s phase gates rather than derived from them, because the
83/// gates are `if` statements over a tier and cannot be enumerated. The pairing is
84/// pinned by `chaos_phase_gate_matches_the_declared_omission`, so a new gate that
85/// forgets this list fails a test instead of quietly overstating the run.
86fn phases_not_attempted_for(tier: &SuiteTier) -> Vec<String> {
87    match tier {
88        SuiteTier::Chaos => Vec::new(),
89        SuiteTier::Quick | SuiteTier::Nightly | SuiteTier::Stress => vec!["chaos".to_string()],
90    }
91}
92
93pub fn run_suite(cfg: SuiteConfig) -> Result<SimReport> {
94    let mut report = SimReport::new(&format!("{:?}", cfg.tier), cfg.seed);
95    // The chaos phase is gated on the Chaos tier below. Naming it here keeps the
96    // statement next to the gate that causes it, so a new gate that forgets to
97    // update this list is a visible omission rather than a silent one.
98    report.set_phases_not_attempted(phases_not_attempted_for(&cfg.tier));
99    let budget = TimeBudget::new(Duration::from_secs(cfg.time_budget_secs));
100    let limits = cfg
101        .verify_limits
102        .unwrap_or_else(|| tier_default_limits(&format!("{:?}", cfg.tier).to_lowercase()));
103
104    // 1. Integrity Attacks (all tiers)
105    //
106    // Note: The workspace uses panic="abort" in dev/release profiles, so catch_unwind
107    // is not effective. Integrity attacks run in-process (they don't trigger panics —
108    // they test verification outcomes). Chaos/differential attacks use subprocess
109    // isolation instead.
110    {
111        let seed = cfg.seed;
112        let start = Instant::now();
113        let mut inner_report = SimReport::new("integrity", seed);
114        match attacks::integrity::check_integrity_attacks(&mut inner_report, seed, limits, &budget)
115        {
116            Ok(()) => {
117                for r in inner_report.results {
118                    report.add_result(r);
119                }
120            }
121            Err(attacks::integrity::IntegrityError::BudgetExceeded) => {
122                for r in inner_report.results {
123                    report.add_result(r);
124                }
125                report.set_time_budget_exceeded(vec!["differential".into(), "chaos".into()]);
126                report.add_result(AttackResult {
127                    name: "integrity.time_budget".into(),
128                    status: AttackStatus::Error,
129                    error_class: None,
130                    error_code: None,
131                    message: Some("time budget exceeded during integrity phase".into()),
132                    duration_ms: budget.elapsed().as_millis() as u64,
133                });
134                return Ok(report);
135            }
136            Err(attacks::integrity::IntegrityError::Other(e)) => {
137                for r in inner_report.results {
138                    report.add_result(r);
139                }
140                report.add_result(AttackResult {
141                    name: "integrity_attacks".into(),
142                    status: AttackStatus::Error,
143                    error_class: None,
144                    error_code: None,
145                    message: Some(e.to_string()),
146                    duration_ms: start.elapsed().as_millis() as u64,
147                });
148            }
149        }
150    }
151
152    if budget.exceeded() {
153        report.set_time_budget_exceeded(vec!["differential".into(), "chaos".into()]);
154        report.add_result(AttackResult {
155            name: "integrity.time_budget".into(),
156            status: AttackStatus::Error,
157            error_class: None,
158            error_code: None,
159            message: Some("time budget exceeded after integrity phase".into()),
160            duration_ms: budget.elapsed().as_millis() as u64,
161        });
162        return Ok(report);
163    }
164
165    // 2. Differential Testing
166    let iterations = match cfg.tier {
167        SuiteTier::Quick => 5,
168        SuiteTier::Nightly => 100,
169        SuiteTier::Stress => 1000,
170        SuiteTier::Chaos => 50,
171    };
172
173    {
174        let start = Instant::now();
175        let inner = differential::check_invariants(iterations, Some(cfg.seed));
176        let duration = start.elapsed().as_millis() as u64;
177        report.add_check("differential.invariants", inner, duration);
178    }
179
180    if budget.exceeded() {
181        report.set_time_budget_exceeded(vec!["chaos".into()]);
182        report.add_result(AttackResult {
183            name: "differential.time_budget".into(),
184            status: AttackStatus::Error,
185            error_class: None,
186            error_code: None,
187            message: Some("time budget exceeded after differential phase".into()),
188            duration_ms: budget.elapsed().as_millis() as u64,
189        });
190        return Ok(report);
191    }
192
193    // 3. Chaos-tier extras (use subprocess isolation for panic=abort safety)
194    if matches!(cfg.tier, SuiteTier::Chaos) {
195        run_chaos_phase(&mut report, cfg.seed, &budget);
196    }
197
198    Ok(report)
199}
200
201fn run_chaos_phase(report: &mut SimReport, seed: u64, budget: &TimeBudget) {
202    // Fail-fast: skip chaos if already over budget
203    if budget.exceeded() {
204        report.set_time_budget_exceeded(vec!["chaos".into()]);
205        report.add_result(AttackResult {
206            name: "chaos.time_budget".into(),
207            status: AttackStatus::Error,
208            error_class: None,
209            error_code: None,
210            message: Some("time budget exceeded before chaos phase".into()),
211            duration_ms: budget.elapsed().as_millis() as u64,
212        });
213        report.add_result(AttackResult {
214            name: "differential.parity".into(),
215            status: AttackStatus::Error,
216            error_class: None,
217            error_code: None,
218            message: Some("skipped due to time budget".into()),
219            duration_ms: 0,
220        });
221        return;
222    }
223
224    // IO chaos attacks (in-process — these inject IO errors, not panics)
225    match attacks::chaos::check_chaos_attacks(seed) {
226        Ok(results) => {
227            for r in results {
228                report.add_result(r);
229            }
230        }
231        Err(e) => {
232            report.add_result(AttackResult {
233                name: "chaos.io_faults".into(),
234                status: AttackStatus::Error,
235                error_class: None,
236                error_code: None,
237                message: Some(format!("chaos attacks failed: {}", e)),
238                duration_ms: 0,
239            });
240        }
241    }
242
243    if budget.exceeded() {
244        report.set_time_budget_exceeded(vec![]);
245        report.add_result(AttackResult {
246            name: "chaos.time_budget".into(),
247            status: AttackStatus::Error,
248            error_class: None,
249            error_code: None,
250            message: Some("time budget exceeded during chaos phase".into()),
251            duration_ms: budget.elapsed().as_millis() as u64,
252        });
253        // Optie C: make skipped work visible (parity was not run)
254        report.add_result(AttackResult {
255            name: "differential.parity".into(),
256            status: AttackStatus::Error,
257            error_class: None,
258            error_code: None,
259            message: Some("skipped due to time budget".into()),
260            duration_ms: 0,
261        });
262        return;
263    }
264
265    // Differential parity checks (uses subprocess isolation for production verifier)
266    match attacks::differential::check_differential_parity(seed) {
267        Ok(results) => {
268            for r in results {
269                report.add_result(r);
270            }
271        }
272        Err(e) => {
273            report.add_result(AttackResult {
274                name: "differential.parity".into(),
275                status: AttackStatus::Error,
276                error_class: None,
277                error_code: None,
278                message: Some(format!("differential parity failed: {}", e)),
279                duration_ms: 0,
280            });
281        }
282    }
283}
284
285#[cfg(test)]
286mod not_attempted_tests {
287    use super::*;
288
289    #[test]
290    fn quick_decode_ceiling_fits_the_suite_budget() {
291        let limits = tier_default_limits("quick");
292        assert_eq!(limits.max_bundle_bytes, 5 * 1024 * 1024);
293        assert_eq!(
294            limits.max_decode_bytes,
295            32 * 1024 * 1024,
296            "Quick's decode ceiling must stay inside the 60s budget; 1 GiB was measured at ~70s debug verify"
297        );
298    }
299
300    /// The tiers that skip the chaos phase must say so, and the tier that runs it must not.
301    ///
302    /// This is the control for #2170: before it, a Quick run reported `bypassed=0` over a
303    /// programme that never attempted a whole phase, and a reader could not tell that from a
304    /// run that attempted everything.
305    #[test]
306    fn tiers_that_skip_chaos_declare_it() {
307        for tier in [SuiteTier::Quick, SuiteTier::Nightly, SuiteTier::Stress] {
308            assert_eq!(
309                phases_not_attempted_for(&tier),
310                vec!["chaos".to_string()],
311                "{tier:?} does not run the chaos phase and must declare it"
312            );
313        }
314        assert!(
315            phases_not_attempted_for(&SuiteTier::Chaos).is_empty(),
316            "the Chaos tier runs every phase, so it declares no omission"
317        );
318    }
319
320    /// Pins the declaration to the gate that causes it.
321    ///
322    /// `run_suite` gates the chaos phase on `matches!(cfg.tier, SuiteTier::Chaos)`. If that gate
323    /// moves, this reads the source and fails, rather than leaving the declaration describing a
324    /// programme the code no longer runs.
325    ///
326    /// Only the code above `#[cfg(test)]` is searched. This test lives in the file it reads, so a
327    /// whole-file search would be satisfied by this test's own literal — the assertion would hold
328    /// even after the real gate moved, which is precisely the failure it exists to prevent.
329    #[test]
330    fn chaos_phase_gate_matches_the_declared_omission() {
331        let source = include_str!("suite.rs");
332        let production = source
333            .split_once("#[cfg(test)]")
334            .expect("suite.rs keeps its test module")
335            .0;
336        assert!(
337            production.contains("if matches!(cfg.tier, SuiteTier::Chaos) {"),
338            "the chaos gate moved; phases_not_attempted_for must be updated with it"
339        );
340    }
341}