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 pub time_budget_secs: u64,
25}
26
27#[derive(Debug, Clone)]
32pub struct TimeBudget {
33 start: Instant,
34 limit: Duration,
35}
36
37pub 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; 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 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
80fn 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 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 {
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 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 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 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 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 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 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 #[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 #[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}