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
76pub fn run_suite(cfg: SuiteConfig) -> Result<SimReport> {
77    let mut report = SimReport::new(&format!("{:?}", cfg.tier), cfg.seed);
78    let budget = TimeBudget::new(Duration::from_secs(cfg.time_budget_secs));
79    let limits = cfg
80        .verify_limits
81        .unwrap_or_else(|| tier_default_limits(&format!("{:?}", cfg.tier).to_lowercase()));
82
83    // 1. Integrity Attacks (all tiers)
84    //
85    // Note: The workspace uses panic="abort" in dev/release profiles, so catch_unwind
86    // is not effective. Integrity attacks run in-process (they don't trigger panics —
87    // they test verification outcomes). Chaos/differential attacks use subprocess
88    // isolation instead.
89    {
90        let seed = cfg.seed;
91        let start = Instant::now();
92        let mut inner_report = SimReport::new("integrity", seed);
93        match attacks::integrity::check_integrity_attacks(&mut inner_report, seed, limits, &budget)
94        {
95            Ok(()) => {
96                for r in inner_report.results {
97                    report.add_result(r);
98                }
99            }
100            Err(attacks::integrity::IntegrityError::BudgetExceeded) => {
101                for r in inner_report.results {
102                    report.add_result(r);
103                }
104                report.set_time_budget_exceeded(vec!["differential".into(), "chaos".into()]);
105                report.add_result(AttackResult {
106                    name: "integrity.time_budget".into(),
107                    status: AttackStatus::Error,
108                    error_class: None,
109                    error_code: None,
110                    message: Some("time budget exceeded during integrity phase".into()),
111                    duration_ms: budget.elapsed().as_millis() as u64,
112                });
113                return Ok(report);
114            }
115            Err(attacks::integrity::IntegrityError::Other(e)) => {
116                for r in inner_report.results {
117                    report.add_result(r);
118                }
119                report.add_result(AttackResult {
120                    name: "integrity_attacks".into(),
121                    status: AttackStatus::Error,
122                    error_class: None,
123                    error_code: None,
124                    message: Some(e.to_string()),
125                    duration_ms: start.elapsed().as_millis() as u64,
126                });
127            }
128        }
129    }
130
131    if budget.exceeded() {
132        report.set_time_budget_exceeded(vec!["differential".into(), "chaos".into()]);
133        report.add_result(AttackResult {
134            name: "integrity.time_budget".into(),
135            status: AttackStatus::Error,
136            error_class: None,
137            error_code: None,
138            message: Some("time budget exceeded after integrity phase".into()),
139            duration_ms: budget.elapsed().as_millis() as u64,
140        });
141        return Ok(report);
142    }
143
144    // 2. Differential Testing
145    let iterations = match cfg.tier {
146        SuiteTier::Quick => 5,
147        SuiteTier::Nightly => 100,
148        SuiteTier::Stress => 1000,
149        SuiteTier::Chaos => 50,
150    };
151
152    {
153        let start = Instant::now();
154        let inner = differential::check_invariants(iterations, Some(cfg.seed));
155        let duration = start.elapsed().as_millis() as u64;
156        report.add_check("differential.invariants", inner, duration);
157    }
158
159    if budget.exceeded() {
160        report.set_time_budget_exceeded(vec!["chaos".into()]);
161        report.add_result(AttackResult {
162            name: "differential.time_budget".into(),
163            status: AttackStatus::Error,
164            error_class: None,
165            error_code: None,
166            message: Some("time budget exceeded after differential phase".into()),
167            duration_ms: budget.elapsed().as_millis() as u64,
168        });
169        return Ok(report);
170    }
171
172    // 3. Chaos-tier extras (use subprocess isolation for panic=abort safety)
173    if matches!(cfg.tier, SuiteTier::Chaos) {
174        run_chaos_phase(&mut report, cfg.seed, &budget);
175    }
176
177    Ok(report)
178}
179
180fn run_chaos_phase(report: &mut SimReport, seed: u64, budget: &TimeBudget) {
181    // Fail-fast: skip chaos if already over budget
182    if budget.exceeded() {
183        report.set_time_budget_exceeded(vec!["chaos".into()]);
184        report.add_result(AttackResult {
185            name: "chaos.time_budget".into(),
186            status: AttackStatus::Error,
187            error_class: None,
188            error_code: None,
189            message: Some("time budget exceeded before chaos phase".into()),
190            duration_ms: budget.elapsed().as_millis() as u64,
191        });
192        report.add_result(AttackResult {
193            name: "differential.parity".into(),
194            status: AttackStatus::Error,
195            error_class: None,
196            error_code: None,
197            message: Some("skipped due to time budget".into()),
198            duration_ms: 0,
199        });
200        return;
201    }
202
203    // IO chaos attacks (in-process — these inject IO errors, not panics)
204    match attacks::chaos::check_chaos_attacks(seed) {
205        Ok(results) => {
206            for r in results {
207                report.add_result(r);
208            }
209        }
210        Err(e) => {
211            report.add_result(AttackResult {
212                name: "chaos.io_faults".into(),
213                status: AttackStatus::Error,
214                error_class: None,
215                error_code: None,
216                message: Some(format!("chaos attacks failed: {}", e)),
217                duration_ms: 0,
218            });
219        }
220    }
221
222    if budget.exceeded() {
223        report.set_time_budget_exceeded(vec![]);
224        report.add_result(AttackResult {
225            name: "chaos.time_budget".into(),
226            status: AttackStatus::Error,
227            error_class: None,
228            error_code: None,
229            message: Some("time budget exceeded during chaos phase".into()),
230            duration_ms: budget.elapsed().as_millis() as u64,
231        });
232        // Optie C: make skipped work visible (parity was not run)
233        report.add_result(AttackResult {
234            name: "differential.parity".into(),
235            status: AttackStatus::Error,
236            error_class: None,
237            error_code: None,
238            message: Some("skipped due to time budget".into()),
239            duration_ms: 0,
240        });
241        return;
242    }
243
244    // Differential parity checks (uses subprocess isolation for production verifier)
245    match attacks::differential::check_differential_parity(seed) {
246        Ok(results) => {
247            for r in results {
248                report.add_result(r);
249            }
250        }
251        Err(e) => {
252            report.add_result(AttackResult {
253                name: "differential.parity".into(),
254                status: AttackStatus::Error,
255                error_class: None,
256                error_code: None,
257                message: Some(format!("differential parity failed: {}", e)),
258                duration_ms: 0,
259            });
260        }
261    }
262}