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