Skip to main content

harn_cli/commands/pack/
archive.rs

1//! Archive input/output for `harn pack`: safe unpack/repack and bundle verification.
2
3use std::collections::BTreeMap;
4use std::env;
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7use std::process;
8
9use ed25519_dalek::VerifyingKey;
10use harn_vm::bytecode_cache;
11use harn_vm::orchestration::{
12    build_harnpack, load_workflow_bundle_any_version, read_harnpack,
13    verify_workflow_bundle_signature, workflow_bundle_hash, Ed25519Signature, HarnpackEntry,
14    WorkflowBundle, HARNPACK_MANIFEST_PATH,
15};
16use serde::Serialize;
17
18use crate::cli::{PackRepackArgs, PackUnpackArgs, PackVerifyArgs};
19use crate::command_error;
20use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput};
21use crate::skill_provenance;
22
23use super::{adjacent_with_extension, blake3_hash, PackError, DEFAULT_PACK_FILE_MODE};
24
25#[derive(Debug)]
26pub struct PackUnpackOutcome {
27    pub output_dir: PathBuf,
28    pub content_entry_count: usize,
29}
30
31#[derive(Debug)]
32pub struct PackRepackOutcome {
33    pub output_path: PathBuf,
34    pub size_bytes: u64,
35    pub content_entry_count: usize,
36}
37
38pub fn run_unpack(args: PackUnpackArgs) {
39    match unpack(&args) {
40        Ok(outcome) => {
41            println!(
42                "unpacked {} to {} ({} payload entries)",
43                args.bundle.display(),
44                outcome.output_dir.display(),
45                outcome.content_entry_count
46            );
47        }
48        Err(err) => command_error(&err.message),
49    }
50}
51
52pub fn run_repack(args: PackRepackArgs) {
53    match repack(&args) {
54        Ok(outcome) => {
55            println!(
56                "repacked {} to {} ({} payload entries, {} bytes)",
57                args.dir.display(),
58                outcome.output_path.display(),
59                outcome.content_entry_count,
60                outcome.size_bytes
61            );
62        }
63        Err(err) => command_error(&err.message),
64    }
65}
66
67pub fn unpack(args: &PackUnpackArgs) -> Result<PackUnpackOutcome, PackError> {
68    let bytes = fs::read(&args.bundle).map_err(|err| {
69        PackError::new(
70            "unpack.read_failed",
71            format!("failed to read {}: {err}", args.bundle.display()),
72        )
73    })?;
74    let archive = read_harnpack(&bytes).map_err(|err| {
75        PackError::new(
76            "unpack.archive_failed",
77            format!("failed to parse {}: {err}", args.bundle.display()),
78        )
79    })?;
80    prepare_unpack_dir(&args.out, args.force)?;
81
82    let manifest_bytes = serde_json::to_vec_pretty(&archive.manifest).map_err(|err| {
83        PackError::new(
84            "unpack.manifest_failed",
85            format!("failed to encode {HARNPACK_MANIFEST_PATH}: {err}"),
86        )
87    })?;
88    write_unpack_file(
89        &args.out,
90        Path::new(HARNPACK_MANIFEST_PATH),
91        &manifest_bytes,
92        DEFAULT_PACK_FILE_MODE,
93    )?;
94    for entry in &archive.contents {
95        write_unpack_file(&args.out, &entry.path, &entry.bytes, entry.mode)?;
96    }
97
98    Ok(PackUnpackOutcome {
99        output_dir: args.out.clone(),
100        content_entry_count: archive.contents.len(),
101    })
102}
103
104pub fn repack(args: &PackRepackArgs) -> Result<PackRepackOutcome, PackError> {
105    if !args.dir.is_dir() {
106        return Err(PackError::new(
107            "repack.input_not_directory",
108            format!("input is not a directory: {}", args.dir.display()),
109        ));
110    }
111    reject_repack_output_inside_input(&args.dir, &args.out)?;
112    if args.out.exists() && !args.force {
113        return Err(PackError::new(
114            "repack.output_exists",
115            format!(
116                "output path already exists: {} (re-run with --force to replace it)",
117                args.out.display()
118            ),
119        ));
120    }
121    if args.out.is_dir() {
122        return Err(PackError::new(
123            "repack.output_is_directory",
124            format!("output path is a directory: {}", args.out.display()),
125        ));
126    }
127
128    let manifest_path = args.dir.join(HARNPACK_MANIFEST_PATH);
129    let manifest = load_workflow_bundle_any_version(&manifest_path).map_err(|err| {
130        PackError::new(
131            "repack.manifest_failed",
132            format!("failed to read {}: {err}", manifest_path.display()),
133        )
134    })?;
135    let contents = collect_repack_entries(&args.dir)?;
136    let archive_bytes = build_harnpack(&manifest, &contents).map_err(|err| {
137        PackError::new(
138            "repack.archive_failed",
139            format!("failed to assemble {}: {err}", args.out.display()),
140        )
141    })?;
142    if let Some(parent) = args
143        .out
144        .parent()
145        .filter(|parent| !parent.as_os_str().is_empty())
146    {
147        fs::create_dir_all(parent).map_err(|err| {
148            PackError::new(
149                "repack.write_failed",
150                format!("failed to create {}: {err}", parent.display()),
151            )
152        })?;
153    }
154    fs::write(&args.out, &archive_bytes).map_err(|err| {
155        PackError::new(
156            "repack.write_failed",
157            format!("failed to write {}: {err}", args.out.display()),
158        )
159    })?;
160
161    Ok(PackRepackOutcome {
162        output_path: args.out.clone(),
163        size_bytes: archive_bytes.len() as u64,
164        content_entry_count: contents.len(),
165    })
166}
167
168fn prepare_unpack_dir(out: &Path, force: bool) -> Result<(), PackError> {
169    if out.exists() {
170        if !force {
171            return Err(PackError::new(
172                "unpack.output_exists",
173                format!(
174                    "output path already exists: {} (re-run with --force to replace it)",
175                    out.display()
176                ),
177            ));
178        }
179        let metadata = fs::symlink_metadata(out).map_err(|err| {
180            PackError::new(
181                "unpack.read_failed",
182                format!("failed to stat {}: {err}", out.display()),
183            )
184        })?;
185        if metadata.file_type().is_symlink() {
186            return Err(PackError::new(
187                "unpack.output_symlink",
188                format!("refusing to replace symlink {}", out.display()),
189            ));
190        }
191        if metadata.is_dir() {
192            require_prior_unpack_dir(out)?;
193            fs::remove_dir_all(out).map_err(|err| {
194                PackError::new(
195                    "unpack.remove_failed",
196                    format!("failed to remove {}: {err}", out.display()),
197                )
198            })?;
199        } else if metadata.is_file() {
200            fs::remove_file(out).map_err(|err| {
201                PackError::new(
202                    "unpack.remove_failed",
203                    format!("failed to remove {}: {err}", out.display()),
204                )
205            })?;
206        } else {
207            return Err(PackError::new(
208                "unpack.output_unsupported",
209                format!("refusing to replace non-file output path {}", out.display()),
210            ));
211        }
212    }
213    fs::create_dir_all(out).map_err(|err| {
214        PackError::new(
215            "unpack.write_failed",
216            format!("failed to create {}: {err}", out.display()),
217        )
218    })
219}
220
221fn require_prior_unpack_dir(out: &Path) -> Result<(), PackError> {
222    if is_current_dir(out) {
223        return Err(PackError::new(
224            "unpack.output_unsafe",
225            format!("refusing to replace current directory {}", out.display()),
226        ));
227    }
228    let manifest = out.join(HARNPACK_MANIFEST_PATH);
229    if manifest.is_file() {
230        return Ok(());
231    }
232    Err(PackError::new(
233        "unpack.output_not_harnpack_dir",
234        format!(
235            "refusing to remove {} because it does not contain {}; choose a fresh --out dir or remove it manually",
236            out.display(),
237            HARNPACK_MANIFEST_PATH
238        ),
239    ))
240}
241
242fn is_current_dir(path: &Path) -> bool {
243    let Ok(path) = path.canonicalize() else {
244        return false;
245    };
246    let Ok(cwd) = env::current_dir().and_then(|cwd| cwd.canonicalize()) else {
247        return false;
248    };
249    path == cwd
250}
251
252fn write_unpack_file(
253    root: &Path,
254    archive_path: &Path,
255    bytes: &[u8],
256    mode: u32,
257) -> Result<(), PackError> {
258    let safe_path = normalize_safe_archive_path(archive_path)?;
259    let destination = root.join(&safe_path);
260    if let Some(parent) = destination.parent() {
261        fs::create_dir_all(parent).map_err(|err| {
262            PackError::new(
263                "unpack.write_failed",
264                format!("failed to create {}: {err}", parent.display()),
265            )
266        })?;
267    }
268    fs::write(&destination, bytes).map_err(|err| {
269        PackError::new(
270            "unpack.write_failed",
271            format!("failed to write {}: {err}", destination.display()),
272        )
273    })?;
274    set_file_mode(&destination, mode)?;
275    Ok(())
276}
277
278fn collect_repack_entries(root: &Path) -> Result<Vec<HarnpackEntry>, PackError> {
279    let mut entries = Vec::new();
280    collect_repack_entries_inner(root, root, &mut entries)?;
281    entries.sort_by(|left, right| left.path.cmp(&right.path));
282    Ok(entries)
283}
284
285fn reject_repack_output_inside_input(input_dir: &Path, out: &Path) -> Result<(), PackError> {
286    let input = input_dir.canonicalize().map_err(|err| {
287        PackError::new(
288            "repack.read_failed",
289            format!("failed to canonicalize {}: {err}", input_dir.display()),
290        )
291    })?;
292    let out_parent = out
293        .parent()
294        .filter(|parent| !parent.as_os_str().is_empty())
295        .unwrap_or_else(|| Path::new("."));
296    let out_file_name = out.file_name().ok_or_else(|| {
297        PackError::new(
298            "repack.output_invalid",
299            format!("output path must include a file name: {}", out.display()),
300        )
301    })?;
302    let out_parent = out_parent.canonicalize().unwrap_or_else(|_| {
303        if out_parent.is_absolute() {
304            out_parent.to_path_buf()
305        } else {
306            env::current_dir()
307                .unwrap_or_else(|_| PathBuf::from("."))
308                .join(out_parent)
309        }
310    });
311    let output = out_parent.join(out_file_name);
312    if output.starts_with(&input) {
313        return Err(PackError::new(
314            "repack.output_inside_input",
315            format!(
316                "refusing to write {} inside input directory {}; choose an output path outside the unpacked tree",
317                out.display(),
318                input_dir.display()
319            ),
320        ));
321    }
322    Ok(())
323}
324
325fn collect_repack_entries_inner(
326    root: &Path,
327    current: &Path,
328    entries: &mut Vec<HarnpackEntry>,
329) -> Result<(), PackError> {
330    let mut children = fs::read_dir(current)
331        .map_err(|err| {
332            PackError::new(
333                "repack.read_failed",
334                format!("failed to read {}: {err}", current.display()),
335            )
336        })?
337        .collect::<Result<Vec<_>, _>>()
338        .map_err(|err| {
339            PackError::new(
340                "repack.read_failed",
341                format!("failed to read {}: {err}", current.display()),
342            )
343        })?;
344    children.sort_by_key(|entry| entry.path());
345
346    for child in children {
347        let path = child.path();
348        let metadata = fs::symlink_metadata(&path).map_err(|err| {
349            PackError::new(
350                "repack.read_failed",
351                format!("failed to stat {}: {err}", path.display()),
352            )
353        })?;
354        if metadata.file_type().is_symlink() {
355            return Err(PackError::new(
356                "repack.unsupported_entry",
357                format!("refusing to pack symlink {}", path.display()),
358            ));
359        }
360        if metadata.is_dir() {
361            collect_repack_entries_inner(root, &path, entries)?;
362            continue;
363        }
364        if !metadata.is_file() {
365            return Err(PackError::new(
366                "repack.unsupported_entry",
367                format!("refusing to pack non-file entry {}", path.display()),
368            ));
369        }
370
371        let rel = path.strip_prefix(root).map_err(|err| {
372            PackError::new(
373                "repack.path_failed",
374                format!(
375                    "failed to relativize {} against {}: {err}",
376                    path.display(),
377                    root.display()
378                ),
379            )
380        })?;
381        let archive_path = normalize_safe_archive_path(rel)?;
382        if archive_path == Path::new(HARNPACK_MANIFEST_PATH) {
383            continue;
384        }
385        let bytes = fs::read(&path).map_err(|err| {
386            PackError::new(
387                "repack.read_failed",
388                format!("failed to read {}: {err}", path.display()),
389            )
390        })?;
391        entries.push(HarnpackEntry::new(archive_path, bytes).with_mode(file_mode(&metadata)));
392    }
393    Ok(())
394}
395
396fn normalize_safe_archive_path(path: &Path) -> Result<PathBuf, PackError> {
397    let mut normalized = PathBuf::new();
398    for component in path.components() {
399        match component {
400            Component::Normal(part) => normalized.push(part),
401            Component::CurDir => {}
402            Component::ParentDir => {
403                return Err(PackError::new(
404                    "pack.unsafe_archive_path",
405                    format!("archive path may not contain '..': {}", path.display()),
406                ));
407            }
408            Component::Prefix(_) | Component::RootDir => {
409                return Err(PackError::new(
410                    "pack.unsafe_archive_path",
411                    format!("archive path must be relative: {}", path.display()),
412                ));
413            }
414        }
415    }
416    if normalized.as_os_str().is_empty() {
417        return Err(PackError::new(
418            "pack.unsafe_archive_path",
419            "archive path may not be empty",
420        ));
421    }
422    Ok(normalized)
423}
424
425#[cfg(unix)]
426fn file_mode(metadata: &fs::Metadata) -> u32 {
427    use std::os::unix::fs::PermissionsExt;
428
429    metadata.permissions().mode() & 0o7777
430}
431
432#[cfg(not(unix))]
433fn file_mode(_metadata: &fs::Metadata) -> u32 {
434    DEFAULT_PACK_FILE_MODE
435}
436
437#[cfg(unix)]
438fn set_file_mode(path: &Path, mode: u32) -> Result<(), PackError> {
439    use std::os::unix::fs::PermissionsExt;
440
441    fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|err| {
442        PackError::new(
443            "unpack.write_failed",
444            format!("failed to set permissions on {}: {err}", path.display()),
445        )
446    })
447}
448
449#[cfg(not(unix))]
450fn set_file_mode(_path: &Path, _mode: u32) -> Result<(), PackError> {
451    Ok(())
452}
453
454// --- `harn pack verify` -----------------------------------------------------
455
456/// Stable schema version for the `harn pack verify --json` envelope.
457/// Bump when [`PackVerifyJsonData`] changes shape in a way agents need
458/// to detect.
459pub const PACK_VERIFY_SCHEMA_VERSION: u32 = 1;
460
461/// JSON payload emitted under `JsonEnvelope.data` for `harn pack verify`.
462#[derive(Debug, Clone, Serialize)]
463pub struct PackVerifyJsonData {
464    pub bundle: PathBuf,
465    pub bundle_hash: String,
466    pub recorded_bundle_hash: Option<String>,
467    pub signature_present: bool,
468    pub signature_verified: bool,
469    pub key_id: Option<String>,
470    pub schema_version: u32,
471    pub entrypoint: PathBuf,
472    pub module_count: usize,
473    pub content_entry_count: usize,
474    /// Present for schema-v3 bundles, which carry one linked-program artifact
475    /// instead of parallel per-module bytecode.
476    pub link_report: Option<harn_vm::linked_program::LinkReport>,
477}
478
479struct PackVerifyJsonOutput(PackVerifyJsonData);
480
481impl JsonOutput for PackVerifyJsonOutput {
482    const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
483    type Data = PackVerifyJsonData;
484    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
485        JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
486    }
487}
488
489/// JSON schema for `harn pack verify --json`. Mirrors the runtime
490/// envelope so agents can validate output before consuming it.
491pub fn verify_json_schema() -> serde_json::Value {
492    serde_json::json!({
493        "$schema": "https://json-schema.org/draft/2020-12/schema",
494        "title": "harn pack verify --json",
495        "type": "object",
496        "required": ["schemaVersion", "ok", "data", "warnings"],
497        "properties": {
498            "schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
499            "ok": { "type": "boolean" },
500            "warnings": { "type": "array" },
501            "data": {
502                "type": "object",
503                "required": [
504                    "bundle",
505                    "bundle_hash",
506                    "signature_present",
507                    "signature_verified",
508                    "recorded_bundle_hash",
509                    "key_id",
510                    "schema_version",
511                    "entrypoint",
512                    "module_count",
513                    "content_entry_count"
514                ],
515                "properties": {
516                    "bundle": { "type": "string", "minLength": 1 },
517                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
518                    "recorded_bundle_hash": { "type": ["string", "null"] },
519                    "signature_present": { "type": "boolean" },
520                    "signature_verified": { "type": "boolean" },
521                    "key_id": { "type": ["string", "null"] },
522                    "schema_version": { "type": "integer", "minimum": 1 },
523                    "entrypoint": { "type": "string", "minLength": 1 },
524                    "module_count": { "type": "integer", "minimum": 1 },
525                    "content_entry_count": { "type": "integer", "minimum": 1 }
526                }
527            }
528        }
529    })
530}
531
532/// Dispatcher for [`PackCommand::Verify`]: prints a human-readable
533/// line or a `JsonEnvelope` and exits non-zero on verification failure.
534pub fn run_verify(args: PackVerifyArgs) {
535    match verify(&args) {
536        Ok(outcome) => {
537            if args.json {
538                let envelope = PackVerifyJsonOutput(outcome).into_envelope();
539                println!("{}", to_string_pretty(&envelope));
540            } else {
541                println!(
542                    "ok {} (bundle_hash {}, signature_verified={})",
543                    outcome.bundle.display(),
544                    outcome.bundle_hash,
545                    outcome.signature_verified
546                );
547            }
548        }
549        Err(err) => {
550            if args.json {
551                let envelope: JsonEnvelope<PackVerifyJsonData> =
552                    JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
553                println!("{}", to_string_pretty(&envelope));
554                process::exit(1);
555            }
556            command_error(&err.message);
557        }
558    }
559}
560
561/// Programmatic verify entry point used by tests so they can read the
562/// envelope structurally instead of parsing stdout.
563pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
564    match verify(args) {
565        Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
566        Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
567    }
568}
569
570/// Verify the bundle at `args.bundle`:
571///
572/// 1. Read the archive (`tar.zst`) and decode the manifest.
573/// 2. Recompute the canonical bundle hash from manifest + contents.
574/// 3. If the manifest carries an Ed25519 signature, run the existing
575///    [`verify_workflow_bundle_signature`] check; refuse unsigned
576///    bundles unless `--allow-unsigned` was passed.
577/// 4. Walk each `ModuleEntry` and verify its `source_hash_blake3` /
578///    `harnbc_hash_blake3` match the in-archive payload.
579///
580/// Any mismatch yields a [`PackError`] with a stable structured code
581/// suitable for JSON consumers.
582pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
583    let bytes = std::fs::read(&args.bundle).map_err(|err| {
584        PackError::new(
585            "verify.read_failed",
586            format!("failed to read {}: {err}", args.bundle.display()),
587        )
588    })?;
589    let archive = read_harnpack(&bytes).map_err(|err| {
590        PackError::new(
591            "verify.archive_failed",
592            format!("failed to parse {}: {err}", args.bundle.display()),
593        )
594    })?;
595    let manifest = &archive.manifest;
596    let contents = &archive.contents;
597
598    let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
599        PackError::new(
600            "verify.hash_failed",
601            format!("failed to recompute bundle hash: {err}"),
602        )
603    })?;
604
605    let trust_policy = args
606        .trust_policy
607        .as_deref()
608        .map(skill_provenance::load_trust_policy)
609        .transpose()
610        .map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
611    let signature_present = manifest.signature.is_some();
612    let mut signature_verified = false;
613    let mut key_id = None;
614    if let Some(signature) = manifest.signature.as_ref() {
615        key_id = signature.key_id.clone();
616        verify_workflow_bundle_signature(manifest, contents)
617            .map_err(|err| PackError::new("verify.signature_failed", err.message))?;
618        if args.require_trusted_signer {
619            let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
620                PackError::new(
621                    "verify.signature_failed",
622                    format!("invalid bundle signer: {err}"),
623                )
624            })?;
625            match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
626                .map_err(|err| PackError::new("verify.trust_policy_failed", err))?
627            {
628                skill_provenance::TrustedSignerStatus::Trusted => {}
629                skill_provenance::TrustedSignerStatus::MissingSigner => {
630                    return Err(PackError::new(
631                        "verify.untrusted_signer",
632                        format!(
633                            "bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
634                            args.bundle.display(),
635                            signer_fingerprint
636                        ),
637                    ));
638                }
639                skill_provenance::TrustedSignerStatus::UntrustedSigner => {
640                    return Err(PackError::new(
641                        "verify.untrusted_signer",
642                        format!(
643                            "bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
644                            args.bundle.display(),
645                            signer_fingerprint
646                        ),
647                    ));
648                }
649            }
650        }
651        signature_verified = true;
652        key_id.get_or_insert(
653            signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
654                PackError::new(
655                    "verify.signature_failed",
656                    format!("invalid bundle signer: {err}"),
657                )
658            })?,
659        );
660    } else if args.require_trusted_signer {
661        return Err(PackError::new(
662            "verify.untrusted_signer",
663            format!(
664                "bundle {} is unsigned and cannot satisfy --require-trusted-signer",
665                args.bundle.display()
666            ),
667        ));
668    } else if !args.allow_unsigned {
669        return Err(PackError::new(
670            "verify.unsigned",
671            format!(
672                "refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
673                args.bundle.display()
674            ),
675        ));
676    }
677
678    verify_runtime_payloads(manifest, contents, signature_present)?;
679
680    let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
681    for entry in contents {
682        archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
683    }
684
685    if args.strict {
686        verify_sbom_package_hashes(manifest, &archive_hashes, SbomHashScope::Strict)?;
687    }
688
689    // Cross-check the recorded signature hash against the recomputed
690    // canonical hash. `verify_workflow_bundle_signature` already does
691    // this for signed bundles; for unsigned bundles we report the
692    // recomputed hash so callers can compare against external
693    // attestations.
694    let recorded_bundle_hash = manifest
695        .signature
696        .as_ref()
697        .map(|sig| sig.manifest_hash_blake3.clone());
698    if let Some(recorded) = &recorded_bundle_hash {
699        if recorded != &expected_hash {
700            return Err(PackError::new(
701                "verify.recorded_hash_mismatch",
702                format!(
703                    "recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
704                ),
705            ));
706        }
707    }
708
709    Ok(PackVerifyJsonData {
710        bundle: args.bundle.clone(),
711        bundle_hash: expected_hash,
712        recorded_bundle_hash,
713        signature_present,
714        signature_verified,
715        key_id,
716        schema_version: manifest.schema_version,
717        entrypoint: manifest.entrypoint.clone(),
718        module_count: manifest.transitive_modules.len(),
719        content_entry_count: contents.len(),
720        link_report: manifest
721            .execution_artifact
722            .as_ref()
723            .map(|artifact| artifact.link_report.clone()),
724    })
725}
726
727/// Validate the manifest's source and entry-bytecode hashes against their
728/// exact archive paths. Standalone verification and execution share this
729/// owner, so path-insensitive bundle hashing cannot authorize moving signed
730/// payload bytes between modules.
731pub(crate) fn verify_module_payloads(
732    manifest: &WorkflowBundle,
733    contents: &[HarnpackEntry],
734) -> Result<(), PackError> {
735    let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
736    let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
737    for entry in contents {
738        if let Ok(rel) = entry.path.strip_prefix("sources") {
739            source_map.insert(rel.to_path_buf(), entry);
740        } else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
741            bytecode_map.insert(rel.to_path_buf(), entry);
742        }
743    }
744
745    for module in &manifest.transitive_modules {
746        let source_entry = source_map.get(&module.path).ok_or_else(|| {
747            PackError::new(
748                "verify.module_missing",
749                format!(
750                    "manifest lists module {} but archive has no sources/{} entry",
751                    module.path.display(),
752                    module.path.display()
753                ),
754            )
755        })?;
756        let actual_source = blake3_hash(&source_entry.bytes);
757        if actual_source != module.source_hash_blake3 {
758            return Err(PackError::new(
759                "verify.source_mismatch",
760                format!(
761                    "source hash mismatch for {}: manifest {}, archive {}",
762                    module.path.display(),
763                    module.source_hash_blake3,
764                    actual_source
765                ),
766            ));
767        }
768        // Only schema-v2 bundles carry parallel per-module bytecode. A v3
769        // bundle binds its executable bytes through the linked-program
770        // artifact's own hash, and records an empty per-module bytecode hash.
771        if manifest.execution_artifact.is_none() {
772            let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
773                .ok_or_else(|| {
774                    PackError::new(
775                        "verify.module_invalid_path",
776                        format!("module {} has no stem", module.path.display()),
777                    )
778                })?;
779            let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
780                PackError::new(
781                    "verify.module_missing",
782                    format!(
783                        "manifest lists bytecode for {} but archive has no bytecode/{} entry",
784                        module.path.display(),
785                        chunk_rel.display()
786                    ),
787                )
788            })?;
789            let actual_harnbc = blake3_hash(&chunk_entry.bytes);
790            if actual_harnbc != module.harnbc_hash_blake3 {
791                return Err(PackError::new(
792                    "verify.bytecode_mismatch",
793                    format!(
794                        "bytecode hash mismatch for {}: manifest {}, archive {}",
795                        module.path.display(),
796                        module.harnbc_hash_blake3,
797                        actual_harnbc
798                    ),
799                ));
800            }
801        }
802    }
803    // A schema-v3 bundle moved its executable bytes out of the per-module
804    // chunks and into one linked-program artifact, so that artifact is the only
805    // remaining path-bound carrier of anything the VM will run. Bind it here
806    // rather than in the caller: the invariant "every executable byte in this
807    // archive is hash-checked against its exact path" has one owner.
808    if let Some(artifact) = manifest.execution_artifact.as_ref() {
809        let entry = contents
810            .iter()
811            .find(|entry| entry.path == artifact.path)
812            .ok_or_else(|| {
813                PackError::new(
814                    "verify.module_missing",
815                    format!(
816                        "manifest lists execution artifact {} but the archive has no such entry",
817                        artifact.path.display()
818                    ),
819                )
820            })?;
821        let actual = blake3_hash(&entry.bytes);
822        if actual != artifact.hash_blake3 {
823            return Err(PackError::new(
824                "verify.bytecode_mismatch",
825                format!(
826                    "execution artifact hash mismatch for {}: manifest {}, archive {actual}",
827                    artifact.path.display(),
828                    artifact.hash_blake3
829                ),
830            ));
831        }
832    }
833    Ok(())
834}
835
836/// Bind every payload that `harn run` can execute or read to its archive path
837/// before replay. Legacy v2 signatures cover a multiset of content hashes but
838/// not paths, so module-manifest and SBOM identities supply the missing path
839/// binding. Signed packs with extension-only payloads that have no such
840/// identity are rejected; unsigned local-development packs already require an
841/// explicit execution override and retain their legacy behavior.
842pub(crate) fn verify_runtime_payloads(
843    manifest: &WorkflowBundle,
844    contents: &[HarnpackEntry],
845    reject_unbound: bool,
846) -> Result<(), PackError> {
847    verify_module_payloads(manifest, contents)?;
848
849    let archive_hashes = contents
850        .iter()
851        .map(|entry| (entry.path.clone(), blake3_hash(&entry.bytes)))
852        .collect::<BTreeMap<_, _>>();
853    verify_sbom_package_hashes(manifest, &archive_hashes, SbomHashScope::RuntimePaths)?;
854
855    let sbom_entry = contents
856        .iter()
857        .find(|entry| entry.path == Path::new(super::PACK_SBOM_ARCHIVE_PATH))
858        .ok_or_else(|| {
859            PackError::new(
860                "verify.sbom_mismatch",
861                format!("archive is missing {}", super::PACK_SBOM_ARCHIVE_PATH),
862            )
863        })?;
864    let archived_sbom: harn_vm::orchestration::SBOMDoc = serde_json::from_slice(&sbom_entry.bytes)
865        .map_err(|error| {
866            PackError::new(
867                "verify.sbom_mismatch",
868                format!("archive SBOM is not valid JSON: {error}"),
869            )
870        })?;
871    if archived_sbom != manifest.sbom {
872        return Err(PackError::new(
873            "verify.sbom_mismatch",
874            format!(
875                "archive {} does not match the signed manifest SBOM",
876                super::PACK_SBOM_ARCHIVE_PATH
877            ),
878        ));
879    }
880
881    let mut bound_paths = std::collections::BTreeSet::new();
882    bound_paths.insert(PathBuf::from(super::PACK_SBOM_ARCHIVE_PATH));
883    if let Some(artifact) = manifest.execution_artifact.as_ref() {
884        // The linked-program artifact carries its own path and hash in the
885        // signed manifest, which is exactly the path binding this check exists
886        // to require; `verify_module_payloads` enforces the hash.
887        bound_paths.insert(artifact.path.clone());
888    }
889    for module in &manifest.transitive_modules {
890        bound_paths.insert(PathBuf::from("sources").join(&module.path));
891        for extension in [
892            bytecode_cache::CACHE_EXTENSION,
893            bytecode_cache::MODULE_CACHE_EXTENSION,
894        ] {
895            if let Some(relative) = adjacent_with_extension(&module.path, extension) {
896                bound_paths.insert(PathBuf::from("bytecode").join(relative));
897            }
898        }
899    }
900    for package in &manifest.sbom.packages {
901        if package.package_hash_blake3.is_some() {
902            if let Some(relative) = package.name.strip_prefix("asset:") {
903                bound_paths.insert(PathBuf::from("sources").join(relative));
904            }
905        }
906    }
907
908    if reject_unbound {
909        if let Some(entry) = contents
910            .iter()
911            .find(|entry| !bound_paths.contains(&entry.path))
912        {
913            return Err(PackError::new(
914                "verify.unbound_payload",
915                format!(
916                    "signed legacy bundle payload {} has no path-bound manifest or SBOM identity",
917                    entry.path.display()
918                ),
919            ));
920        }
921    }
922    Ok(())
923}
924
925#[derive(Clone, Copy, Debug, PartialEq, Eq)]
926enum SbomHashScope {
927    /// Bind payloads whose runtime identity comes from the SBOM. Module source
928    /// identity is already authoritative in `transitive_modules`.
929    RuntimePaths,
930    /// Also audit redundant module and package metadata for standalone strict
931    /// verification.
932    Strict,
933}
934
935fn verify_sbom_package_hashes(
936    manifest: &WorkflowBundle,
937    archive_hashes: &BTreeMap<PathBuf, String>,
938    scope: SbomHashScope,
939) -> Result<(), PackError> {
940    let module_hashes: BTreeMap<&Path, &str> = manifest
941        .transitive_modules
942        .iter()
943        .map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
944        .collect();
945
946    for package in &manifest.sbom.packages {
947        let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
948            continue;
949        };
950
951        if let Some(rel) = package.name.strip_prefix("module:") {
952            if scope == SbomHashScope::RuntimePaths {
953                continue;
954            }
955            let module_path = Path::new(rel);
956            let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
957                PackError::new(
958                    "verify.sbom_mismatch",
959                    format!(
960                        "SBOM package {} does not match any manifest transitive module",
961                        package.name
962                    ),
963                )
964            })?;
965            if *manifest_hash != expected_hash {
966                return Err(PackError::new(
967                    "verify.sbom_mismatch",
968                    format!(
969                        "SBOM package {} recorded hash {} but manifest module {} uses {}",
970                        package.name,
971                        expected_hash,
972                        module_path.display(),
973                        manifest_hash
974                    ),
975                ));
976            }
977            let source_archive_path = PathBuf::from("sources").join(module_path);
978            let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
979                PackError::new(
980                    "verify.sbom_mismatch",
981                    format!(
982                        "SBOM package {} refers to {}, but archive is missing {}",
983                        package.name,
984                        module_path.display(),
985                        source_archive_path.display()
986                    ),
987                )
988            })?;
989            if archive_hash != expected_hash {
990                return Err(PackError::new(
991                    "verify.sbom_mismatch",
992                    format!(
993                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
994                        package.name,
995                        expected_hash,
996                        source_archive_path.display(),
997                        archive_hash
998                    ),
999                ));
1000            }
1001            continue;
1002        }
1003
1004        if let Some(rel) = package.name.strip_prefix("asset:") {
1005            let asset_archive_path = PathBuf::from("sources").join(rel);
1006            let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
1007                PackError::new(
1008                    "verify.sbom_mismatch",
1009                    format!(
1010                        "SBOM package {} refers to {}, but archive is missing {}",
1011                        package.name,
1012                        rel,
1013                        asset_archive_path.display()
1014                    ),
1015                )
1016            })?;
1017            if archive_hash != expected_hash {
1018                return Err(PackError::new(
1019                    "verify.sbom_mismatch",
1020                    format!(
1021                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
1022                        package.name,
1023                        expected_hash,
1024                        asset_archive_path.display(),
1025                        archive_hash
1026                    ),
1027                ));
1028            }
1029            continue;
1030        }
1031
1032        if scope == SbomHashScope::RuntimePaths {
1033            continue;
1034        }
1035        let candidate_path = Path::new(&package.name);
1036        let Some(archive_hash) = archive_hashes.get(candidate_path) else {
1037            continue;
1038        };
1039        if archive_hash != expected_hash {
1040            return Err(PackError::new(
1041                "verify.sbom_mismatch",
1042                format!(
1043                    "SBOM package {} recorded hash {} but archive {} hashes to {}",
1044                    package.name,
1045                    expected_hash,
1046                    candidate_path.display(),
1047                    archive_hash
1048                ),
1049            ));
1050        }
1051    }
1052
1053    Ok(())
1054}
1055
1056fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
1057    match signature.key_id.as_deref() {
1058        Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
1059        _ => signer_fingerprint_from_public_key(&signature.public_key),
1060    }
1061}
1062
1063fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
1064    let public_key_bytes = decode_hex_32(public_key_hex)?;
1065    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
1066        format!("workflow bundle signature public_key is invalid Ed25519: {error}")
1067    })?;
1068    Ok(skill_provenance::fingerprint_for_key(&verifying_key))
1069}
1070
1071fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
1072    let trimmed = raw.trim();
1073    if trimmed.len() != 64 {
1074        return Err(format!(
1075            "workflow bundle signature public_key must be 64 hex characters, found {}",
1076            trimmed.len()
1077        ));
1078    }
1079    let mut bytes = [0_u8; 32];
1080    for (idx, slot) in bytes.iter_mut().enumerate() {
1081        let start = idx * 2;
1082        let end = start + 2;
1083        *slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
1084            format!(
1085                "workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
1086            )
1087        })?;
1088    }
1089    Ok(bytes)
1090}