1use std::fs::{self, File};
4use std::io::{Read, Write};
5use std::path::{Component, Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use anyhow::{bail, Context, Result};
9use serde::{Deserialize, Serialize};
10use sha2::{Digest, Sha256};
11
12use crate::evidence::build_evidence;
13
14pub const SCHEMA: &str = "candle-graph/bundle/1";
15pub const VERIFICATION_SCHEMA: &str = "candle-graph/bundle-verification/1";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct BundleFile {
19 pub path: String,
20 pub bytes: u64,
21 pub sha256: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct BundleManifest {
26 pub schema: String,
27 pub run_id: String,
28 pub files: Vec<BundleFile>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct BundleVerificationReceipt {
34 pub schema: String,
35 pub bundle_schema: String,
36 pub run_id: String,
37 pub manifest_path: String,
38 pub manifest_sha256: String,
39 pub files_verified: usize,
40 pub bytes_verified: u64,
41}
42
43pub fn verify_bundle(root: &Path) -> Result<BundleVerificationReceipt> {
46 if !root.is_dir() {
47 bail!("bundle directory does not exist: {}", root.display());
48 }
49 let manifest_path = root.join("bundle.json");
50 let manifest_metadata = fs::symlink_metadata(&manifest_path)
51 .with_context(|| format!("read bundle manifest {}", manifest_path.display()))?;
52 if !manifest_metadata.file_type().is_file() {
53 bail!(
54 "bundle manifest is not a regular file: {}",
55 manifest_path.display()
56 );
57 }
58 let manifest_bytes = fs::read(&manifest_path)
59 .with_context(|| format!("read bundle manifest {}", manifest_path.display()))?;
60 let manifest: BundleManifest = serde_json::from_slice(&manifest_bytes)
61 .with_context(|| format!("parse bundle manifest {}", manifest_path.display()))?;
62 if manifest.schema != SCHEMA {
63 bail!(
64 "unsupported bundle schema {:?}; expected {SCHEMA:?}",
65 manifest.schema
66 );
67 }
68
69 let mut declared = std::collections::BTreeMap::new();
70 for file in &manifest.files {
71 if !is_safe_relative_path(&file.path) || file.path == "bundle.json" {
72 bail!("unsafe or reserved bundle path {:?}", file.path);
73 }
74 if declared.insert(file.path.as_str(), file).is_some() {
75 bail!("bundle manifest declares {:?} more than once", file.path);
76 }
77 if file.sha256.len() != 64 || !file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) {
78 bail!("bundle manifest has an invalid SHA-256 for {:?}", file.path);
79 }
80 }
81
82 let mut observed = Vec::new();
83 collect_bundle_files(root, root, &mut observed)?;
84 observed.sort();
85 for relative in &observed {
86 if relative == Path::new("bundle.json") {
87 continue;
88 }
89 let normalized = relative.to_string_lossy().replace('\\', "/");
90 if !declared.contains_key(normalized.as_str()) {
91 bail!("undeclared regular file in bundle: {normalized}");
92 }
93 }
94 if observed
95 .iter()
96 .filter(|path| path.as_path() != Path::new("bundle.json"))
97 .count()
98 != declared.len()
99 {
100 let missing = declared
101 .keys()
102 .find(|path| !observed.iter().any(|item| item == Path::new(path)))
103 .copied()
104 .unwrap_or("<unknown>");
105 bail!("declared bundle file is missing: {missing}");
106 }
107
108 let mut bytes_verified = 0u64;
109 for (relative, expected) in &declared {
110 let path = root.join(relative);
111 let metadata = fs::symlink_metadata(&path)
112 .with_context(|| format!("read declared bundle file {}", path.display()))?;
113 if !metadata.file_type().is_file() {
114 bail!("declared bundle path is not a regular file: {relative}");
115 }
116 let actual = describe_file(root, &path)?;
117 if actual.bytes != expected.bytes {
118 bail!(
119 "bundle file {relative:?} size mismatch: expected {}, observed {}",
120 expected.bytes,
121 actual.bytes
122 );
123 }
124 if !actual.sha256.eq_ignore_ascii_case(&expected.sha256) {
125 bail!("bundle file {relative:?} SHA-256 mismatch");
126 }
127 bytes_verified = bytes_verified
128 .checked_add(actual.bytes)
129 .context("verified bundle byte count overflowed u64")?;
130 }
131
132 Ok(BundleVerificationReceipt {
133 schema: VERIFICATION_SCHEMA.into(),
134 bundle_schema: manifest.schema,
135 run_id: manifest.run_id,
136 manifest_path: "bundle.json".into(),
137 manifest_sha256: sha256_bytes(&manifest_bytes),
138 files_verified: declared.len(),
139 bytes_verified,
140 })
141}
142
143pub(crate) fn verify_consumed_bundle_files(
147 root: &Path,
148 initial: &BundleVerificationReceipt,
149 consumed: &[&str],
150) -> Result<()> {
151 if initial.schema != VERIFICATION_SCHEMA || initial.bundle_schema != SCHEMA {
152 bail!(
153 "unsupported initial bundle verification receipt {:?}/{:?}",
154 initial.schema,
155 initial.bundle_schema
156 );
157 }
158 let manifest_path = root.join("bundle.json");
159 let manifest_metadata = fs::symlink_metadata(&manifest_path)
160 .with_context(|| format!("re-read bundle manifest {}", manifest_path.display()))?;
161 if !manifest_metadata.file_type().is_file() {
162 bail!(
163 "bundle manifest changed to a non-regular file after initial verification: {}",
164 manifest_path.display()
165 );
166 }
167 let manifest_bytes = fs::read(&manifest_path)
168 .with_context(|| format!("re-read bundle manifest {}", manifest_path.display()))?;
169 if sha256_bytes(&manifest_bytes) != initial.manifest_sha256 {
170 bail!("bundle manifest changed after initial verification");
171 }
172 let manifest: BundleManifest = serde_json::from_slice(&manifest_bytes)
173 .with_context(|| format!("re-parse bundle manifest {}", manifest_path.display()))?;
174 if manifest.schema != initial.bundle_schema || manifest.run_id != initial.run_id {
175 bail!("bundle manifest identity changed after initial verification");
176 }
177
178 let declared = manifest
179 .files
180 .iter()
181 .map(|file| (file.path.as_str(), file))
182 .collect::<std::collections::BTreeMap<_, _>>();
183 for &relative in consumed {
184 if !is_safe_relative_path(relative) || relative == "bundle.json" {
185 bail!("unsafe or reserved consumed bundle path {relative:?}");
186 }
187 let expected = declared
188 .get(relative)
189 .with_context(|| format!("consumed bundle file is not declared: {relative}"))?;
190 let path = root.join(relative);
191 let metadata = fs::symlink_metadata(&path)
192 .with_context(|| format!("re-read consumed bundle file {}", path.display()))?;
193 if !metadata.file_type().is_file() {
194 bail!("consumed bundle path is no longer a regular file: {relative}");
195 }
196 let actual = describe_file(root, &path)?;
197 if actual.bytes != expected.bytes || !actual.sha256.eq_ignore_ascii_case(&expected.sha256) {
198 bail!("consumed bundle file {relative:?} changed after initial verification");
199 }
200 }
201 Ok(())
202}
203
204pub fn publish_bundle(
207 destination: &Path,
208 trace: &Path,
209 nsight_dir: Option<&Path>,
210) -> Result<BundleManifest> {
211 if destination.exists() {
212 bail!(
213 "bundle destination already exists: {}",
214 destination.display()
215 );
216 }
217 let parent = destination.parent().unwrap_or_else(|| Path::new("."));
218 fs::create_dir_all(parent)
219 .with_context(|| format!("create bundle parent {}", parent.display()))?;
220 let name = destination
221 .file_name()
222 .and_then(|name| name.to_str())
223 .context("bundle destination needs a file name")?;
224 let nonce = SystemTime::now()
225 .duration_since(UNIX_EPOCH)
226 .unwrap_or_default()
227 .as_nanos();
228 let temporary = parent.join(format!(".{name}.tmp-{}-{nonce}", std::process::id()));
229 fs::create_dir(&temporary)
230 .with_context(|| format!("create temporary bundle {}", temporary.display()))?;
231
232 let result = write_bundle(&temporary, trace, nsight_dir).and_then(|manifest| {
233 verify_bundle(&temporary).context("verify staged evidence bundle")?;
234 sync_directory(&temporary)?;
235 fs::rename(&temporary, destination)
236 .with_context(|| format!("atomically publish bundle {}", destination.display()))?;
237 sync_directory(parent)?;
238 Ok(manifest)
239 });
240 if result.is_err() && temporary.exists() {
241 let _ = fs::remove_dir_all(&temporary);
242 }
243 result
244}
245
246fn write_bundle(root: &Path, trace: &Path, nsight_dir: Option<&Path>) -> Result<BundleManifest> {
247 let mut files = Vec::new();
248 copy_and_record(trace, root, Path::new("trace.jsonl"), &mut files)?;
249
250 if let Some(nsight) = nsight_dir {
251 fs::create_dir(root.join("nsight"))?;
252 let mut sources = fs::read_dir(nsight)
253 .with_context(|| format!("read Nsight directory {}", nsight.display()))?
254 .collect::<std::io::Result<Vec<_>>>()?;
255 sources.sort_by_key(|entry| entry.file_name());
256 for entry in sources {
257 let source = entry.path();
258 let file_type = entry
259 .file_type()
260 .with_context(|| format!("inspect Nsight artifact {}", source.display()))?;
261 if file_type.is_symlink() {
262 bail!(
263 "symbolic links are not allowed in Nsight inputs: {}",
264 source.display()
265 );
266 }
267 if file_type.is_dir() {
268 bail!(
269 "directories are not allowed in Nsight inputs: {}",
270 source.display()
271 );
272 }
273 if !file_type.is_file() {
274 bail!(
275 "special files are not allowed in Nsight inputs: {}",
276 source.display()
277 );
278 }
279 let file_name = source
280 .file_name()
281 .context("Nsight artifact missing file name")?;
282 copy_and_record(
283 &source,
284 root,
285 &Path::new("nsight").join(file_name),
286 &mut files,
287 )?;
288 }
289 }
290 let staged_trace = root.join("trace.jsonl");
291 let staged_nsight = nsight_dir.map(|_| root.join("nsight"));
292 let evidence = build_evidence(&staged_trace, staged_nsight.as_deref())?;
293 write_and_record(
294 root,
295 Path::new("evidence.json"),
296 &(serde_json::to_string_pretty(&evidence)? + "\n"),
297 &mut files,
298 )?;
299 write_and_record(
300 root,
301 Path::new("report.md"),
302 &evidence.markdown(),
303 &mut files,
304 )?;
305 #[cfg(feature = "visualizer")]
306 write_and_record(
307 root,
308 Path::new("viewer.html"),
309 &crate::viewer::render_evidence_html(&evidence),
310 &mut files,
311 )?;
312 files.sort_by(|left, right| left.path.cmp(&right.path));
313 let manifest = BundleManifest {
314 schema: SCHEMA.into(),
315 run_id: evidence.provenance.run_id.clone(),
316 files,
317 };
318 let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
319 let path = root.join("bundle.json");
320 let mut file = File::create(&path)?;
321 file.write_all(manifest_json.as_bytes())?;
322 file.sync_all()?;
323 Ok(manifest)
324}
325
326fn copy_and_record(
327 source: &Path,
328 root: &Path,
329 relative: &Path,
330 files: &mut Vec<BundleFile>,
331) -> Result<()> {
332 let target = root.join(relative);
333 if let Some(parent) = target.parent() {
334 fs::create_dir_all(parent)?;
335 }
336 fs::copy(source, &target).with_context(|| format!("copy {} into bundle", source.display()))?;
337 File::open(&target)?.sync_all()?;
338 files.push(describe_file(root, &target)?);
339 Ok(())
340}
341
342fn write_and_record(
343 root: &Path,
344 relative: &Path,
345 contents: &str,
346 files: &mut Vec<BundleFile>,
347) -> Result<()> {
348 let path = root.join(relative);
349 let mut file = File::create(&path)?;
350 file.write_all(contents.as_bytes())?;
351 file.sync_all()?;
352 files.push(describe_file(root, &path)?);
353 Ok(())
354}
355
356fn describe_file(root: &Path, path: &Path) -> Result<BundleFile> {
357 let mut file = File::open(path)?;
358 let mut hasher = Sha256::new();
359 let mut buffer = [0u8; 64 * 1024];
360 let mut bytes = 0u64;
361 loop {
362 let read = file.read(&mut buffer)?;
363 if read == 0 {
364 break;
365 }
366 hasher.update(&buffer[..read]);
367 bytes += read as u64;
368 }
369 Ok(BundleFile {
370 path: path
371 .strip_prefix(root)?
372 .to_string_lossy()
373 .replace('\\', "/"),
374 bytes,
375 sha256: format!("{:x}", hasher.finalize()),
376 })
377}
378
379fn collect_bundle_files(root: &Path, directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
380 let mut entries = fs::read_dir(directory)
381 .with_context(|| format!("read bundle directory {}", directory.display()))?
382 .collect::<std::io::Result<Vec<_>>>()?;
383 entries.sort_by_key(|entry| entry.file_name());
384 for entry in entries {
385 let path = entry.path();
386 let metadata = fs::symlink_metadata(&path)?;
387 let file_type = metadata.file_type();
388 if file_type.is_symlink() {
389 bail!(
390 "symbolic links are not allowed in bundles: {}",
391 path.display()
392 );
393 }
394 if file_type.is_dir() {
395 collect_bundle_files(root, &path, files)?;
396 } else if file_type.is_file() {
397 files.push(path.strip_prefix(root)?.to_path_buf());
398 } else {
399 bail!(
400 "special files are not allowed in bundles: {}",
401 path.display()
402 );
403 }
404 }
405 Ok(())
406}
407
408fn is_safe_relative_path(value: &str) -> bool {
409 let path = Path::new(value);
410 !path.as_os_str().is_empty()
411 && !value.contains('\\')
412 && !path.is_absolute()
413 && path
414 .components()
415 .all(|component| matches!(component, Component::Normal(_)))
416}
417
418fn sha256_bytes(bytes: &[u8]) -> String {
419 let mut hasher = Sha256::new();
420 hasher.update(bytes);
421 format!("{:x}", hasher.finalize())
422}
423
424fn sync_directory(path: &Path) -> Result<()> {
425 File::open(path)?
426 .sync_all()
427 .with_context(|| format!("sync directory {}", path.display()))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::capability::CaptureContract;
434 use crate::trace::{
435 write_jsonl, RunOutcome, SpanKind, SpanRecord, TerminalEvent, TimingMode, TraceDocument,
436 TraceRunMeta, SCHEMA as TRACE_SCHEMA,
437 };
438
439 #[test]
440 fn publishes_complete_content_addressed_directory() {
441 let nonce = SystemTime::now()
442 .duration_since(UNIX_EPOCH)
443 .unwrap()
444 .as_nanos();
445 let root = std::env::temp_dir().join(format!(
446 "candle-graph-bundle-test-{}-{nonce}",
447 std::process::id()
448 ));
449 fs::create_dir(&root).unwrap();
450 let trace = root.join("input.jsonl");
451 let nsight = root.join("nsight-input");
452 let destination = root.join("bundle");
453 fs::create_dir(&nsight).unwrap();
454 fs::write(nsight.join("capture.nsys-rep"), b"retained raw capture").unwrap();
455 let document = TraceDocument {
456 schema: TRACE_SCHEMA.into(),
457 run: TraceRunMeta {
458 run_id: "bundle-run".into(),
459 correlation_id: "bundle/run".into(),
460 entrypoint: "demo".into(),
461 phase: crate::ExecutionPhase::Infer,
462 timestamp: "2026-08-19T00:00:00Z".into(),
463 capture_step: 1,
464 warmup_steps: 0,
465 device: "cpu".into(),
466 measured_region_device_synchronized: false,
467 timing_mode: TimingMode::Host,
468 capture_contract: CaptureContract::default(),
469 comparison_identity: None,
470 tags: Default::default(),
471 candle_version: None,
472 },
473 spans: vec![SpanRecord {
474 id: "root".into(),
475 parent_id: None,
476 name: "demo".into(),
477 kind: SpanKind::Function,
478 measured: true,
479 start_ns: 0,
480 closed: true,
481 duration_ns: 10,
482 step: None,
483 }],
484 ops: vec![],
485 tensors: vec![],
486 tensor_stats: vec![],
487 memory: vec![],
488 device_memory: vec![],
489 device_intervals: vec![],
490 gradients: vec![],
491 edges: vec![],
492 terminal: TerminalEvent {
493 outcome: RunOutcome::Complete,
494 timestamp_ns: 10,
495 reason: None,
496 },
497 };
498 write_jsonl(&trace, &document.to_events()).unwrap();
499 let manifest = publish_bundle(&destination, &trace, Some(&nsight)).unwrap();
500 assert!(destination.join("bundle.json").is_file());
501 assert!(destination.join("evidence.json").is_file());
502 assert!(destination.join("report.md").is_file());
503 assert_eq!(
504 fs::read(destination.join("nsight/capture.nsys-rep")).unwrap(),
505 b"retained raw capture"
506 );
507 assert!(manifest
508 .files
509 .iter()
510 .any(|file| file.path == "nsight/capture.nsys-rep"));
511 assert!(manifest.files.iter().all(|file| file.sha256.len() == 64));
512 let receipt = verify_bundle(&destination).unwrap();
513 assert_eq!(receipt.run_id, "bundle-run");
514 assert_eq!(receipt.manifest_sha256.len(), 64);
515 assert_eq!(receipt.files_verified, manifest.files.len());
516 let bundled_evidence: crate::evidence::EvidencePacket =
517 serde_json::from_slice(&fs::read(destination.join("evidence.json")).unwrap()).unwrap();
518 assert_eq!(bundled_evidence.provenance.run_id, manifest.run_id);
519 assert!(publish_bundle(&destination, &trace, Some(&nsight)).is_err());
520
521 fs::write(
522 destination.join("nsight/capture.nsys-rep"),
523 b"tampered raw capture",
524 )
525 .unwrap();
526 assert!(verify_bundle(&destination)
527 .unwrap_err()
528 .to_string()
529 .contains("mismatch"));
530 fs::remove_dir_all(root).unwrap();
531 }
532
533 #[test]
534 fn publication_rejects_non_regular_nsight_inputs() {
535 let nonce = SystemTime::now()
536 .duration_since(UNIX_EPOCH)
537 .unwrap()
538 .as_nanos();
539 let root = std::env::temp_dir().join(format!(
540 "candle-graph-nsight-input-test-{}-{nonce}",
541 std::process::id()
542 ));
543 fs::create_dir(&root).unwrap();
544 let trace = root.join("input.jsonl");
545 let document = TraceDocument {
546 schema: TRACE_SCHEMA.into(),
547 run: TraceRunMeta {
548 run_id: "nsight-input-run".into(),
549 correlation_id: "nsight/input/run".into(),
550 entrypoint: "demo".into(),
551 phase: crate::ExecutionPhase::Infer,
552 timestamp: "2026-08-19T00:00:00Z".into(),
553 capture_step: 1,
554 warmup_steps: 0,
555 device: "cpu".into(),
556 measured_region_device_synchronized: false,
557 timing_mode: TimingMode::Host,
558 capture_contract: CaptureContract::default(),
559 comparison_identity: None,
560 tags: Default::default(),
561 candle_version: None,
562 },
563 spans: vec![SpanRecord {
564 id: "root".into(),
565 parent_id: None,
566 name: "demo".into(),
567 kind: SpanKind::Function,
568 measured: true,
569 start_ns: 0,
570 closed: true,
571 duration_ns: 10,
572 step: None,
573 }],
574 ops: vec![],
575 tensors: vec![],
576 tensor_stats: vec![],
577 memory: vec![],
578 device_memory: vec![],
579 device_intervals: vec![],
580 gradients: vec![],
581 edges: vec![],
582 terminal: TerminalEvent {
583 outcome: RunOutcome::Complete,
584 timestamp_ns: 10,
585 reason: None,
586 },
587 };
588 write_jsonl(&trace, &document.to_events()).unwrap();
589
590 let directory_input = root.join("directory-input");
591 fs::create_dir_all(directory_input.join("nested")).unwrap();
592 let error = publish_bundle(
593 &root.join("directory-bundle"),
594 &trace,
595 Some(&directory_input),
596 )
597 .unwrap_err();
598 assert!(error.to_string().contains("directories are not allowed"));
599
600 #[cfg(unix)]
601 {
602 use std::os::unix::fs::symlink;
603 use std::os::unix::net::UnixListener;
604
605 let symlink_input = root.join("symlink-input");
606 fs::create_dir(&symlink_input).unwrap();
607 fs::write(root.join("regular-input"), b"input").unwrap();
608 symlink(root.join("regular-input"), symlink_input.join("linked")).unwrap();
609 let error = publish_bundle(&root.join("symlink-bundle"), &trace, Some(&symlink_input))
610 .unwrap_err();
611 assert!(error.to_string().contains("symbolic links are not allowed"));
612
613 let special_input = root.join("special-input");
614 fs::create_dir(&special_input).unwrap();
615 match UnixListener::bind(special_input.join("socket")) {
616 Ok(_socket) => {
617 let error =
618 publish_bundle(&root.join("special-bundle"), &trace, Some(&special_input))
619 .unwrap_err();
620 assert!(error.to_string().contains("special files are not allowed"));
621 }
622 Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
623 }
626 Err(error) => panic!("create special-file fixture: {error}"),
627 }
628 }
629
630 fs::remove_dir_all(root).unwrap();
631 }
632
633 #[test]
634 fn verification_rejects_tampered_deleted_and_injected_files() {
635 let nonce = SystemTime::now()
636 .duration_since(UNIX_EPOCH)
637 .unwrap()
638 .as_nanos();
639 let root = std::env::temp_dir().join(format!(
640 "candle-graph-bundle-tamper-test-{}-{nonce}",
641 std::process::id()
642 ));
643 fs::create_dir(&root).unwrap();
644 let trace = root.join("input.jsonl");
645 let destination = root.join("bundle");
646 let document = TraceDocument {
647 schema: TRACE_SCHEMA.into(),
648 run: TraceRunMeta {
649 run_id: "tamper-run".into(),
650 correlation_id: "tamper/run".into(),
651 entrypoint: "demo".into(),
652 phase: crate::ExecutionPhase::Infer,
653 timestamp: "2026-08-19T00:00:00Z".into(),
654 capture_step: 1,
655 warmup_steps: 0,
656 device: "cpu".into(),
657 measured_region_device_synchronized: false,
658 timing_mode: TimingMode::Host,
659 capture_contract: CaptureContract::default(),
660 comparison_identity: None,
661 tags: Default::default(),
662 candle_version: None,
663 },
664 spans: vec![SpanRecord {
665 id: "root".into(),
666 parent_id: None,
667 name: "demo".into(),
668 kind: SpanKind::Function,
669 measured: true,
670 start_ns: 0,
671 closed: true,
672 duration_ns: 10,
673 step: None,
674 }],
675 ops: vec![],
676 tensors: vec![],
677 tensor_stats: vec![],
678 memory: vec![],
679 device_memory: vec![],
680 device_intervals: vec![],
681 gradients: vec![],
682 edges: vec![],
683 terminal: TerminalEvent {
684 outcome: RunOutcome::Complete,
685 timestamp_ns: 10,
686 reason: None,
687 },
688 };
689 write_jsonl(&trace, &document.to_events()).unwrap();
690 publish_bundle(&destination, &trace, None).unwrap();
691
692 let initial_receipt = verify_bundle(&destination).unwrap();
693 verify_consumed_bundle_files(
694 &destination,
695 &initial_receipt,
696 &["trace.jsonl", "evidence.json"],
697 )
698 .unwrap();
699
700 let report_path = destination.join("report.md");
701 let original_report = fs::read(&report_path).unwrap();
702 fs::write(&report_path, b"changed after the initial deep verification").unwrap();
703 verify_consumed_bundle_files(
704 &destination,
705 &initial_receipt,
706 &["trace.jsonl", "evidence.json"],
707 )
708 .unwrap();
709 fs::write(&report_path, &original_report).unwrap();
710
711 let evidence_path = destination.join("evidence.json");
712 let original_evidence = fs::read(&evidence_path).unwrap();
713 fs::write(&evidence_path, b"tampered").unwrap();
714 assert!(verify_consumed_bundle_files(
715 &destination,
716 &initial_receipt,
717 &["trace.jsonl", "evidence.json"],
718 )
719 .unwrap_err()
720 .to_string()
721 .contains("changed after initial verification"));
722 assert!(verify_bundle(&destination)
723 .unwrap_err()
724 .to_string()
725 .contains("mismatch"));
726 fs::write(&evidence_path, original_evidence).unwrap();
727
728 fs::remove_file(&report_path).unwrap();
729 assert!(verify_bundle(&destination)
730 .unwrap_err()
731 .to_string()
732 .contains("missing"));
733 fs::write(&report_path, original_report).unwrap();
734
735 let bundled_trace = destination.join("trace.jsonl");
736 let original_trace = fs::read(&bundled_trace).unwrap();
737 fs::write(&bundled_trace, b"changed trace").unwrap();
738 assert!(verify_consumed_bundle_files(
739 &destination,
740 &initial_receipt,
741 &["trace.jsonl", "evidence.json"],
742 )
743 .unwrap_err()
744 .to_string()
745 .contains("changed after initial verification"));
746 fs::write(&bundled_trace, original_trace).unwrap();
747
748 let manifest_path = destination.join("bundle.json");
749 let original_manifest = fs::read(&manifest_path).unwrap();
750 fs::write(&manifest_path, b"changed manifest").unwrap();
751 assert!(verify_consumed_bundle_files(
752 &destination,
753 &initial_receipt,
754 &["trace.jsonl", "evidence.json"],
755 )
756 .unwrap_err()
757 .to_string()
758 .contains("manifest changed"));
759 fs::write(&manifest_path, original_manifest).unwrap();
760
761 fs::write(destination.join("injected.txt"), b"not declared").unwrap();
762 assert!(verify_bundle(&destination)
763 .unwrap_err()
764 .to_string()
765 .contains("undeclared"));
766 fs::remove_dir_all(root).unwrap();
767 }
768}