1use super::test_bundle::create_differential_bundle;
2use crate::mutators::bitflip::BitFlip;
3use crate::mutators::inject::InjectFile;
4use crate::mutators::truncate::Truncate;
5use crate::mutators::Mutator;
6use crate::report::{AttackResult, AttackStatus};
7use crate::subprocess::{subprocess_verify, SubprocessResult};
8use anyhow::{Context, Result};
9use assay_evidence::crypto::id::{compute_content_hash, compute_run_root};
10use assay_evidence::types::EvidenceEvent;
11use sha2::{Digest, Sha256};
12use std::io::{Cursor, Read};
13use std::time::{Duration, Instant};
14
15#[derive(Debug)]
17pub struct ReferenceResult {
18 pub valid: bool,
19 pub event_count: usize,
20 pub run_root: String,
21 pub error: Option<String>,
22}
23
24pub fn reference_verify(bundle_data: &[u8]) -> ReferenceResult {
30 match reference_verify_inner(bundle_data) {
31 Ok(r) => r,
32 Err(e) => ReferenceResult {
33 valid: false,
34 event_count: 0,
35 run_root: String::new(),
36 error: Some(e.to_string()),
37 },
38 }
39}
40
41fn reference_verify_inner(bundle_data: &[u8]) -> Result<ReferenceResult> {
42 let decoder = flate2::read::GzDecoder::new(Cursor::new(bundle_data));
44 let mut archive = tar::Archive::new(decoder);
45
46 let mut manifest_bytes: Option<Vec<u8>> = None;
47 let mut events_bytes: Option<Vec<u8>> = None;
48
49 for entry in archive.entries().context("reading tar entries")? {
50 let mut entry = entry.context("reading tar entry")?;
51 let path = entry.path()?.to_string_lossy().to_string();
52
53 let mut content = Vec::new();
54 entry
55 .read_to_end(&mut content)
56 .context("reading entry content")?;
57
58 match path.as_str() {
59 "manifest.json" => manifest_bytes = Some(content),
60 "events.ndjson" => events_bytes = Some(content),
61 _ => {
62 return Ok(ReferenceResult {
63 valid: false,
64 event_count: 0,
65 run_root: String::new(),
66 error: Some(format!("unexpected file: {}", path)),
67 });
68 }
69 }
70 }
71
72 let manifest_bytes = manifest_bytes.context("missing manifest.json")?;
73 let events_bytes = events_bytes.context("missing events.ndjson")?;
74
75 let manifest: serde_json::Value =
77 serde_json::from_slice(&manifest_bytes).context("parsing manifest")?;
78
79 let declared_event_count = manifest
80 .get("event_count")
81 .and_then(|v| v.as_u64())
82 .unwrap_or(0) as usize;
83 let declared_run_root = manifest
84 .get("run_root")
85 .and_then(|v| v.as_str())
86 .unwrap_or("")
87 .to_string();
88
89 let events_hash = format!("sha256:{}", hex::encode(Sha256::digest(&events_bytes)));
91 let declared_events_hash = manifest
92 .get("files")
93 .and_then(|f| f.get("events.ndjson"))
94 .and_then(|f| f.get("sha256"))
95 .and_then(|v| v.as_str())
96 .unwrap_or("");
97
98 if events_hash != declared_events_hash {
99 return Ok(ReferenceResult {
100 valid: false,
101 event_count: 0,
102 run_root: String::new(),
103 error: Some(format!(
104 "events hash mismatch: computed={}, declared={}",
105 events_hash, declared_events_hash
106 )),
107 });
108 }
109
110 let events_str = std::str::from_utf8(&events_bytes).context("events not valid UTF-8")?;
112 let mut events: Vec<EvidenceEvent> = Vec::new();
113 for line in events_str.lines() {
114 if line.is_empty() {
115 continue;
116 }
117 let event: EvidenceEvent = serde_json::from_str(line).context("parsing event")?;
118 events.push(event);
119 }
120
121 let mut content_hashes = Vec::new();
123 for event in &events {
124 let computed = compute_content_hash(event).context("computing content hash")?;
125 let claimed = event.content_hash.as_deref().unwrap_or("").to_string();
126
127 if computed != claimed {
128 return Ok(ReferenceResult {
129 valid: false,
130 event_count: events.len(),
131 run_root: String::new(),
132 error: Some(format!(
133 "content hash mismatch at seq {}: computed={}, claimed={}",
134 event.seq, computed, claimed
135 )),
136 });
137 }
138 content_hashes.push(computed);
139 }
140
141 let computed_run_root = compute_run_root(&content_hashes);
142
143 if events.len() != declared_event_count {
145 return Ok(ReferenceResult {
146 valid: false,
147 event_count: events.len(),
148 run_root: computed_run_root,
149 error: Some(format!(
150 "event count mismatch: actual={}, declared={}",
151 events.len(),
152 declared_event_count
153 )),
154 });
155 }
156
157 if computed_run_root != declared_run_root {
158 let error_msg = format!(
159 "run root mismatch: computed={}, declared={}",
160 computed_run_root, declared_run_root
161 );
162 return Ok(ReferenceResult {
163 valid: false,
164 event_count: events.len(),
165 run_root: computed_run_root,
166 error: Some(error_msg),
167 });
168 }
169
170 Ok(ReferenceResult {
171 valid: true,
172 event_count: events.len(),
173 run_root: computed_run_root,
174 error: None,
175 })
176}
177
178pub fn check_differential_parity(seed: u64) -> Result<Vec<AttackResult>> {
191 let valid_bundle = create_differential_bundle()?;
192 let mut results = Vec::new();
193 let timeout = Duration::from_secs(30);
194
195 let bitflip_count = ((seed % 10) + 1) as usize; let mutations: Vec<(&str, Box<dyn Mutator>)> = vec![
200 (
201 "differential.parity.bitflip",
202 Box::new(BitFlip {
203 count: bitflip_count,
204 seed: Some(seed),
205 }),
206 ),
207 (
208 "differential.parity.truncate",
209 Box::new(Truncate {
210 at: valid_bundle.len() / 2,
211 }),
212 ),
213 (
214 "differential.parity.inject",
215 Box::new(InjectFile {
216 name: "extra.txt".into(),
217 content: b"injected".to_vec(),
218 }),
219 ),
220 ];
221
222 {
224 let start = Instant::now();
225 let production = subprocess_verify(&valid_bundle, timeout);
226 let reference = reference_verify(&valid_bundle);
227 let duration = start.elapsed().as_millis() as u64;
228
229 let result = match production {
230 Ok(ref prod) => {
231 compare_results("differential.parity.identity", prod, &reference, duration)
232 }
233 Err(e) => AttackResult {
234 name: "differential.parity.identity".into(),
235 status: AttackStatus::Error,
236 error_class: None,
237 error_code: None,
238 message: Some(format!("subprocess failed: {}", e)),
239 duration_ms: duration,
240 },
241 };
242 results.push(result);
243 }
244
245 for (name, mutator) in mutations {
247 let start = Instant::now();
248
249 let mutated = match mutator.mutate(&valid_bundle) {
250 Ok(m) => m,
251 Err(e) => {
252 let duration = start.elapsed().as_millis() as u64;
253 results.push(AttackResult {
254 name: name.into(),
255 status: AttackStatus::Error,
256 error_class: None,
257 error_code: None,
258 message: Some(format!("mutation failed: {}", e)),
259 duration_ms: duration,
260 });
261 continue;
262 }
263 };
264
265 let production = subprocess_verify(&mutated, timeout);
266 let reference = reference_verify(&mutated);
267 let duration = start.elapsed().as_millis() as u64;
268
269 let result = match production {
270 Ok(ref prod) => compare_results(name, prod, &reference, duration),
271 Err(e) => AttackResult {
272 name: name.into(),
273 status: AttackStatus::Error,
274 error_class: None,
275 error_code: None,
276 message: Some(format!("subprocess failed: {}", e)),
277 duration_ms: duration,
278 },
279 };
280 results.push(result);
281 }
282
283 Ok(results)
284}
285
286fn compare_results(
293 name: &str,
294 production: &SubprocessResult,
295 reference: &ReferenceResult,
296 duration_ms: u64,
297) -> AttackResult {
298 let production_ok = production.valid;
299
300 if production_ok && !reference.valid {
301 AttackResult {
303 name: name.into(),
304 status: AttackStatus::Failed,
305 error_class: Some("parity_violation".into()),
306 error_code: Some("SOTA_BYPASS".into()),
307 message: Some(format!(
308 "SOTA parity violation: production accepted, reference rejected ({})",
309 reference.error.as_deref().unwrap_or("unknown")
310 )),
311 duration_ms,
312 }
313 } else if production_ok && reference.valid {
314 AttackResult {
319 name: name.into(),
320 status: AttackStatus::Passed,
321 error_class: None,
322 error_code: None,
323 message: Some(format!(
324 "both accepted (ref: events={}, run_root={})",
325 reference.event_count,
326 truncate_hash(&reference.run_root, 16)
327 )),
328 duration_ms,
329 }
330 } else if !production_ok && reference.valid {
331 AttackResult {
333 name: name.into(),
334 status: AttackStatus::Passed,
335 error_class: None,
336 error_code: None,
337 message: Some("strictness divergence: production rejected, reference accepted".into()),
338 duration_ms,
339 }
340 } else {
341 let ref_error = reference.error.as_deref().unwrap_or("unknown");
343 let prod_stderr = production.stderr.lines().next().unwrap_or("unknown");
344 AttackResult {
345 name: name.into(),
346 status: AttackStatus::Passed,
347 error_class: None,
348 error_code: None,
349 message: Some(format!(
350 "both rejected (ref: {}, prod: {})",
351 truncate_hash(ref_error, 80),
352 truncate_hash(prod_stderr, 80)
353 )),
354 duration_ms,
355 }
356 }
357}
358
359fn truncate_hash(s: &str, max: usize) -> String {
360 if s.len() <= max {
361 s.to_string()
362 } else {
363 format!("{}…", &s[..max])
364 }
365}