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}
475
476struct PackVerifyJsonOutput(PackVerifyJsonData);
477
478impl JsonOutput for PackVerifyJsonOutput {
479    const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
480    type Data = PackVerifyJsonData;
481    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
482        JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
483    }
484}
485
486/// JSON schema for `harn pack verify --json`. Mirrors the runtime
487/// envelope so agents can validate output before consuming it.
488pub fn verify_json_schema() -> serde_json::Value {
489    serde_json::json!({
490        "$schema": "https://json-schema.org/draft/2020-12/schema",
491        "title": "harn pack verify --json",
492        "type": "object",
493        "required": ["schemaVersion", "ok", "data", "warnings"],
494        "properties": {
495            "schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
496            "ok": { "type": "boolean" },
497            "warnings": { "type": "array" },
498            "data": {
499                "type": "object",
500                "required": [
501                    "bundle",
502                    "bundle_hash",
503                    "signature_present",
504                    "signature_verified",
505                    "recorded_bundle_hash",
506                    "key_id",
507                    "schema_version",
508                    "entrypoint",
509                    "module_count",
510                    "content_entry_count"
511                ],
512                "properties": {
513                    "bundle": { "type": "string", "minLength": 1 },
514                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
515                    "recorded_bundle_hash": { "type": ["string", "null"] },
516                    "signature_present": { "type": "boolean" },
517                    "signature_verified": { "type": "boolean" },
518                    "key_id": { "type": ["string", "null"] },
519                    "schema_version": { "type": "integer", "minimum": 1 },
520                    "entrypoint": { "type": "string", "minLength": 1 },
521                    "module_count": { "type": "integer", "minimum": 1 },
522                    "content_entry_count": { "type": "integer", "minimum": 1 }
523                }
524            }
525        }
526    })
527}
528
529/// Dispatcher for [`PackCommand::Verify`]: prints a human-readable
530/// line or a `JsonEnvelope` and exits non-zero on verification failure.
531pub fn run_verify(args: PackVerifyArgs) {
532    match verify(&args) {
533        Ok(outcome) => {
534            if args.json {
535                let envelope = PackVerifyJsonOutput(outcome).into_envelope();
536                println!("{}", to_string_pretty(&envelope));
537            } else {
538                println!(
539                    "ok {} (bundle_hash {}, signature_verified={})",
540                    outcome.bundle.display(),
541                    outcome.bundle_hash,
542                    outcome.signature_verified
543                );
544            }
545        }
546        Err(err) => {
547            if args.json {
548                let envelope: JsonEnvelope<PackVerifyJsonData> =
549                    JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
550                println!("{}", to_string_pretty(&envelope));
551                process::exit(1);
552            }
553            command_error(&err.message);
554        }
555    }
556}
557
558/// Programmatic verify entry point used by tests so they can read the
559/// envelope structurally instead of parsing stdout.
560pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
561    match verify(args) {
562        Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
563        Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
564    }
565}
566
567/// Verify the bundle at `args.bundle`:
568///
569/// 1. Read the archive (`tar.zst`) and decode the manifest.
570/// 2. Recompute the canonical bundle hash from manifest + contents.
571/// 3. If the manifest carries an Ed25519 signature, run the existing
572///    [`verify_workflow_bundle_signature`] check; refuse unsigned
573///    bundles unless `--allow-unsigned` was passed.
574/// 4. Walk each `ModuleEntry` and verify its `source_hash_blake3` /
575///    `harnbc_hash_blake3` match the in-archive payload.
576///
577/// Any mismatch yields a [`PackError`] with a stable structured code
578/// suitable for JSON consumers.
579pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
580    let bytes = std::fs::read(&args.bundle).map_err(|err| {
581        PackError::new(
582            "verify.read_failed",
583            format!("failed to read {}: {err}", args.bundle.display()),
584        )
585    })?;
586    let archive = read_harnpack(&bytes).map_err(|err| {
587        PackError::new(
588            "verify.archive_failed",
589            format!("failed to parse {}: {err}", args.bundle.display()),
590        )
591    })?;
592    let manifest = &archive.manifest;
593    let contents = &archive.contents;
594
595    let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
596        PackError::new(
597            "verify.hash_failed",
598            format!("failed to recompute bundle hash: {err}"),
599        )
600    })?;
601
602    let trust_policy = args
603        .trust_policy
604        .as_deref()
605        .map(skill_provenance::load_trust_policy)
606        .transpose()
607        .map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
608    let signature_present = manifest.signature.is_some();
609    let mut signature_verified = false;
610    let mut key_id = None;
611    if let Some(signature) = manifest.signature.as_ref() {
612        key_id = signature.key_id.clone();
613        verify_workflow_bundle_signature(manifest, contents)
614            .map_err(|err| PackError::new("verify.signature_failed", err.message))?;
615        if args.require_trusted_signer {
616            let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
617                PackError::new(
618                    "verify.signature_failed",
619                    format!("invalid bundle signer: {err}"),
620                )
621            })?;
622            match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
623                .map_err(|err| PackError::new("verify.trust_policy_failed", err))?
624            {
625                skill_provenance::TrustedSignerStatus::Trusted => {}
626                skill_provenance::TrustedSignerStatus::MissingSigner => {
627                    return Err(PackError::new(
628                        "verify.untrusted_signer",
629                        format!(
630                            "bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
631                            args.bundle.display(),
632                            signer_fingerprint
633                        ),
634                    ));
635                }
636                skill_provenance::TrustedSignerStatus::UntrustedSigner => {
637                    return Err(PackError::new(
638                        "verify.untrusted_signer",
639                        format!(
640                            "bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
641                            args.bundle.display(),
642                            signer_fingerprint
643                        ),
644                    ));
645                }
646            }
647        }
648        signature_verified = true;
649        key_id.get_or_insert(
650            signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
651                PackError::new(
652                    "verify.signature_failed",
653                    format!("invalid bundle signer: {err}"),
654                )
655            })?,
656        );
657    } else if args.require_trusted_signer {
658        return Err(PackError::new(
659            "verify.untrusted_signer",
660            format!(
661                "bundle {} is unsigned and cannot satisfy --require-trusted-signer",
662                args.bundle.display()
663            ),
664        ));
665    } else if !args.allow_unsigned {
666        return Err(PackError::new(
667            "verify.unsigned",
668            format!(
669                "refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
670                args.bundle.display()
671            ),
672        ));
673    }
674
675    let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
676    let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
677    let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
678    for entry in contents {
679        archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
680        if let Ok(rel) = entry.path.strip_prefix("sources") {
681            source_map.insert(rel.to_path_buf(), entry);
682        } else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
683            bytecode_map.insert(rel.to_path_buf(), entry);
684        }
685    }
686
687    for module in &manifest.transitive_modules {
688        let source_entry = source_map.get(&module.path).ok_or_else(|| {
689            PackError::new(
690                "verify.module_missing",
691                format!(
692                    "manifest lists module {} but archive has no sources/{} entry",
693                    module.path.display(),
694                    module.path.display()
695                ),
696            )
697        })?;
698        let actual_source = blake3_hash(&source_entry.bytes);
699        if actual_source != module.source_hash_blake3 {
700            return Err(PackError::new(
701                "verify.source_mismatch",
702                format!(
703                    "source hash mismatch for {}: manifest {}, archive {}",
704                    module.path.display(),
705                    module.source_hash_blake3,
706                    actual_source
707                ),
708            ));
709        }
710        let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
711            .ok_or_else(|| {
712                PackError::new(
713                    "verify.module_invalid_path",
714                    format!("module {} has no stem", module.path.display()),
715                )
716            })?;
717        let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
718            PackError::new(
719                "verify.module_missing",
720                format!(
721                    "manifest lists bytecode for {} but archive has no bytecode/{} entry",
722                    module.path.display(),
723                    chunk_rel.display()
724                ),
725            )
726        })?;
727        let actual_harnbc = blake3_hash(&chunk_entry.bytes);
728        if actual_harnbc != module.harnbc_hash_blake3 {
729            return Err(PackError::new(
730                "verify.bytecode_mismatch",
731                format!(
732                    "bytecode hash mismatch for {}: manifest {}, archive {}",
733                    module.path.display(),
734                    module.harnbc_hash_blake3,
735                    actual_harnbc
736                ),
737            ));
738        }
739    }
740
741    if args.strict {
742        verify_sbom_package_hashes(manifest, &archive_hashes)?;
743    }
744
745    // Cross-check the recorded signature hash against the recomputed
746    // canonical hash. `verify_workflow_bundle_signature` already does
747    // this for signed bundles; for unsigned bundles we report the
748    // recomputed hash so callers can compare against external
749    // attestations.
750    let recorded_bundle_hash = manifest
751        .signature
752        .as_ref()
753        .map(|sig| sig.manifest_hash_blake3.clone());
754    if let Some(recorded) = &recorded_bundle_hash {
755        if recorded != &expected_hash {
756            return Err(PackError::new(
757                "verify.recorded_hash_mismatch",
758                format!(
759                    "recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
760                ),
761            ));
762        }
763    }
764
765    Ok(PackVerifyJsonData {
766        bundle: args.bundle.clone(),
767        bundle_hash: expected_hash,
768        recorded_bundle_hash,
769        signature_present,
770        signature_verified,
771        key_id,
772        schema_version: manifest.schema_version,
773        entrypoint: manifest.entrypoint.clone(),
774        module_count: manifest.transitive_modules.len(),
775        content_entry_count: contents.len(),
776    })
777}
778
779fn verify_sbom_package_hashes(
780    manifest: &WorkflowBundle,
781    archive_hashes: &BTreeMap<PathBuf, String>,
782) -> Result<(), PackError> {
783    let module_hashes: BTreeMap<&Path, &str> = manifest
784        .transitive_modules
785        .iter()
786        .map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
787        .collect();
788
789    for package in &manifest.sbom.packages {
790        let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
791            continue;
792        };
793
794        if let Some(rel) = package.name.strip_prefix("module:") {
795            let module_path = Path::new(rel);
796            let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
797                PackError::new(
798                    "verify.sbom_mismatch",
799                    format!(
800                        "SBOM package {} does not match any manifest transitive module",
801                        package.name
802                    ),
803                )
804            })?;
805            if *manifest_hash != expected_hash {
806                return Err(PackError::new(
807                    "verify.sbom_mismatch",
808                    format!(
809                        "SBOM package {} recorded hash {} but manifest module {} uses {}",
810                        package.name,
811                        expected_hash,
812                        module_path.display(),
813                        manifest_hash
814                    ),
815                ));
816            }
817            let source_archive_path = PathBuf::from("sources").join(module_path);
818            let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
819                PackError::new(
820                    "verify.sbom_mismatch",
821                    format!(
822                        "SBOM package {} refers to {}, but archive is missing {}",
823                        package.name,
824                        module_path.display(),
825                        source_archive_path.display()
826                    ),
827                )
828            })?;
829            if archive_hash != expected_hash {
830                return Err(PackError::new(
831                    "verify.sbom_mismatch",
832                    format!(
833                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
834                        package.name,
835                        expected_hash,
836                        source_archive_path.display(),
837                        archive_hash
838                    ),
839                ));
840            }
841            continue;
842        }
843
844        if let Some(rel) = package.name.strip_prefix("asset:") {
845            let asset_archive_path = PathBuf::from("sources").join(rel);
846            let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
847                PackError::new(
848                    "verify.sbom_mismatch",
849                    format!(
850                        "SBOM package {} refers to {}, but archive is missing {}",
851                        package.name,
852                        rel,
853                        asset_archive_path.display()
854                    ),
855                )
856            })?;
857            if archive_hash != expected_hash {
858                return Err(PackError::new(
859                    "verify.sbom_mismatch",
860                    format!(
861                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
862                        package.name,
863                        expected_hash,
864                        asset_archive_path.display(),
865                        archive_hash
866                    ),
867                ));
868            }
869            continue;
870        }
871
872        let candidate_path = Path::new(&package.name);
873        let Some(archive_hash) = archive_hashes.get(candidate_path) else {
874            continue;
875        };
876        if archive_hash != expected_hash {
877            return Err(PackError::new(
878                "verify.sbom_mismatch",
879                format!(
880                    "SBOM package {} recorded hash {} but archive {} hashes to {}",
881                    package.name,
882                    expected_hash,
883                    candidate_path.display(),
884                    archive_hash
885                ),
886            ));
887        }
888    }
889
890    Ok(())
891}
892
893fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
894    match signature.key_id.as_deref() {
895        Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
896        _ => signer_fingerprint_from_public_key(&signature.public_key),
897    }
898}
899
900fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
901    let public_key_bytes = decode_hex_32(public_key_hex)?;
902    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
903        format!("workflow bundle signature public_key is invalid Ed25519: {error}")
904    })?;
905    Ok(skill_provenance::fingerprint_for_key(&verifying_key))
906}
907
908fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
909    let trimmed = raw.trim();
910    if trimmed.len() != 64 {
911        return Err(format!(
912            "workflow bundle signature public_key must be 64 hex characters, found {}",
913            trimmed.len()
914        ));
915    }
916    let mut bytes = [0_u8; 32];
917    for (idx, slot) in bytes.iter_mut().enumerate() {
918        let start = idx * 2;
919        let end = start + 2;
920        *slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
921            format!(
922                "workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
923            )
924        })?;
925    }
926    Ok(bytes)
927}