Skip to main content

assay_sim/
lib.rs

1pub mod attacks;
2pub mod corpus;
3pub mod differential;
4pub mod mutators;
5pub mod report;
6pub mod subprocess;
7pub mod suite;
8
9pub use report::{AttackResult, AttackStatus, SimReport};
10pub use suite::{run_suite, tier_default_limits, SuiteConfig, SuiteTier, TimeBudget};
11
12#[cfg(test)]
13mod tests {
14    use super::*;
15    use std::path::PathBuf;
16
17    /// The refusal each Quick-tier name is supposed to reach. A green `bypassed=0` is not
18    /// enough: an earlier check can refuse the fixture and the named rule can then regress
19    /// unnoticed. `integrity.bitflip` at seed 42 lands wherever the flips land, so only its
20    /// class is pinned; `integrity.bitflip_crc` pins the trailer drain. CRLF is tolerated by
21    /// the verifier, so that name is an invariant rather than an attack.
22    const QUICK_REFUSALS: &[QuickRefusal] = &[
23        QuickRefusal::class("integrity.bitflip", "Integrity"),
24        QuickRefusal::code_and_message(
25            "integrity.bitflip_crc",
26            "Integrity",
27            "IntegrityGzip",
28            "Gzip trailer",
29        ),
30        QuickRefusal::code("integrity.truncate", "Integrity", "IntegrityIo"),
31        QuickRefusal::code(
32            "integrity.inject_file",
33            "Contract",
34            "ContractUnexpectedFile",
35        ),
36        QuickRefusal::code("security.zip_bomb", "Limits", "LimitDecodeBytes"),
37        QuickRefusal::code(
38            "integrity.tar_duplicate",
39            "Contract",
40            "ContractDuplicateFile",
41        ),
42        QuickRefusal::code_and_message(
43            "integrity.ndjson_bom",
44            "Contract",
45            "ContractInvalidJson",
46            "BOM not allowed",
47        ),
48        QuickRefusal::passed("integrity.ndjson_crlf"),
49        QuickRefusal::code("integrity.limit_bundle_bytes", "Limits", "LimitBundleBytes"),
50        QuickRefusal::passed("differential.invariants"),
51    ];
52
53    struct QuickRefusal {
54        name: &'static str,
55        status: AttackStatus,
56        class: Option<&'static str>,
57        code: Option<&'static str>,
58        message_contains: Option<&'static str>,
59    }
60
61    impl QuickRefusal {
62        const fn class(name: &'static str, class: &'static str) -> Self {
63            Self {
64                name,
65                status: AttackStatus::Blocked,
66                class: Some(class),
67                code: None,
68                message_contains: None,
69            }
70        }
71
72        const fn code(name: &'static str, class: &'static str, code: &'static str) -> Self {
73            Self {
74                name,
75                status: AttackStatus::Blocked,
76                class: Some(class),
77                code: Some(code),
78                message_contains: None,
79            }
80        }
81
82        const fn code_and_message(
83            name: &'static str,
84            class: &'static str,
85            code: &'static str,
86            message_contains: &'static str,
87        ) -> Self {
88            Self {
89                name,
90                status: AttackStatus::Blocked,
91                class: Some(class),
92                code: Some(code),
93                message_contains: Some(message_contains),
94            }
95        }
96
97        const fn passed(name: &'static str) -> Self {
98            Self {
99                name,
100                status: AttackStatus::Passed,
101                class: None,
102                code: None,
103                message_contains: None,
104            }
105        }
106    }
107
108    #[test]
109    fn test_quick_suite() {
110        let cfg = SuiteConfig {
111            tier: SuiteTier::Quick,
112            target_bundle: PathBuf::from("placeholder"),
113            seed: 42,
114            verify_limits: None,
115            time_budget_secs: 60,
116        };
117
118        let report = run_suite(cfg).expect("Suite failed to run");
119
120        // Print full report on failure for debugging
121        if report.summary.bypassed > 0 {
122            println!("{}", serde_json::to_string_pretty(&report).unwrap());
123        }
124
125        // Invariant assertions (stable across attack additions):
126        // - No attack may bypass verification (security contract)
127        assert_eq!(
128            report.summary.bypassed, 0,
129            "SECURITY: {} attacks bypassed verification",
130            report.summary.bypassed
131        );
132        // - At least 1 attack must be blocked (sanity: attacks actually ran)
133        assert!(
134            report.summary.blocked >= 1,
135            "SANITY: no attacks were blocked — suite may not have run"
136        );
137        // - At least 1 check must pass, or differential ran (sanity: differential tests ran; allow flaky fail on CI)
138        let differential_ran = report
139            .results
140            .iter()
141            .any(|r| r.name == "differential.invariants");
142        assert!(
143            report.summary.passed >= 1 || differential_ran,
144            "SANITY: no checks passed and differential did not run — suite may not have run"
145        );
146        // - Every result must have a valid status classification:
147        //   Blocked/Passed are normal outcomes.
148        //   Error is acceptable for chaos IO faults (WouldBlock, persistent EINTR)
149        //   but NOT for integrity/differential tests.
150        for r in &report.results {
151            let is_chaos_io = r.name.starts_with("chaos.io_fault.");
152            match r.status {
153                AttackStatus::Blocked | AttackStatus::Passed => {} // always ok
154                AttackStatus::Error if is_chaos_io => {}           // infra IO, acceptable
155                _ => panic!(
156                    "Unexpected status {:?} for '{}': {:?}",
157                    r.status, r.name, r.message
158                ),
159            }
160        }
161
162        assert_named_quick_refusals(&report);
163    }
164
165    fn assert_named_quick_refusals(report: &SimReport) {
166        for expected in QUICK_REFUSALS {
167            let r = report
168                .results
169                .iter()
170                .find(|r| r.name == expected.name)
171                .unwrap_or_else(|| panic!("Quick suite is missing '{}'", expected.name));
172            assert_eq!(
173                r.status, expected.status,
174                "{}: status {:?} (message {:?})",
175                expected.name, r.status, r.message
176            );
177            if let Some(class) = expected.class {
178                assert_eq!(
179                    r.error_class.as_deref(),
180                    Some(class),
181                    "{}: refusing class",
182                    expected.name
183                );
184            }
185            if let Some(code) = expected.code {
186                assert_eq!(
187                    r.error_code.as_deref(),
188                    Some(code),
189                    "{}: refusing code (message {:?})",
190                    expected.name,
191                    r.message
192                );
193            }
194            if let Some(part) = expected.message_contains {
195                let message = r.message.as_deref().unwrap_or("");
196                assert!(
197                    message.contains(part),
198                    "{}: message {message:?} should name {part:?}",
199                    expected.name
200                );
201            }
202        }
203
204        for r in &report.results {
205            assert!(
206                QUICK_REFUSALS
207                    .iter()
208                    .any(|expected| expected.name == r.name),
209                "Quick suite result '{}' has no pinned refusal",
210                r.name
211            );
212        }
213    }
214}