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; }
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 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
76fn 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 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 {
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 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 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 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 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 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 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 #[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 #[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}