1use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8pub const CANONICAL_PACKET_FILES: &[&str] = &[
16 "manifest.json",
17 "packet.lock.json",
18 "proof-trace.json",
19 "ro-crate-metadata.jsonld",
20 "findings/full.json",
21 "artifacts/artifacts.json",
22 "artifacts/artifact-audit.json",
23 "artifacts/blob-map.json",
24 "sources/source-registry.json",
25 "evidence/evidence-atoms.json",
26 "evidence/source-evidence-map.json",
27 "conditions/condition-records.json",
28 "events/events.json",
29 "events/replay-report.json",
30 "proposals/proposals.json",
31 "reviews/review-events.json",
32 "reviews/confidence-updates.json",
33 "check-summary.json",
34];
35
36pub const DERIVED_PACKET_ARTIFACTS: &[&str] = &[
45 "overview.json",
46 "scope.json",
47 "source-table.json",
48 "evidence-matrix.json",
49 "conditions/condition-matrix.json",
50 "signals.json",
51 "review-queue.json",
52 "quality-table.json",
53 "state-transitions.json",
54 "candidate-tensions.json",
55 "candidate-gaps.json",
56 "candidate-bridges.json",
57 "mcp-session.json",
58];
59
60pub const REQUIRED_PACKET_FILES: &[&str] = &[
63 "manifest.json",
64 "packet.lock.json",
65 "proof-trace.json",
66 "ro-crate-metadata.jsonld",
67 "findings/full.json",
68 "artifacts/artifacts.json",
69 "artifacts/artifact-audit.json",
70 "artifacts/blob-map.json",
71 "sources/source-registry.json",
72 "evidence/evidence-atoms.json",
73 "evidence/source-evidence-map.json",
74 "conditions/condition-records.json",
75 "conditions/condition-matrix.json",
76 "events/events.json",
77 "events/replay-report.json",
78 "proposals/proposals.json",
79 "reviews/review-events.json",
80 "reviews/confidence-updates.json",
81 "check-summary.json",
82 "overview.json",
83 "scope.json",
84 "source-table.json",
85 "evidence-matrix.json",
86 "signals.json",
87 "review-queue.json",
88 "quality-table.json",
89 "state-transitions.json",
90 "candidate-tensions.json",
91 "candidate-gaps.json",
92 "candidate-bridges.json",
93 "mcp-session.json",
94];
95
96pub fn required_packet_files() -> &'static [&'static str] {
97 REQUIRED_PACKET_FILES
98}
99
100pub fn canonical_packet_files() -> &'static [&'static str] {
103 CANONICAL_PACKET_FILES
104}
105
106pub fn derived_packet_artifacts() -> &'static [&'static str] {
109 DERIVED_PACKET_ARTIFACTS
110}
111
112#[derive(Debug, Deserialize)]
113struct PacketManifest {
114 packet_format: String,
115 packet_version: String,
116 generated_at: String,
117 source: PacketSource,
118 stats: PacketStats,
119 included_files: Vec<PacketManifestFile>,
120}
121
122#[derive(Debug, Deserialize)]
123struct PacketSource {
124 project_name: String,
125 description: String,
126 compiled_at: String,
127 compiler: String,
128 vela_version: String,
129 schema: String,
130}
131
132#[derive(Debug, Deserialize)]
133struct PacketStats {
134 findings: usize,
135 review_events: usize,
136 #[serde(default)]
137 proposals: usize,
138 gaps: usize,
139 contested: usize,
140 bridge_entities: usize,
141 contradiction_edges: usize,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145struct PacketManifestFile {
146 path: String,
147 sha256: String,
148 bytes: usize,
149}
150
151#[derive(Debug, Deserialize)]
152struct ProofTrace {
153 trace_version: String,
154 generated_at: Option<String>,
155 #[serde(default)]
156 command: Vec<String>,
157 source: String,
158 source_hash: String,
159 #[serde(default)]
160 snapshot_hash: Option<String>,
161 #[serde(default)]
162 event_log_hash: Option<String>,
163 #[serde(default)]
164 proposal_state_hash: Option<String>,
165 #[serde(default)]
166 replay_status: Option<String>,
167 #[serde(default)]
168 packet_manifest_hash: Option<String>,
169 schema_version: String,
170 checked_artifacts: Vec<String>,
171 packet_manifest: Option<String>,
172 packet_validation: Option<String>,
173 caveats: Vec<String>,
174 status: String,
175 trace_path: Option<String>,
176}
177
178pub fn inspect(path: &Path) -> Result<String, String> {
179 let manifest = load_manifest(path)?;
180 let mut out = String::new();
181 out.push_str("vela packet inspect\n");
182 out.push_str(&format!(" root: {}\n", path.display()));
183 out.push_str(&format!(
184 " project: {}\n",
185 manifest.source.project_name
186 ));
187 out.push_str(&format!(
188 " format: {} {}\n",
189 manifest.packet_format, manifest.packet_version
190 ));
191 out.push_str(&format!(" generated: {}\n", manifest.generated_at));
192 out.push_str(&format!(
193 " compiled_at: {}\n",
194 manifest.source.compiled_at
195 ));
196 out.push_str(&format!(
197 " compiler: {}\n",
198 manifest.source.compiler
199 ));
200 out.push_str(&format!(
201 " vela_version: {}\n",
202 manifest.source.vela_version
203 ));
204 out.push_str(&format!(" schema: {}\n", manifest.source.schema));
205 out.push_str(&format!(
206 " findings: {}\n",
207 manifest.stats.findings
208 ));
209 out.push_str(&format!(
210 " review_events: {}\n",
211 manifest.stats.review_events
212 ));
213 out.push_str(&format!(
214 " proposals: {}\n",
215 manifest.stats.proposals
216 ));
217 out.push_str(&format!(" gaps: {}\n", manifest.stats.gaps));
218 out.push_str(&format!(
219 " contested: {}\n",
220 manifest.stats.contested
221 ));
222 out.push_str(&format!(
223 " bridge_entities: {}\n",
224 manifest.stats.bridge_entities
225 ));
226 out.push_str(&format!(
227 " contradictions: {}\n",
228 manifest.stats.contradiction_edges
229 ));
230 out.push_str(&format!(
231 " files: {}\n",
232 manifest.included_files.len()
233 ));
234 if !manifest.source.description.is_empty() {
235 out.push_str(&format!(
236 " description: {}\n",
237 manifest.source.description
238 ));
239 }
240 Ok(out)
241}
242
243pub fn validate(path: &Path) -> Result<String, String> {
244 let manifest = load_manifest(path)?;
245 if manifest.packet_format != "vela.frontier-packet" {
246 return Err(format!(
247 "Unsupported packet format '{}' in {}",
248 manifest.packet_format,
249 path.display()
250 ));
251 }
252
253 let mut checked = 0usize;
254 for file in &manifest.included_files {
255 let abs = path.join(&file.path);
256 let bytes = std::fs::read(&abs)
257 .map_err(|e| format!("Missing or unreadable packet file {}: {e}", abs.display()))?;
258 if file.path == "proof-trace.json" {
259 validate_proof_trace(path, &abs)?;
260 }
261 if bytes.len() != file.bytes {
262 return Err(format!(
263 "Packet file size mismatch for {}: manifest={}, actual={}",
264 file.path,
265 file.bytes,
266 bytes.len()
267 ));
268 }
269 let actual_hash = sha256_hex(&bytes);
270 if actual_hash != file.sha256 {
271 return Err(format!(
272 "Packet checksum mismatch for {}: manifest={}, actual={}",
273 file.path, file.sha256, actual_hash
274 ));
275 }
276 checked += 1;
277 }
278
279 for required in REQUIRED_PACKET_FILES {
280 if !path.join(required).exists() {
281 return Err(format!("Packet missing required file: {}", required));
282 }
283 }
284
285 validate_packet_lock(path)?;
286 validate_replay_report(path)?;
287 validate_source_evidence(path)?;
288 validate_conditions(path)?;
289 validate_artifact_payloads(path)?;
290
291 validate_proof_trace(path, &path.join("proof-trace.json"))?;
292
293 Ok(format!(
294 "vela packet validate\n root: {}\n status: ok\n checked_files: {}\n project: {}",
295 path.display(),
296 checked,
297 manifest.source.project_name
298 ))
299}
300
301fn load_manifest(path: &Path) -> Result<PacketManifest, String> {
302 let manifest_path = path.join("manifest.json");
303 let manifest_data = std::fs::read_to_string(&manifest_path).map_err(|e| {
304 format!(
305 "Failed to read packet manifest {}: {e}",
306 manifest_path.display()
307 )
308 })?;
309 serde_json::from_str(&manifest_data).map_err(|e| {
310 format!(
311 "Failed to parse packet manifest {}: {e}",
312 manifest_path.display()
313 )
314 })
315}
316
317fn validate_proof_trace(packet_dir: &Path, trace_path: &Path) -> Result<(), String> {
318 let trace_data = std::fs::read_to_string(trace_path)
319 .map_err(|e| format!("Failed to read proof trace {}: {e}", trace_path.display()))?;
320 let trace: ProofTrace = serde_json::from_str(&trace_data)
321 .map_err(|e| format!("Failed to parse proof trace {}: {e}", trace_path.display()))?;
322
323 if trace.trace_version.trim().is_empty() {
324 return Err("Proof trace missing trace_version".to_string());
325 }
326 if !trace.command.is_empty()
327 && trace
328 .command
329 .first()
330 .is_none_or(|command| command != "vela")
331 {
332 return Err("Proof trace command must start with vela when present".to_string());
333 }
334 if let Some(generated_at) = &trace.generated_at
335 && generated_at.trim().is_empty()
336 {
337 return Err("Proof trace generated_at must be non-empty when present".to_string());
338 }
339 if trace.source.trim().is_empty() {
340 return Err("Proof trace source must be non-empty".to_string());
341 }
342 if !is_sha256_hex(&trace.source_hash) {
343 return Err(format!(
344 "Proof trace source_hash must be a 64-character sha256 hex digest, got '{}'",
345 trace.source_hash
346 ));
347 }
348 if trace.schema_version.trim().is_empty() {
349 return Err("Proof trace schema_version must be non-empty".to_string());
350 }
351 if trace
352 .snapshot_hash
353 .as_deref()
354 .is_some_and(|hash| !is_sha256_hex(hash))
355 {
356 return Err("Proof trace snapshot_hash must be a sha256 hex digest".to_string());
357 }
358 if trace
359 .event_log_hash
360 .as_deref()
361 .is_some_and(|hash| !is_sha256_hex(hash))
362 {
363 return Err("Proof trace event_log_hash must be a sha256 hex digest".to_string());
364 }
365 if trace
366 .proposal_state_hash
367 .as_deref()
368 .is_some_and(|hash| !is_sha256_hex(hash))
369 {
370 return Err("Proof trace proposal_state_hash must be a sha256 hex digest".to_string());
371 }
372 if trace
373 .packet_manifest_hash
374 .as_deref()
375 .is_some_and(|hash| !is_sha256_hex(hash))
376 {
377 return Err("Proof trace packet_manifest_hash must be a sha256 hex digest".to_string());
378 }
379 if trace
380 .replay_status
381 .as_deref()
382 .is_some_and(|status| status != "ok" && status != "no_events")
383 {
384 return Err("Proof trace replay_status must be ok or no_events".to_string());
385 }
386 if trace.status != "ok" {
387 return Err(format!(
388 "Proof trace status must be ok, got '{}'",
389 trace.status
390 ));
391 }
392 if trace.caveats.is_empty() {
393 return Err("Proof trace must include caveats".to_string());
394 }
395 for required in CANONICAL_PACKET_FILES {
400 if !trace
401 .checked_artifacts
402 .iter()
403 .any(|artifact| artifact == required)
404 {
405 return Err(format!(
406 "Proof trace checked_artifacts missing canonical artifact: {}",
407 required
408 ));
409 }
410 }
411 if let Some(packet_manifest) = &trace.packet_manifest
412 && !Path::new(packet_manifest).ends_with("manifest.json")
413 {
414 return Err("Proof trace packet_manifest must point to manifest.json".to_string());
415 }
416 if let Some(packet_validation) = &trace.packet_validation
417 && !packet_validation.contains("status: ok")
418 {
419 return Err("Proof trace packet_validation must include status: ok".to_string());
420 }
421 if let Some(trace_path_value) = &trace.trace_path
422 && !Path::new(trace_path_value).ends_with("proof-trace.json")
423 {
424 return Err("Proof trace trace_path must point to proof-trace.json".to_string());
425 }
426 if !packet_dir.join("manifest.json").exists() {
427 return Err("Proof trace validation requires packet manifest".to_string());
428 }
429
430 Ok(())
431}
432
433fn validate_replay_report(packet_dir: &Path) -> Result<(), String> {
434 let events_path = packet_dir.join("events/events.json");
435 if !events_path.is_file() {
436 return Err("Packet missing canonical events file".to_string());
437 }
438 let replay_path = packet_dir.join("events/replay-report.json");
439 let replay_data = std::fs::read_to_string(&replay_path).map_err(|e| {
440 format!(
441 "Failed to read replay report {}: {e}",
442 replay_path.display()
443 )
444 })?;
445 let replay: serde_json::Value = serde_json::from_str(&replay_data).map_err(|e| {
446 format!(
447 "Failed to parse replay report {}: {e}",
448 replay_path.display()
449 )
450 })?;
451 if replay["ok"].as_bool() != Some(true) {
452 return Err("Replay report status is not ok".to_string());
453 }
454 let status = replay["status"].as_str().unwrap_or_default();
455 if status != "ok" && status != "no_events" {
456 return Err(format!("Replay report has unsupported status: {status}"));
457 }
458 Ok(())
459}
460
461fn validate_source_evidence(packet_dir: &Path) -> Result<(), String> {
462 let sources_path = packet_dir.join("sources/source-registry.json");
463 let atoms_path = packet_dir.join("evidence/evidence-atoms.json");
464 let findings_path = packet_dir.join("findings/full.json");
465
466 let sources_data = std::fs::read_to_string(&sources_path).map_err(|e| {
467 format!(
468 "Failed to read source registry {}: {e}",
469 sources_path.display()
470 )
471 })?;
472 let atoms_data = std::fs::read_to_string(&atoms_path).map_err(|e| {
473 format!(
474 "Failed to read evidence atoms {}: {e}",
475 atoms_path.display()
476 )
477 })?;
478 let findings_data = std::fs::read_to_string(&findings_path).map_err(|e| {
479 format!(
480 "Failed to read packet findings {}: {e}",
481 findings_path.display()
482 )
483 })?;
484
485 let sources: serde_json::Value = serde_json::from_str(&sources_data).map_err(|e| {
486 format!(
487 "Failed to parse source registry {}: {e}",
488 sources_path.display()
489 )
490 })?;
491 let atoms: serde_json::Value = serde_json::from_str(&atoms_data).map_err(|e| {
492 format!(
493 "Failed to parse evidence atoms {}: {e}",
494 atoms_path.display()
495 )
496 })?;
497 let findings: serde_json::Value = serde_json::from_str(&findings_data).map_err(|e| {
498 format!(
499 "Failed to parse packet findings {}: {e}",
500 findings_path.display()
501 )
502 })?;
503
504 let source_ids = sources
505 .as_array()
506 .ok_or("Source registry must be a JSON array")?
507 .iter()
508 .filter_map(|source| source["id"].as_str())
509 .collect::<std::collections::BTreeSet<_>>();
510 let finding_ids = findings
511 .as_array()
512 .ok_or("Packet findings/full.json must be a JSON array")?
513 .iter()
514 .filter_map(|finding| finding["id"].as_str())
515 .collect::<std::collections::BTreeSet<_>>();
516 let mut atoms_by_finding = std::collections::BTreeMap::<&str, usize>::new();
517
518 for atom in atoms
519 .as_array()
520 .ok_or("Evidence atoms must be a JSON array")?
521 {
522 let source_id = atom["source_id"]
523 .as_str()
524 .ok_or("Evidence atom missing source_id")?;
525 let finding_id = atom["finding_id"]
526 .as_str()
527 .ok_or("Evidence atom missing finding_id")?;
528 if !source_ids.contains(source_id) {
529 return Err(format!(
530 "Evidence atom references missing source_id: {source_id}"
531 ));
532 }
533 if !finding_ids.contains(finding_id) {
534 return Err(format!(
535 "Evidence atom references missing finding_id: {finding_id}"
536 ));
537 }
538 *atoms_by_finding.entry(finding_id).or_default() += 1;
539 }
540
541 for finding in findings
542 .as_array()
543 .ok_or("Packet findings/full.json must be a JSON array")?
544 {
545 let id = finding["id"].as_str().unwrap_or_default();
546 let retracted = finding["flags"]["retracted"].as_bool().unwrap_or(false);
547 if !retracted && !atoms_by_finding.contains_key(id) {
548 return Err(format!("Active finding has no evidence atom: {id}"));
549 }
550 }
551
552 Ok(())
553}
554
555fn validate_conditions(packet_dir: &Path) -> Result<(), String> {
556 let conditions_path = packet_dir.join("conditions/condition-records.json");
557 let atoms_path = packet_dir.join("evidence/evidence-atoms.json");
558 let findings_path = packet_dir.join("findings/full.json");
559
560 let conditions_data = std::fs::read_to_string(&conditions_path).map_err(|e| {
561 format!(
562 "Failed to read condition records {}: {e}",
563 conditions_path.display()
564 )
565 })?;
566 let atoms_data = std::fs::read_to_string(&atoms_path).map_err(|e| {
567 format!(
568 "Failed to read evidence atoms {}: {e}",
569 atoms_path.display()
570 )
571 })?;
572 let findings_data = std::fs::read_to_string(&findings_path).map_err(|e| {
573 format!(
574 "Failed to read packet findings {}: {e}",
575 findings_path.display()
576 )
577 })?;
578
579 let conditions: serde_json::Value = serde_json::from_str(&conditions_data).map_err(|e| {
580 format!(
581 "Failed to parse condition records {}: {e}",
582 conditions_path.display()
583 )
584 })?;
585 let atoms: serde_json::Value = serde_json::from_str(&atoms_data).map_err(|e| {
586 format!(
587 "Failed to parse evidence atoms {}: {e}",
588 atoms_path.display()
589 )
590 })?;
591 let findings: serde_json::Value = serde_json::from_str(&findings_data).map_err(|e| {
592 format!(
593 "Failed to parse packet findings {}: {e}",
594 findings_path.display()
595 )
596 })?;
597
598 let condition_ids = conditions
599 .as_array()
600 .ok_or("Condition records must be a JSON array")?
601 .iter()
602 .filter_map(|condition| condition["id"].as_str())
603 .collect::<std::collections::BTreeSet<_>>();
604 let finding_ids = findings
605 .as_array()
606 .ok_or("Packet findings/full.json must be a JSON array")?
607 .iter()
608 .filter_map(|finding| finding["id"].as_str())
609 .collect::<std::collections::BTreeSet<_>>();
610 for condition in conditions
611 .as_array()
612 .ok_or("Condition records must be a JSON array")?
613 {
614 let finding_id = condition["finding_id"]
615 .as_str()
616 .ok_or("Condition record missing finding_id")?;
617 if !finding_ids.contains(finding_id) {
618 return Err(format!(
619 "Condition record references missing finding_id: {finding_id}"
620 ));
621 }
622 }
623 for atom in atoms
624 .as_array()
625 .ok_or("Evidence atoms must be a JSON array")?
626 {
627 for condition_ref in atom["condition_refs"]
628 .as_array()
629 .ok_or("Evidence atom missing condition_refs")?
630 .iter()
631 .filter_map(|value| value.as_str())
632 {
633 if condition_ref.starts_with("finding:") {
634 continue;
635 }
636 if !condition_ids.contains(condition_ref) {
637 return Err(format!(
638 "Evidence atom references missing condition record: {condition_ref}"
639 ));
640 }
641 }
642 }
643
644 Ok(())
645}
646
647fn validate_artifact_payloads(packet_dir: &Path) -> Result<(), String> {
648 let artifacts_path = packet_dir.join("artifacts/artifacts.json");
649 let audit_path = packet_dir.join("artifacts/artifact-audit.json");
650 let blob_map_path = packet_dir.join("artifacts/blob-map.json");
651
652 let artifacts_data = std::fs::read_to_string(&artifacts_path).map_err(|e| {
653 format!(
654 "Failed to read artifact records {}: {e}",
655 artifacts_path.display()
656 )
657 })?;
658 let audit_data = std::fs::read_to_string(&audit_path).map_err(|e| {
659 format!(
660 "Failed to read artifact audit {}: {e}",
661 audit_path.display()
662 )
663 })?;
664 let blob_map_data = std::fs::read_to_string(&blob_map_path).map_err(|e| {
665 format!(
666 "Failed to read artifact blob map {}: {e}",
667 blob_map_path.display()
668 )
669 })?;
670
671 let artifacts: serde_json::Value = serde_json::from_str(&artifacts_data).map_err(|e| {
672 format!(
673 "Failed to parse artifact records {}: {e}",
674 artifacts_path.display()
675 )
676 })?;
677 let audit: serde_json::Value = serde_json::from_str(&audit_data).map_err(|e| {
678 format!(
679 "Failed to parse artifact audit {}: {e}",
680 audit_path.display()
681 )
682 })?;
683 let blob_map: serde_json::Value = serde_json::from_str(&blob_map_data).map_err(|e| {
684 format!(
685 "Failed to parse artifact blob map {}: {e}",
686 blob_map_path.display()
687 )
688 })?;
689
690 let artifact_rows = artifacts
691 .as_array()
692 .ok_or("Artifact records must be a JSON array")?;
693 let blob_rows = blob_map
694 .as_array()
695 .ok_or("Artifact blob map must be a JSON array")?;
696
697 if audit["ok"].as_bool() != Some(true) {
698 return Err("Artifact audit status is not ok".to_string());
699 }
700 if audit["artifact_count"].as_u64() != Some(artifact_rows.len() as u64) {
701 return Err("Artifact audit count does not match artifacts/artifacts.json".to_string());
702 }
703 if audit["issue_count"].as_u64().unwrap_or(1) != 0 {
704 return Err("Artifact audit reports non-zero issues".to_string());
705 }
706
707 let blob_by_artifact = blob_rows
708 .iter()
709 .filter_map(|row| Some((row["artifact_id"].as_str()?, row)))
710 .collect::<std::collections::BTreeMap<_, _>>();
711 let mut local_artifact_count = 0u64;
712
713 for artifact in artifact_rows {
714 let id = artifact["id"].as_str().unwrap_or("<unknown>");
715 let storage_mode = artifact["storage_mode"].as_str().unwrap_or_default();
716 if storage_mode != "local_blob" && storage_mode != "local_file" {
717 continue;
718 }
719 local_artifact_count += 1;
720 let content_hash = artifact["content_hash"]
721 .as_str()
722 .ok_or_else(|| format!("Artifact {id} missing content_hash"))?;
723 let Some(hex) = content_hash.strip_prefix("sha256:") else {
724 return Err(format!(
725 "Artifact {id} content_hash must use sha256:<hex> format"
726 ));
727 };
728 if !is_sha256_hex(hex) {
729 return Err(format!("Artifact {id} content_hash is not sha256 hex"));
730 }
731 let blob = blob_by_artifact
732 .get(id)
733 .ok_or_else(|| format!("Local artifact {id} missing packet blob map entry"))?;
734 if blob["content_hash"].as_str() != Some(content_hash) {
735 return Err(format!("Artifact {id} blob map content_hash mismatch"));
736 }
737 let packet_path = blob["packet_path"]
738 .as_str()
739 .ok_or_else(|| format!("Artifact {id} blob map missing packet_path"))?;
740 let blob_path = packet_dir.join(packet_path);
741 let bytes = std::fs::read(&blob_path).map_err(|e| {
742 format!(
743 "Artifact {id} packet blob is unreadable at {}: {e}",
744 blob_path.display()
745 )
746 })?;
747 let actual_hash = sha256_hex(&bytes);
748 if actual_hash != hex {
749 return Err(format!(
750 "Artifact {id} packet blob hash mismatch: expected {hex}, found {actual_hash}"
751 ));
752 }
753 if let Some(size) = blob["size_bytes"].as_u64()
754 && size != bytes.len() as u64
755 {
756 return Err(format!(
757 "Artifact {id} blob size mismatch: expected {size}, found {}",
758 bytes.len()
759 ));
760 }
761 }
762
763 if audit["checked_local_blobs"].as_u64().unwrap_or(0) != local_artifact_count {
764 return Err(
765 "Artifact audit checked_local_blobs does not match local artifacts".to_string(),
766 );
767 }
768
769 Ok(())
770}
771
772fn validate_packet_lock(packet_dir: &Path) -> Result<(), String> {
773 let lock_path = packet_dir.join("packet.lock.json");
774 let lock_data = std::fs::read_to_string(&lock_path)
775 .map_err(|e| format!("Failed to read packet lock {}: {e}", lock_path.display()))?;
776 let lock: serde_json::Value = serde_json::from_str(&lock_data)
777 .map_err(|e| format!("Failed to parse packet lock {}: {e}", lock_path.display()))?;
778 if lock["lock_format"].as_str() != Some("vela.packet-lock.v1") {
779 return Err("Packet lock has unsupported lock_format".to_string());
780 }
781 let Some(files) = lock["files"].as_array() else {
782 return Err("Packet lock missing files array".to_string());
783 };
784 for file in files {
785 let Some(path_value) = file["path"].as_str() else {
786 return Err("Packet lock file entry missing path".to_string());
787 };
788 let Some(expected_hash) = file["sha256"].as_str() else {
789 return Err(format!("Packet lock entry missing sha256 for {path_value}"));
790 };
791 let bytes = std::fs::read(packet_dir.join(path_value))
792 .map_err(|e| format!("Packet lock references unreadable file {path_value}: {e}"))?;
793 let actual_hash = sha256_hex(&bytes);
794 if actual_hash != expected_hash {
795 return Err(format!(
796 "Packet lock checksum mismatch for {}: lock={}, actual={}",
797 path_value, expected_hash, actual_hash
798 ));
799 }
800 }
801 Ok(())
802}
803
804fn sha256_hex(bytes: &[u8]) -> String {
805 let mut hasher = Sha256::new();
806 hasher.update(bytes);
807 hex::encode(hasher.finalize())
808}
809
810fn is_sha256_hex(value: &str) -> bool {
811 value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817 use std::fs;
818 use tempfile::TempDir;
819
820 fn write_file(root: &Path, path: &str, body: &[u8]) -> PacketManifestFile {
821 let abs = root.join(path);
822 if let Some(parent) = abs.parent() {
823 fs::create_dir_all(parent).unwrap();
824 }
825 fs::write(&abs, body).unwrap();
826 PacketManifestFile {
827 path: path.to_string(),
828 sha256: sha256_hex(body),
829 bytes: body.len(),
830 }
831 }
832
833 fn refresh_packet_entry(root: &Path, path: &str, body: &[u8]) {
834 let lock_path = root.join("packet.lock.json");
835 let mut lock: serde_json::Value =
836 serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
837 let lock_files = lock["files"].as_array_mut().unwrap();
838 let lock_entry = lock_files
839 .iter_mut()
840 .find(|entry| entry["path"] == serde_json::json!(path))
841 .unwrap();
842 lock_entry["sha256"] = serde_json::json!(sha256_hex(body));
843 lock_entry["bytes"] = serde_json::json!(body.len());
844 let lock_bytes = serde_json::to_vec_pretty(&lock).unwrap();
845 fs::write(&lock_path, &lock_bytes).unwrap();
846
847 let manifest_path = root.join("manifest.json");
848 let mut manifest: serde_json::Value =
849 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap()).unwrap();
850 let manifest_files = manifest["included_files"].as_array_mut().unwrap();
851 let manifest_entry = manifest_files
852 .iter_mut()
853 .find(|entry| entry["path"] == serde_json::json!(path))
854 .unwrap();
855 manifest_entry["sha256"] = serde_json::json!(sha256_hex(body));
856 manifest_entry["bytes"] = serde_json::json!(body.len());
857 let lock_entry = manifest_files
858 .iter_mut()
859 .find(|entry| entry["path"] == serde_json::json!("packet.lock.json"))
860 .unwrap();
861 lock_entry["sha256"] = serde_json::json!(sha256_hex(&lock_bytes));
862 lock_entry["bytes"] = serde_json::json!(lock_bytes.len());
863 fs::write(
864 &manifest_path,
865 serde_json::to_vec_pretty(&manifest).unwrap(),
866 )
867 .unwrap();
868 }
869
870 fn write_valid_packet(root: &Path) {
871 let mut files = vec![
872 write_file(root, "README.md", b"packet"),
873 write_file(root, "reviewer-guide.md", b"guide"),
874 write_file(root, "overview.json", br#"{"findings":1}"#),
875 write_file(root, "scope.json", br#"{"frontier_name":"test"}"#),
876 write_file(root, "source-table.json", br#"[]"#),
877 write_file(root, "sources/source-registry.json", br#"[]"#),
878 write_file(root, "evidence-matrix.json", br#"[]"#),
879 write_file(root, "evidence/evidence-atoms.json", br#"[]"#),
880 write_file(root, "evidence/source-evidence-map.json", br#"{"schema":"vela.source-evidence-map.v0","sources":{}}"#),
881 write_file(root, "conditions/condition-records.json", br#"[]"#),
882 write_file(root, "conditions/condition-matrix.json", br#"{"schema":"vela.condition-matrix.v0","conditions":[]}"#),
883 write_file(root, "candidate-tensions.json", br#"[]"#),
884 write_file(root, "candidate-gaps.json", br#"[]"#),
885 write_file(root, "candidate-bridges.json", br#"[]"#),
886 write_file(root, "mcp-session.json", br#"{"recommended_loop":[]}"#),
887 write_file(root, "check-summary.json", br#"{"status":"ok"}"#),
888 write_file(root, "signals.json", br#"[]"#),
889 write_file(root, "review-queue.json", br#"[]"#),
890 write_file(root, "quality-table.json", br#"{"proof_readiness":{"status":"ready"}}"#),
891 write_file(
892 root,
893 "state-transitions.json",
894 br#"{"schema":"vela.state-transitions.v0","transitions":[]}"#,
895 ),
896 write_file(root, "events/events.json", br#"[]"#),
897 write_file(
898 root,
899 "events/replay-report.json",
900 br#"{"ok":true,"status":"no_events","baseline_hash":null,"replayed_hash":null,"current_hash":null,"conflicts":[],"applied_events":0}"#,
901 ),
902 write_file(root, "proposals/proposals.json", br#"[]"#),
903 write_file(root, "ro-crate-metadata.jsonld", br#"{"@context":"https://w3id.org/ro/crate/1.2/context","@graph":[]}"#),
904 write_file(
905 root,
906 "proof-trace.json",
907 br#"{"trace_version":"0.2.0","generated_at":"2026-04-22T00:00:00Z","source":"test","source_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","schema_version":"0.2.0","checked_artifacts":["manifest.json","overview.json","scope.json","source-table.json","sources/source-registry.json","evidence-matrix.json","evidence/evidence-atoms.json","evidence/source-evidence-map.json","conditions/condition-records.json","conditions/condition-matrix.json","candidate-tensions.json","candidate-gaps.json","candidate-bridges.json","mcp-session.json","check-summary.json","signals.json","review-queue.json","quality-table.json","state-transitions.json","events/events.json","events/replay-report.json","proposals/proposals.json","ro-crate-metadata.jsonld","proof-trace.json","packet.lock.json","findings/full.json","artifacts/artifacts.json","artifacts/artifact-audit.json","artifacts/blob-map.json","reviews/review-events.json","reviews/confidence-updates.json"],"event_log_hash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","proposal_state_hash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","replay_status":"no_events","caveats":["candidate outputs require review"],"status":"ok"}"#,
908 ),
909 write_file(root, "findings/full.json", br#"[]"#),
910 write_file(root, "artifacts/artifacts.json", br#"[]"#),
911 write_file(root, "artifacts/artifact-audit.json", br#"{"ok":true,"command":"artifact-audit","frontier":"test","artifact_count":0,"checked_local_blobs":0,"local_blob_bytes":0,"by_kind":{},"by_storage_mode":{},"issue_count":0,"issues":[]}"#),
912 write_file(root, "artifacts/blob-map.json", br#"[]"#),
913 write_file(root, "reviews/review-events.json", br#"[]"#),
914 write_file(root, "reviews/confidence-updates.json", br#"[]"#),
915 ];
916 let lock = serde_json::json!({
917 "lock_format": "vela.packet-lock.v1",
918 "generated_at": "2026-04-22T00:00:00Z",
919 "files": files.clone(),
920 });
921 let lock_bytes = serde_json::to_vec_pretty(&lock).unwrap();
922 files.push(write_file(root, "packet.lock.json", &lock_bytes));
923 let manifest = serde_json::json!({
924 "packet_format": "vela.frontier-packet",
925 "packet_version": "v1",
926 "generated_at": "2026-04-22T00:00:00Z",
927 "source": {
928 "project_name": "test",
929 "description": "test packet",
930 "compiled_at": "2026-04-22T00:00:00Z",
931 "compiler": "vela/0.2.0",
932 "vela_version": "0.2.0",
933 "schema": "https://vela.science/schema/finding-bundle/v0.2.0"
934 },
935 "stats": {
936 "findings": 1,
937 "sources": 0,
938 "evidence_atoms": 0,
939 "condition_records": 0,
940 "review_events": 0,
941 "gaps": 0,
942 "contested": 0,
943 "bridge_entities": 0,
944 "contradiction_edges": 0
945 },
946 "included_files": files,
947 });
948 fs::write(
949 root.join("manifest.json"),
950 serde_json::to_vec_pretty(&manifest).unwrap(),
951 )
952 .unwrap();
953 }
954
955 fn write_valid_trace(root: &Path) {
956 let trace = serde_json::json!({
957 "trace_version": "0.1.0",
958 "command": ["vela", "proof"],
959 "source": "frontiers/bbb-alzheimer.json",
960 "source_hash": "a".repeat(64),
961 "schema_version": "0.2.0",
962 "checked_artifacts": [
963 "manifest.json",
964 "overview.json",
965 "scope.json",
966 "source-table.json",
967 "sources/source-registry.json",
968 "evidence-matrix.json",
969 "evidence/evidence-atoms.json",
970 "evidence/source-evidence-map.json",
971 "conditions/condition-records.json",
972 "conditions/condition-matrix.json",
973 "candidate-tensions.json",
974 "candidate-gaps.json",
975 "candidate-bridges.json",
976 "mcp-session.json",
977 "check-summary.json",
978 "signals.json",
979 "review-queue.json",
980 "quality-table.json",
981 "state-transitions.json",
982 "events/events.json",
983 "events/replay-report.json",
984 "proposals/proposals.json",
985 "ro-crate-metadata.jsonld",
986 "proof-trace.json",
987 "packet.lock.json",
988 "findings/full.json",
989 "artifacts/artifacts.json",
990 "artifacts/artifact-audit.json",
991 "artifacts/blob-map.json",
992 "reviews/review-events.json",
993 "reviews/confidence-updates.json"
994 ],
995 "proposal_state_hash": "a".repeat(64),
996 "benchmark": null,
997 "packet_manifest": root.join("manifest.json").display().to_string(),
998 "packet_validation": "vela packet validate\n status: ok",
999 "caveats": ["candidate outputs require review"],
1000 "status": "ok",
1001 "trace_path": root.join("proof-trace.json").display().to_string()
1002 });
1003 fs::write(
1004 root.join("proof-trace.json"),
1005 serde_json::to_vec_pretty(&trace).unwrap(),
1006 )
1007 .unwrap();
1008 let trace_bytes = fs::read(root.join("proof-trace.json")).unwrap();
1009 refresh_packet_entry(root, "proof-trace.json", &trace_bytes);
1010 }
1011
1012 #[test]
1013 fn validates_packet_with_proof_trace() {
1014 let tmp = TempDir::new().unwrap();
1015 write_valid_packet(tmp.path());
1016 write_valid_trace(tmp.path());
1017
1018 let result = validate(tmp.path()).unwrap();
1019 assert!(result.contains("status: ok"));
1020 }
1021
1022 #[test]
1023 fn rejects_bad_proof_trace_hash() {
1024 let tmp = TempDir::new().unwrap();
1025 write_valid_packet(tmp.path());
1026 write_valid_trace(tmp.path());
1027 let trace_path = tmp.path().join("proof-trace.json");
1028 let mut trace: serde_json::Value =
1029 serde_json::from_str(&fs::read_to_string(&trace_path).unwrap()).unwrap();
1030 trace["source_hash"] = serde_json::json!("not-a-hash");
1031 let trace_bytes = serde_json::to_vec_pretty(&trace).unwrap();
1032 fs::write(&trace_path, &trace_bytes).unwrap();
1033
1034 let lock_path = tmp.path().join("packet.lock.json");
1035 let mut lock: serde_json::Value =
1036 serde_json::from_str(&fs::read_to_string(&lock_path).unwrap()).unwrap();
1037 let files = lock["files"].as_array_mut().unwrap();
1038 let entry = files
1039 .iter_mut()
1040 .find(|entry| entry["path"] == serde_json::json!("proof-trace.json"))
1041 .unwrap();
1042 entry["sha256"] = serde_json::json!(sha256_hex(&trace_bytes));
1043 entry["bytes"] = serde_json::json!(trace_bytes.len());
1044 let lock_bytes = serde_json::to_vec_pretty(&lock).unwrap();
1045 fs::write(&lock_path, &lock_bytes).unwrap();
1046
1047 let manifest_path = tmp.path().join("manifest.json");
1048 let mut manifest: serde_json::Value =
1049 serde_json::from_str(&fs::read_to_string(&manifest_path).unwrap()).unwrap();
1050 let manifest_files = manifest["included_files"].as_array_mut().unwrap();
1051 let manifest_entry = manifest_files
1052 .iter_mut()
1053 .find(|entry| entry["path"] == serde_json::json!("proof-trace.json"))
1054 .unwrap();
1055 manifest_entry["sha256"] = serde_json::json!(sha256_hex(&trace_bytes));
1056 manifest_entry["bytes"] = serde_json::json!(trace_bytes.len());
1057 let lock_entry = manifest_files
1058 .iter_mut()
1059 .find(|entry| entry["path"] == serde_json::json!("packet.lock.json"))
1060 .unwrap();
1061 lock_entry["sha256"] = serde_json::json!(sha256_hex(&lock_bytes));
1062 lock_entry["bytes"] = serde_json::json!(lock_bytes.len());
1063 fs::write(
1064 &manifest_path,
1065 serde_json::to_vec_pretty(&manifest).unwrap(),
1066 )
1067 .unwrap();
1068
1069 let err = validate(tmp.path()).unwrap_err();
1070 assert!(err.contains("source_hash"));
1071 }
1072
1073 #[test]
1074 fn validates_packet_local_artifact_blobs() {
1075 let tmp = TempDir::new().unwrap();
1076 let blob_bytes = b"{\"nct\":\"NCT03887455\"}\n";
1077 let digest = sha256_hex(blob_bytes);
1078 let content_hash = format!("sha256:{digest}");
1079 let packet_path = format!("artifacts/blobs/sha256/{digest}");
1080 write_file(tmp.path(), &packet_path, blob_bytes);
1081 write_file(
1082 tmp.path(),
1083 "artifacts/artifacts.json",
1084 serde_json::to_string(&serde_json::json!([
1085 {
1086 "id": "va_checked_blob",
1087 "storage_mode": "local_blob",
1088 "content_hash": content_hash,
1089 "size_bytes": blob_bytes.len()
1090 }
1091 ]))
1092 .unwrap()
1093 .as_bytes(),
1094 );
1095 write_file(
1096 tmp.path(),
1097 "artifacts/artifact-audit.json",
1098 serde_json::to_string(&serde_json::json!({
1099 "ok": true,
1100 "artifact_count": 1,
1101 "checked_local_blobs": 1,
1102 "issue_count": 0
1103 }))
1104 .unwrap()
1105 .as_bytes(),
1106 );
1107 write_file(
1108 tmp.path(),
1109 "artifacts/blob-map.json",
1110 serde_json::to_string(&serde_json::json!([
1111 {
1112 "artifact_id": "va_checked_blob",
1113 "content_hash": format!("sha256:{digest}"),
1114 "packet_path": packet_path,
1115 "size_bytes": blob_bytes.len()
1116 }
1117 ]))
1118 .unwrap()
1119 .as_bytes(),
1120 );
1121
1122 validate_artifact_payloads(tmp.path()).unwrap();
1123
1124 fs::write(
1125 tmp.path().join(format!("artifacts/blobs/sha256/{digest}")),
1126 b"tampered",
1127 )
1128 .unwrap();
1129 let err = validate_artifact_payloads(tmp.path()).unwrap_err();
1130 assert!(err.contains("packet blob hash mismatch"));
1131 }
1132}