Skip to main content

candle_graph/
artifact.rs

1//! Atomic publication of self-contained, content-addressed evidence bundles.
2
3use 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/// Deterministic proof that every file in one evidence bundle matched its manifest when checked.
32#[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
43/// Deeply verify a published bundle. Declared files must be regular files with matching sizes and
44/// digests, and every regular file below the root must be declared except the manifest itself.
45pub 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
143/// Recheck the immutable identity of a previously verified manifest and only the files consumed
144/// after that verification. Callers must first run [`verify_bundle`]; this narrower second pass is
145/// a post-read race detector, not a substitute for the initial recursive verification.
146pub(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
204/// Publish all evidence into a new directory. The final path appears only after every artifact,
205/// hash, and manifest has been written successfully.
206pub 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                    // Sandboxes may forbid AF_UNIX creation; the symlink case
624                    // above still exercises non-regular input rejection.
625                }
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}