Skip to main content

vela_protocol/
packet.rs

1//! Packet inspection and validation utilities.
2
3use std::path::Path;
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8/// Canonical packet artifacts: replay-bearing, signed, load-bearing for
9/// proof. These are what `proof-trace.checked_artifacts` requires; a
10/// proof packet's verifiability stands or falls on these.
11///
12/// Doctrine: a canonical artifact carries protocol state. Two
13/// implementations should produce byte-identical canonical artifacts
14/// from the same logical content.
15pub 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
36/// Derived packet artifacts: regenerable projections over canonical
37/// state. These ship in the packet for human inspection but their
38/// values are reconstructible from the canonical files. A consumer that
39/// wants to verify a derived artifact should re-run the projection
40/// from canonical inputs and compare, not trust the packet's copy.
41///
42/// Doctrine: a derived artifact is a view, not a fact. It must be
43/// idempotently regenerable from the canonical layer.
44pub 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
60/// Every artifact a complete packet ships — canonical + derived. Used
61/// by `vela packet validate` to assert structural completeness.
62pub 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
100/// Canonical-only packet artifacts. Use when checking proof-bearing
101/// correctness, not packet completeness.
102pub fn canonical_packet_files() -> &'static [&'static str] {
103    CANONICAL_PACKET_FILES
104}
105
106/// Derived packet artifacts. Use when reasoning about projections that
107/// can be regenerated from the canonical layer.
108pub 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    // Phase K: proof-bearing means canonical-only. Derived artifacts
396    // ship in the packet for inspection but are regenerable; their
397    // checksums are validated structurally (manifest line above) but
398    // their absence from `checked_artifacts` is not a proof failure.
399    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}