Skip to main content

harn_cli/commands/
pack.rs

1//! `harn pack <entrypoint>` — build a signed-ready `.harnpack` from a
2//! Harn entrypoint.
3//!
4//! Walks the entrypoint's transitive imports, precompiles every module
5//! into a `.harnbc` artifact, snapshots the provider catalog and
6//! stdlib pin, generates a minimal SBOM, assembles a v2 `WorkflowBundle`
7//! manifest, and emits a deterministic tar.zst archive.
8//!
9//! `harn pack verify <bundle.harnpack>` (#1779) reads a bundle back,
10//! recomputes its canonical hash, verifies the embedded Ed25519
11//! signature (if any), and cross-checks every per-module BLAKE3.
12
13use std::collections::BTreeMap;
14use std::env;
15use std::fs;
16use std::path::{Component, Path, PathBuf};
17use std::process;
18
19use ed25519_dalek::Signer;
20use ed25519_dalek::VerifyingKey;
21use harn_parser::DiagnosticSeverity;
22use harn_vm::bytecode_cache;
23use harn_vm::module_artifact;
24use harn_vm::orchestration::{
25    build_harnpack, load_workflow_bundle_any_version, read_harnpack,
26    verify_workflow_bundle_signature, workflow_bundle_hash, CatchupPolicySpec,
27    ConnectorRequirement, Ed25519Signature, EnvironmentRequirements, HarnpackEntry, ModuleEntry,
28    RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry, WorkflowBundle,
29    WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
30    HARNPACK_MANIFEST_PATH, WORKFLOW_BUNDLE_SCHEMA_VERSION,
31};
32use harn_vm::Compiler;
33use harn_vm::{AutonomyTier, TrustRecord};
34use serde::{Deserialize, Serialize};
35
36use crate::cli::{PackArgs, PackCommand, PackRepackArgs, PackUnpackArgs, PackVerifyArgs};
37use crate::command_error;
38use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput, JsonWarning};
39use crate::parse_source_file;
40use crate::skill_provenance;
41
42/// Stable schema version for the `harn pack --json` envelope. Bump when
43/// [`PackJsonData`] changes shape in a way that agents need to detect.
44pub const PACK_SCHEMA_VERSION: u32 = 2;
45pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
46const DEFAULT_PACK_FILE_MODE: u32 = 0o644;
47
48/// JSON payload emitted under `JsonEnvelope.data` for `harn pack`.
49#[derive(Debug, Clone, Serialize)]
50pub struct PackJsonData {
51    pub bundle_hash: String,
52    pub output_path: PathBuf,
53    pub size_bytes: u64,
54    pub signature: PackSignatureSummary,
55    pub sbom_summary: PackSbomSummary,
56    pub debug_symbol_metadata: PackDebugSymbolMetadata,
57    pub manifest: WorkflowBundle,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct PackSignatureSummary {
62    pub algorithm: String,
63    pub key_id: Option<String>,
64    pub present: bool,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct PackSbomSummary {
69    pub components: usize,
70    pub stdlib_modules: usize,
71    pub providers: usize,
72    pub tools: usize,
73}
74
75#[derive(Debug, Clone, Serialize)]
76pub struct PackDebugSymbolMetadata {
77    pub harnbc_count: usize,
78    pub total_bytes: u64,
79}
80
81struct PackJsonOutput {
82    data: PackJsonData,
83    warnings: Vec<JsonWarning>,
84}
85
86fn logical_bundle_path(path: &Path) -> String {
87    path.components()
88        .map(|component| component.as_os_str().to_string_lossy())
89        .collect::<Vec<_>>()
90        .join("/")
91}
92
93impl JsonOutput for PackJsonOutput {
94    const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
95    type Data = PackJsonData;
96    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
97        let mut envelope = JsonEnvelope::ok(Self::SCHEMA_VERSION, self.data);
98        envelope.warnings = self.warnings;
99        envelope
100    }
101}
102
103pub fn run(args: PackArgs) {
104    if let Some(command) = args.command {
105        match command {
106            PackCommand::Unpack(unpack_args) => return run_unpack(unpack_args),
107            PackCommand::Repack(repack_args) => return run_repack(repack_args),
108            PackCommand::Verify(verify_args) => return run_verify(verify_args),
109        }
110    }
111    let Some(entrypoint) = args.entrypoint.clone() else {
112        command_error("harn pack requires an entrypoint or a subcommand (see `harn pack --help`)");
113    };
114    let build_args = BuildArgs {
115        entrypoint,
116        out: args.out,
117        upgrade: args.upgrade,
118        sign: args.sign,
119        key: args.key,
120        unsigned: args.unsigned,
121        exclude_secrets: args.exclude_secrets,
122        json: args.json,
123    };
124    match build(&build_args) {
125        Ok(outcome) => {
126            if build_args.json {
127                let envelope = PackJsonOutput {
128                    data: outcome.json,
129                    warnings: outcome.warnings,
130                }
131                .into_envelope();
132                println!("{}", to_string_pretty(&envelope));
133            } else {
134                for warning in &outcome.warnings {
135                    eprintln!("warning[{}]: {}", warning.code, warning.message);
136                }
137                println!(
138                    "wrote {} ({} bytes, bundle_hash {})",
139                    outcome.output_path.display(),
140                    outcome.size_bytes,
141                    outcome.bundle_hash
142                );
143            }
144        }
145        Err(err) => {
146            if build_args.json {
147                let envelope: JsonEnvelope<PackJsonData> =
148                    JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
149                println!("{}", to_string_pretty(&envelope));
150                process::exit(1);
151            }
152            command_error(&err.message);
153        }
154    }
155}
156
157/// Programmatic entrypoint used by tests and other CLI command code
158/// that needs the JSON envelope without going through stdout.
159pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
160    let Some(entrypoint) = args.entrypoint.clone() else {
161        return JsonEnvelope::err(
162            PACK_SCHEMA_VERSION,
163            "pack.missing_entrypoint",
164            "harn pack requires an entrypoint or a subcommand".to_string(),
165        );
166    };
167    let build_args = BuildArgs {
168        entrypoint,
169        out: args.out.clone(),
170        upgrade: args.upgrade.clone(),
171        sign: args.sign,
172        key: args.key.clone(),
173        unsigned: args.unsigned,
174        exclude_secrets: args.exclude_secrets,
175        json: args.json,
176    };
177    match build(&build_args) {
178        Ok(outcome) => PackJsonOutput {
179            data: outcome.json,
180            warnings: outcome.warnings,
181        }
182        .into_envelope(),
183        Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
184    }
185}
186
187/// Plain-data input to [`build`]: a flattened copy of [`PackArgs`]
188/// without the subcommand surface. Tests can construct this directly
189/// instead of going through the CLI parser.
190#[derive(Debug, Clone)]
191pub struct BuildArgs {
192    pub entrypoint: PathBuf,
193    pub out: Option<PathBuf>,
194    pub upgrade: Option<PathBuf>,
195    pub sign: bool,
196    pub key: Option<PathBuf>,
197    pub unsigned: bool,
198    pub exclude_secrets: bool,
199    pub json: bool,
200}
201
202pub fn json_schema() -> serde_json::Value {
203    serde_json::json!({
204        "$schema": "https://json-schema.org/draft/2020-12/schema",
205        "title": "harn pack --json",
206        "type": "object",
207        "required": ["schemaVersion", "ok", "data", "warnings"],
208        "properties": {
209            "schemaVersion": { "const": PACK_SCHEMA_VERSION },
210            "ok": { "const": true },
211            "warnings": { "type": "array" },
212            "data": {
213                "type": "object",
214                "required": [
215                    "bundle_hash",
216                    "output_path",
217                    "size_bytes",
218                    "signature",
219                    "sbom_summary",
220                    "debug_symbol_metadata",
221                    "manifest"
222                ],
223                "properties": {
224                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
225                    "output_path": { "type": "string", "minLength": 1 },
226                    "size_bytes": { "type": "integer", "minimum": 1 },
227                    "signature": {
228                        "type": "object",
229                        "required": ["algorithm", "key_id", "present"],
230                        "properties": {
231                            "algorithm": { "const": "ed25519" },
232                            "key_id": { "type": ["string", "null"] },
233                            "present": { "type": "boolean" }
234                        }
235                    },
236                    "sbom_summary": {
237                        "type": "object",
238                        "required": ["components", "stdlib_modules", "providers", "tools"],
239                        "properties": {
240                            "components": { "type": "integer", "minimum": 1 },
241                            "stdlib_modules": { "type": "integer", "minimum": 0 },
242                            "providers": { "type": "integer", "minimum": 0 },
243                            "tools": { "type": "integer", "minimum": 0 }
244                        }
245                    },
246                    "debug_symbol_metadata": {
247                        "type": "object",
248                        "required": ["harnbc_count", "total_bytes"],
249                        "properties": {
250                            "harnbc_count": { "type": "integer", "minimum": 1 },
251                            "total_bytes": { "type": "integer", "minimum": 1 }
252                        }
253                    },
254                    "manifest": { "type": "object" }
255                }
256            }
257        }
258    })
259}
260
261/// Outcome of [`build`]. Used by tests; the dispatcher consumes it
262/// directly via [`run`].
263#[derive(Debug)]
264pub struct PackOutcome {
265    pub bundle_hash: String,
266    pub output_path: PathBuf,
267    pub size_bytes: u64,
268    pub json: PackJsonData,
269    pub warnings: Vec<JsonWarning>,
270}
271
272#[derive(Debug)]
273pub struct PackError {
274    pub code: &'static str,
275    pub message: String,
276}
277
278impl std::fmt::Display for PackError {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        write!(f, "{}: {}", self.code, self.message)
281    }
282}
283
284impl std::error::Error for PackError {}
285
286impl PackError {
287    fn new(code: &'static str, message: impl Into<String>) -> Self {
288        Self {
289            code,
290            message: message.into(),
291        }
292    }
293}
294
295#[derive(Debug)]
296pub struct PackUnpackOutcome {
297    pub output_dir: PathBuf,
298    pub content_entry_count: usize,
299}
300
301#[derive(Debug)]
302pub struct PackRepackOutcome {
303    pub output_path: PathBuf,
304    pub size_bytes: u64,
305    pub content_entry_count: usize,
306}
307
308pub fn run_unpack(args: PackUnpackArgs) {
309    match unpack(&args) {
310        Ok(outcome) => {
311            println!(
312                "unpacked {} to {} ({} payload entries)",
313                args.bundle.display(),
314                outcome.output_dir.display(),
315                outcome.content_entry_count
316            );
317        }
318        Err(err) => command_error(&err.message),
319    }
320}
321
322pub fn run_repack(args: PackRepackArgs) {
323    match repack(&args) {
324        Ok(outcome) => {
325            println!(
326                "repacked {} to {} ({} payload entries, {} bytes)",
327                args.dir.display(),
328                outcome.output_path.display(),
329                outcome.content_entry_count,
330                outcome.size_bytes
331            );
332        }
333        Err(err) => command_error(&err.message),
334    }
335}
336
337pub fn unpack(args: &PackUnpackArgs) -> Result<PackUnpackOutcome, PackError> {
338    let bytes = fs::read(&args.bundle).map_err(|err| {
339        PackError::new(
340            "unpack.read_failed",
341            format!("failed to read {}: {err}", args.bundle.display()),
342        )
343    })?;
344    let archive = read_harnpack(&bytes).map_err(|err| {
345        PackError::new(
346            "unpack.archive_failed",
347            format!("failed to parse {}: {err}", args.bundle.display()),
348        )
349    })?;
350    prepare_unpack_dir(&args.out, args.force)?;
351
352    let manifest_bytes = serde_json::to_vec_pretty(&archive.manifest).map_err(|err| {
353        PackError::new(
354            "unpack.manifest_failed",
355            format!("failed to encode {HARNPACK_MANIFEST_PATH}: {err}"),
356        )
357    })?;
358    write_unpack_file(
359        &args.out,
360        Path::new(HARNPACK_MANIFEST_PATH),
361        &manifest_bytes,
362        DEFAULT_PACK_FILE_MODE,
363    )?;
364    for entry in &archive.contents {
365        write_unpack_file(&args.out, &entry.path, &entry.bytes, entry.mode)?;
366    }
367
368    Ok(PackUnpackOutcome {
369        output_dir: args.out.clone(),
370        content_entry_count: archive.contents.len(),
371    })
372}
373
374pub fn repack(args: &PackRepackArgs) -> Result<PackRepackOutcome, PackError> {
375    if !args.dir.is_dir() {
376        return Err(PackError::new(
377            "repack.input_not_directory",
378            format!("input is not a directory: {}", args.dir.display()),
379        ));
380    }
381    reject_repack_output_inside_input(&args.dir, &args.out)?;
382    if args.out.exists() && !args.force {
383        return Err(PackError::new(
384            "repack.output_exists",
385            format!(
386                "output path already exists: {} (re-run with --force to replace it)",
387                args.out.display()
388            ),
389        ));
390    }
391    if args.out.is_dir() {
392        return Err(PackError::new(
393            "repack.output_is_directory",
394            format!("output path is a directory: {}", args.out.display()),
395        ));
396    }
397
398    let manifest_path = args.dir.join(HARNPACK_MANIFEST_PATH);
399    let manifest = load_workflow_bundle_any_version(&manifest_path).map_err(|err| {
400        PackError::new(
401            "repack.manifest_failed",
402            format!("failed to read {}: {err}", manifest_path.display()),
403        )
404    })?;
405    let contents = collect_repack_entries(&args.dir)?;
406    let archive_bytes = build_harnpack(&manifest, &contents).map_err(|err| {
407        PackError::new(
408            "repack.archive_failed",
409            format!("failed to assemble {}: {err}", args.out.display()),
410        )
411    })?;
412    if let Some(parent) = args
413        .out
414        .parent()
415        .filter(|parent| !parent.as_os_str().is_empty())
416    {
417        fs::create_dir_all(parent).map_err(|err| {
418            PackError::new(
419                "repack.write_failed",
420                format!("failed to create {}: {err}", parent.display()),
421            )
422        })?;
423    }
424    fs::write(&args.out, &archive_bytes).map_err(|err| {
425        PackError::new(
426            "repack.write_failed",
427            format!("failed to write {}: {err}", args.out.display()),
428        )
429    })?;
430
431    Ok(PackRepackOutcome {
432        output_path: args.out.clone(),
433        size_bytes: archive_bytes.len() as u64,
434        content_entry_count: contents.len(),
435    })
436}
437
438fn prepare_unpack_dir(out: &Path, force: bool) -> Result<(), PackError> {
439    if out.exists() {
440        if !force {
441            return Err(PackError::new(
442                "unpack.output_exists",
443                format!(
444                    "output path already exists: {} (re-run with --force to replace it)",
445                    out.display()
446                ),
447            ));
448        }
449        let metadata = fs::symlink_metadata(out).map_err(|err| {
450            PackError::new(
451                "unpack.read_failed",
452                format!("failed to stat {}: {err}", out.display()),
453            )
454        })?;
455        if metadata.file_type().is_symlink() {
456            return Err(PackError::new(
457                "unpack.output_symlink",
458                format!("refusing to replace symlink {}", out.display()),
459            ));
460        }
461        if metadata.is_dir() {
462            require_prior_unpack_dir(out)?;
463            fs::remove_dir_all(out).map_err(|err| {
464                PackError::new(
465                    "unpack.remove_failed",
466                    format!("failed to remove {}: {err}", out.display()),
467                )
468            })?;
469        } else if metadata.is_file() {
470            fs::remove_file(out).map_err(|err| {
471                PackError::new(
472                    "unpack.remove_failed",
473                    format!("failed to remove {}: {err}", out.display()),
474                )
475            })?;
476        } else {
477            return Err(PackError::new(
478                "unpack.output_unsupported",
479                format!("refusing to replace non-file output path {}", out.display()),
480            ));
481        }
482    }
483    fs::create_dir_all(out).map_err(|err| {
484        PackError::new(
485            "unpack.write_failed",
486            format!("failed to create {}: {err}", out.display()),
487        )
488    })
489}
490
491fn require_prior_unpack_dir(out: &Path) -> Result<(), PackError> {
492    if is_current_dir(out) {
493        return Err(PackError::new(
494            "unpack.output_unsafe",
495            format!("refusing to replace current directory {}", out.display()),
496        ));
497    }
498    let manifest = out.join(HARNPACK_MANIFEST_PATH);
499    if manifest.is_file() {
500        return Ok(());
501    }
502    Err(PackError::new(
503        "unpack.output_not_harnpack_dir",
504        format!(
505            "refusing to remove {} because it does not contain {}; choose a fresh --out dir or remove it manually",
506            out.display(),
507            HARNPACK_MANIFEST_PATH
508        ),
509    ))
510}
511
512fn is_current_dir(path: &Path) -> bool {
513    let Ok(path) = path.canonicalize() else {
514        return false;
515    };
516    let Ok(cwd) = env::current_dir().and_then(|cwd| cwd.canonicalize()) else {
517        return false;
518    };
519    path == cwd
520}
521
522fn write_unpack_file(
523    root: &Path,
524    archive_path: &Path,
525    bytes: &[u8],
526    mode: u32,
527) -> Result<(), PackError> {
528    let safe_path = normalize_safe_archive_path(archive_path)?;
529    let destination = root.join(&safe_path);
530    if let Some(parent) = destination.parent() {
531        fs::create_dir_all(parent).map_err(|err| {
532            PackError::new(
533                "unpack.write_failed",
534                format!("failed to create {}: {err}", parent.display()),
535            )
536        })?;
537    }
538    fs::write(&destination, bytes).map_err(|err| {
539        PackError::new(
540            "unpack.write_failed",
541            format!("failed to write {}: {err}", destination.display()),
542        )
543    })?;
544    set_file_mode(&destination, mode)?;
545    Ok(())
546}
547
548fn collect_repack_entries(root: &Path) -> Result<Vec<HarnpackEntry>, PackError> {
549    let mut entries = Vec::new();
550    collect_repack_entries_inner(root, root, &mut entries)?;
551    entries.sort_by(|left, right| left.path.cmp(&right.path));
552    Ok(entries)
553}
554
555fn reject_repack_output_inside_input(input_dir: &Path, out: &Path) -> Result<(), PackError> {
556    let input = input_dir.canonicalize().map_err(|err| {
557        PackError::new(
558            "repack.read_failed",
559            format!("failed to canonicalize {}: {err}", input_dir.display()),
560        )
561    })?;
562    let out_parent = out
563        .parent()
564        .filter(|parent| !parent.as_os_str().is_empty())
565        .unwrap_or_else(|| Path::new("."));
566    let out_file_name = out.file_name().ok_or_else(|| {
567        PackError::new(
568            "repack.output_invalid",
569            format!("output path must include a file name: {}", out.display()),
570        )
571    })?;
572    let out_parent = out_parent.canonicalize().unwrap_or_else(|_| {
573        if out_parent.is_absolute() {
574            out_parent.to_path_buf()
575        } else {
576            env::current_dir()
577                .unwrap_or_else(|_| PathBuf::from("."))
578                .join(out_parent)
579        }
580    });
581    let output = out_parent.join(out_file_name);
582    if output.starts_with(&input) {
583        return Err(PackError::new(
584            "repack.output_inside_input",
585            format!(
586                "refusing to write {} inside input directory {}; choose an output path outside the unpacked tree",
587                out.display(),
588                input_dir.display()
589            ),
590        ));
591    }
592    Ok(())
593}
594
595fn collect_repack_entries_inner(
596    root: &Path,
597    current: &Path,
598    entries: &mut Vec<HarnpackEntry>,
599) -> Result<(), PackError> {
600    let mut children = fs::read_dir(current)
601        .map_err(|err| {
602            PackError::new(
603                "repack.read_failed",
604                format!("failed to read {}: {err}", current.display()),
605            )
606        })?
607        .collect::<Result<Vec<_>, _>>()
608        .map_err(|err| {
609            PackError::new(
610                "repack.read_failed",
611                format!("failed to read {}: {err}", current.display()),
612            )
613        })?;
614    children.sort_by_key(|entry| entry.path());
615
616    for child in children {
617        let path = child.path();
618        let metadata = fs::symlink_metadata(&path).map_err(|err| {
619            PackError::new(
620                "repack.read_failed",
621                format!("failed to stat {}: {err}", path.display()),
622            )
623        })?;
624        if metadata.file_type().is_symlink() {
625            return Err(PackError::new(
626                "repack.unsupported_entry",
627                format!("refusing to pack symlink {}", path.display()),
628            ));
629        }
630        if metadata.is_dir() {
631            collect_repack_entries_inner(root, &path, entries)?;
632            continue;
633        }
634        if !metadata.is_file() {
635            return Err(PackError::new(
636                "repack.unsupported_entry",
637                format!("refusing to pack non-file entry {}", path.display()),
638            ));
639        }
640
641        let rel = path.strip_prefix(root).map_err(|err| {
642            PackError::new(
643                "repack.path_failed",
644                format!(
645                    "failed to relativize {} against {}: {err}",
646                    path.display(),
647                    root.display()
648                ),
649            )
650        })?;
651        let archive_path = normalize_safe_archive_path(rel)?;
652        if archive_path == Path::new(HARNPACK_MANIFEST_PATH) {
653            continue;
654        }
655        let bytes = fs::read(&path).map_err(|err| {
656            PackError::new(
657                "repack.read_failed",
658                format!("failed to read {}: {err}", path.display()),
659            )
660        })?;
661        entries.push(HarnpackEntry::new(archive_path, bytes).with_mode(file_mode(&metadata)));
662    }
663    Ok(())
664}
665
666fn normalize_safe_archive_path(path: &Path) -> Result<PathBuf, PackError> {
667    let mut normalized = PathBuf::new();
668    for component in path.components() {
669        match component {
670            Component::Normal(part) => normalized.push(part),
671            Component::CurDir => {}
672            Component::ParentDir => {
673                return Err(PackError::new(
674                    "pack.unsafe_archive_path",
675                    format!("archive path may not contain '..': {}", path.display()),
676                ));
677            }
678            Component::Prefix(_) | Component::RootDir => {
679                return Err(PackError::new(
680                    "pack.unsafe_archive_path",
681                    format!("archive path must be relative: {}", path.display()),
682                ));
683            }
684        }
685    }
686    if normalized.as_os_str().is_empty() {
687        return Err(PackError::new(
688            "pack.unsafe_archive_path",
689            "archive path may not be empty",
690        ));
691    }
692    Ok(normalized)
693}
694
695#[cfg(unix)]
696fn file_mode(metadata: &fs::Metadata) -> u32 {
697    use std::os::unix::fs::PermissionsExt;
698
699    metadata.permissions().mode() & 0o7777
700}
701
702#[cfg(not(unix))]
703fn file_mode(_metadata: &fs::Metadata) -> u32 {
704    DEFAULT_PACK_FILE_MODE
705}
706
707#[cfg(unix)]
708fn set_file_mode(path: &Path, mode: u32) -> Result<(), PackError> {
709    use std::os::unix::fs::PermissionsExt;
710
711    fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|err| {
712        PackError::new(
713            "unpack.write_failed",
714            format!("failed to set permissions on {}: {err}", path.display()),
715        )
716    })
717}
718
719#[cfg(not(unix))]
720fn set_file_mode(_path: &Path, _mode: u32) -> Result<(), PackError> {
721    Ok(())
722}
723
724pub fn build(args: &BuildArgs) -> Result<PackOutcome, PackError> {
725    if args.sign && args.unsigned {
726        return Err(PackError::new(
727            "pack.sign_conflict",
728            "--sign and --unsigned cannot be used together",
729        ));
730    }
731    if args.sign && args.key.is_none() {
732        return Err(PackError::new(
733            "pack.sign_missing_key",
734            "--sign requires --key <path>",
735        ));
736    }
737    if !args.sign && args.key.is_some() {
738        return Err(PackError::new(
739            "pack.key_without_sign",
740            "--key requires --sign",
741        ));
742    }
743    if let Some(upgrade) = &args.upgrade {
744        if !upgrade.exists() {
745            return Err(PackError::new(
746                "upgrade.not_found",
747                format!(
748                    "--upgrade source bundle does not exist: {}",
749                    upgrade.display()
750                ),
751            ));
752        }
753    }
754    let entrypoint_input = args.entrypoint.clone();
755    let entrypoint = entrypoint_input
756        .canonicalize()
757        .unwrap_or_else(|_| entrypoint_input.clone());
758    if !entrypoint.exists() {
759        return Err(PackError::new(
760            "entrypoint.not_found",
761            format!("entrypoint does not exist: {}", entrypoint_input.display()),
762        ));
763    }
764    if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
765    {
766        return Err(PackError::new(
767            "entrypoint.invalid",
768            format!(
769                "entrypoint must be a .harn file: {}",
770                entrypoint_input.display()
771            ),
772        ));
773    }
774    if args.exclude_secrets && path_looks_like_secret(&entrypoint) {
775        return Err(PackError::new(
776            "pack.secret_blocked",
777            format!(
778                "entrypoint {} matches a secret-bearing path pattern; \
779                 re-run with --include-secrets to override",
780                entrypoint_input.display()
781            ),
782        ));
783    }
784    let project_root = pack_archive_root(&entrypoint);
785    let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
786        PackError::new(
787            "entrypoint.outside_root",
788            format!(
789                "entrypoint {} could not be relativized against {}",
790                entrypoint.display(),
791                project_root.display()
792            ),
793        )
794    })?;
795    let entrypoint_id = logical_bundle_path(&entrypoint_rel);
796
797    let prior = match &args.upgrade {
798        Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
799            PackError::new(
800                "upgrade.read_failed",
801                format!("failed to read --upgrade source {}: {err}", path.display()),
802            )
803        })?),
804        None => None,
805    };
806
807    let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
808    let mut graph_paths = graph.module_paths();
809    // The entrypoint is always present in the graph; ensure deterministic order.
810    graph_paths.sort();
811    let mut module_paths: Vec<PathBuf> = graph_paths
812        .iter()
813        .filter(|path| is_harn_module_path(path))
814        .cloned()
815        .collect();
816    module_paths.sort();
817
818    let mut transitive_modules = Vec::new();
819    let mut contents = Vec::new();
820    let mut sbom_packages = Vec::new();
821    let mut sbom_relationships = Vec::new();
822    let mut warnings = Vec::new();
823    let mut skipped_assets = Vec::new();
824    let mut debug_symbol_metadata = PackDebugSymbolMetadata {
825        harnbc_count: 0,
826        total_bytes: 0,
827    };
828
829    let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
830    let harn_version = bytecode_cache::HARN_VERSION.to_string();
831
832    sbom_packages.push(SBOMPackage {
833        name: "harn-stdlib".to_string(),
834        version: Some(stdlib_version.clone()),
835        package_hash_blake3: None,
836        license: None,
837    });
838
839    for module_path in &module_paths {
840        let module_str = module_path.to_string_lossy().to_string();
841        if module_str.starts_with("<std>/") {
842            let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
843            sbom_packages.push(SBOMPackage {
844                name: format!("std/{stdlib_name}"),
845                version: Some(stdlib_version.clone()),
846                package_hash_blake3: None,
847                license: None,
848            });
849            sbom_relationships.push(SBOMRelationship {
850                from: format!("entrypoint:{entrypoint_id}"),
851                to: format!("std/{stdlib_name}"),
852                relationship_type: "depends_on".to_string(),
853            });
854            continue;
855        }
856
857        let source = std::fs::read_to_string(module_path).map_err(|err| {
858            PackError::new(
859                "module.read_failed",
860                format!("failed to read {}: {err}", module_path.display()),
861            )
862        })?;
863
864        let (parsed_source, program) = parse_source_file(&module_str);
865        debug_assert_eq!(parsed_source, source);
866        type_check_or_fail(&source, &module_str, &program)?;
867
868        let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
869            PackError::new(
870                "module.compile_failed",
871                format!("compile error in {}: {err}", module_path.display()),
872            )
873        })?;
874
875        let module_artifact_opt =
876            module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
877
878        let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
879        let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
880            .map_err(|err| {
881                PackError::new(
882                    "module.serialize_failed",
883                    format!(
884                        "failed to serialize chunk for {}: {err}",
885                        module_path.display()
886                    ),
887                )
888            })?;
889
890        let module_artifact_bytes = match module_artifact_opt.as_ref() {
891            Some(artifact) => Some(
892                bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
893                    PackError::new(
894                        "module.serialize_failed",
895                        format!(
896                            "failed to serialize module artifact for {}: {err}",
897                            module_path.display()
898                        ),
899                    )
900                })?,
901            ),
902            None => None,
903        };
904
905        let rel = relativize(&project_root, module_path).ok_or_else(|| {
906            PackError::new(
907                "module.outside_root",
908                format!(
909                    "module {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
910                    module_path.display(),
911                    project_root.display()
912                ),
913            )
914        })?;
915        let source_archive_path = PathBuf::from("sources").join(&rel);
916        let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
917            .ok_or_else(|| {
918                PackError::new(
919                    "module.invalid_path",
920                    format!("module path has no stem: {}", module_path.display()),
921                )
922            })?;
923        let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
924
925        let source_hash = blake3_hash(source.as_bytes());
926        let harnbc_hash = blake3_hash(&chunk_bytes);
927        debug_symbol_metadata.harnbc_count += 1;
928        debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
929
930        transitive_modules.push(ModuleEntry {
931            path: rel.clone(),
932            source_hash_blake3: source_hash.clone(),
933            harnbc_hash_blake3: harnbc_hash.clone(),
934        });
935
936        contents.push(HarnpackEntry::new(
937            source_archive_path,
938            source.as_bytes().to_vec(),
939        ));
940        contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
941        if let Some(artifact_bytes) = module_artifact_bytes {
942            debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
943            let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
944                .ok_or_else(|| {
945                    PackError::new(
946                        "module.invalid_path",
947                        format!("module path has no stem: {}", module_path.display()),
948                    )
949                })?;
950            let module_archive_path = PathBuf::from("bytecode").join(module_rel);
951            contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
952        }
953
954        let module_id = logical_bundle_path(&rel);
955        if module_path != &entrypoint {
956            sbom_relationships.push(SBOMRelationship {
957                from: format!("entrypoint:{entrypoint_id}"),
958                to: format!("module:{module_id}"),
959                relationship_type: "depends_on".to_string(),
960            });
961        }
962        sbom_packages.push(SBOMPackage {
963            name: format!("module:{module_id}"),
964            version: Some(harn_version.clone()),
965            package_hash_blake3: Some(source_hash),
966            license: None,
967        });
968    }
969
970    for asset in discover_import_assets(&graph, &module_paths, &project_root)? {
971        if args.exclude_secrets && path_looks_like_secret(&asset.path) {
972            warnings.push(JsonWarning {
973                code: "pack.asset_skipped_secret".to_string(),
974                message: format!(
975                    "skipped imported asset {} because it matches a secret-bearing path pattern",
976                    asset.rel.display()
977                ),
978            });
979            skipped_assets.push(SkippedAsset {
980                path: logical_bundle_path(&asset.rel),
981                reason: "secret_path".to_string(),
982            });
983            continue;
984        }
985
986        let bytes = std::fs::read(&asset.path).map_err(|err| {
987            PackError::new(
988                "asset.read_failed",
989                format!(
990                    "failed to read imported asset {}: {err}",
991                    asset.path.display()
992                ),
993            )
994        })?;
995        let asset_hash = blake3_hash(&bytes);
996        let asset_id = logical_bundle_path(&asset.rel);
997        contents.push(HarnpackEntry::new(
998            PathBuf::from("sources").join(&asset.rel),
999            bytes,
1000        ));
1001        sbom_packages.push(SBOMPackage {
1002            name: format!("asset:{asset_id}"),
1003            version: Some(harn_version.clone()),
1004            package_hash_blake3: Some(asset_hash),
1005            license: None,
1006        });
1007        sbom_relationships.push(SBOMRelationship {
1008            from: format!("entrypoint:{entrypoint_id}"),
1009            to: format!("asset:{asset_id}"),
1010            relationship_type: "depends_on".to_string(),
1011        });
1012    }
1013
1014    if transitive_modules.is_empty() {
1015        return Err(PackError::new(
1016            "pack.no_modules",
1017            format!(
1018                "no Harn modules resolved from entrypoint {}",
1019                entrypoint.display()
1020            ),
1021        ));
1022    }
1023
1024    let provider_catalog = harn_vm::provider_catalog::artifact();
1025    let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
1026        PackError::new(
1027            "provider_catalog.failed",
1028            format!("failed to serialize provider catalog snapshot: {err}"),
1029        )
1030    })?;
1031    let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
1032    sbom_packages.push(SBOMPackage {
1033        name: "harn-provider-catalog".to_string(),
1034        version: Some(harn_version.clone()),
1035        package_hash_blake3: Some(provider_catalog_hash.clone()),
1036        license: None,
1037    });
1038    sbom_relationships.push(SBOMRelationship {
1039        from: format!("entrypoint:{entrypoint_id}"),
1040        to: "harn-provider-catalog".to_string(),
1041        relationship_type: "depends_on".to_string(),
1042    });
1043    for provider in &provider_catalog.providers {
1044        let provider_name = format!("provider:{}", provider.id);
1045        sbom_packages.push(SBOMPackage {
1046            name: provider_name.clone(),
1047            version: None,
1048            package_hash_blake3: None,
1049            license: None,
1050        });
1051        sbom_relationships.push(SBOMRelationship {
1052            from: "harn-provider-catalog".to_string(),
1053            to: provider_name,
1054            relationship_type: "contains".to_string(),
1055        });
1056    }
1057
1058    // Tool entries use the same manifest/SBOM path as modules and
1059    // providers, keeping the archive representation centralized.
1060    let tool_manifest: Vec<ToolEntry> = Vec::new();
1061    for tool in &tool_manifest {
1062        sbom_packages.push(SBOMPackage {
1063            name: format!("tool:{}", tool.name),
1064            version: None,
1065            package_hash_blake3: tool.schema_hash_blake3.clone(),
1066            license: None,
1067        });
1068        sbom_relationships.push(SBOMRelationship {
1069            from: format!("entrypoint:{entrypoint_id}"),
1070            to: format!("tool:{}", tool.name),
1071            relationship_type: "depends_on".to_string(),
1072        });
1073    }
1074    let mut bundle = assemble_bundle(
1075        &entrypoint_rel,
1076        transitive_modules,
1077        stdlib_version,
1078        harn_version,
1079        provider_catalog_hash,
1080        tool_manifest,
1081        SBOMDoc {
1082            format: "spdx-lite".to_string(),
1083            version: "2.3".to_string(),
1084            packages: sbom_packages,
1085            relationships: sbom_relationships,
1086        },
1087        prior.as_ref(),
1088    );
1089    if !skipped_assets.is_empty() {
1090        bundle.metadata.insert(
1091            "skipped_assets".to_string(),
1092            serde_json::to_value(&skipped_assets).map_err(|err| {
1093                PackError::new(
1094                    "pack.metadata_failed",
1095                    format!("failed to render skipped asset metadata: {err}"),
1096                )
1097            })?,
1098        );
1099    }
1100    // Carry host-surface extension data — the `[[contributes]]` block plus
1101    // package identity/permissions — into the signed bundle's metadata so a
1102    // host (e.g. an IDE) can discover and gate contributions from the verified
1103    // artifact alone, with no separate descriptor. Harn stays agnostic about
1104    // the kind-specific payload; it only ferries it. See `docs` and the
1105    // `ContributionEntry` schema.
1106    // Also bundle files referenced by the `[[contributes]]` block (preview
1107    // HTML, theme JSON, canon dir, skill, …) so an imported pack is
1108    // self-contained for the host.
1109    contents.extend(carry_extension_metadata(&project_root, &mut bundle)?);
1110    sort_sbom_doc(&mut bundle.sbom);
1111    let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
1112        PackError::new(
1113            "pack.sbom_failed",
1114            format!("failed to render SBOM document: {err}"),
1115        )
1116    })?;
1117    contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
1118
1119    if args.sign {
1120        let key_path = args.key.as_ref().expect("checked above");
1121        sign_bundle(&mut bundle, &contents, key_path)?;
1122    }
1123
1124    let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
1125        PackError::new(
1126            "pack.hash_failed",
1127            format!("failed to compute bundle hash: {err}"),
1128        )
1129    })?;
1130    let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
1131        PackError::new(
1132            "pack.archive_failed",
1133            format!("failed to assemble .harnpack archive: {err}"),
1134        )
1135    })?;
1136
1137    let output_path = resolve_output_path(&args.out, &entrypoint);
1138    if let Some(parent) = output_path.parent() {
1139        if !parent.as_os_str().is_empty() {
1140            std::fs::create_dir_all(parent).map_err(|err| {
1141                PackError::new(
1142                    "pack.output_dir_failed",
1143                    format!("failed to create output dir {}: {err}", parent.display()),
1144                )
1145            })?;
1146        }
1147    }
1148    std::fs::write(&output_path, &archive_bytes).map_err(|err| {
1149        PackError::new(
1150            "pack.write_failed",
1151            format!("failed to write {}: {err}", output_path.display()),
1152        )
1153    })?;
1154    let size_bytes = archive_bytes.len() as u64;
1155    emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
1156
1157    Ok(PackOutcome {
1158        bundle_hash: bundle_hash.clone(),
1159        output_path: output_path.clone(),
1160        size_bytes,
1161        json: PackJsonData {
1162            bundle_hash,
1163            output_path,
1164            size_bytes,
1165            signature: signature_summary(&bundle),
1166            sbom_summary: sbom_summary(&bundle),
1167            debug_symbol_metadata,
1168            manifest: bundle,
1169        },
1170        warnings,
1171    })
1172}
1173
1174fn sign_bundle(
1175    bundle: &mut WorkflowBundle,
1176    contents: &[HarnpackEntry],
1177    key_path: &Path,
1178) -> Result<(), PackError> {
1179    let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
1180        PackError::new(
1181            "pack.sign_key_failed",
1182            format!("failed to load signing key {}: {err}", key_path.display()),
1183        )
1184    })?;
1185    let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
1186        PackError::new(
1187            "pack.hash_failed",
1188            format!("failed to compute bundle hash before signing: {err}"),
1189        )
1190    })?;
1191    let verifying_key = signing_key.verifying_key();
1192    let signature = signing_key.sign(bundle_hash.as_bytes());
1193    bundle.signature = Some(Ed25519Signature {
1194        key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
1195        public_key: hex::encode(verifying_key.to_bytes()),
1196        signature: hex::encode(signature.to_bytes()),
1197        manifest_hash_blake3: bundle_hash,
1198        algorithm: "ed25519".to_string(),
1199    });
1200    Ok(())
1201}
1202
1203fn emit_release_trust_record(
1204    project_root: &Path,
1205    bundle_hash: &str,
1206    harn_version: &str,
1207    signed: bool,
1208) -> Result<TrustRecord, PackError> {
1209    let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
1210        PackError::new(
1211            "pack.trust_log_failed",
1212            format!(
1213                "failed to open OpenTrustGraph event log under {}: {err}",
1214                project_root.display()
1215            ),
1216        )
1217    })?;
1218    let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
1219        &log,
1220        &harn_vm::TrustQueryFilters::default(),
1221    ))
1222    .map_err(|err| {
1223        PackError::new(
1224            "pack.trust_query_failed",
1225            format!("failed to query prior OpenTrustGraph records: {err}"),
1226        )
1227    })?
1228    .last()
1229    .map(|record| record.record_id.clone());
1230    let mut record = TrustRecord::release(
1231        std::env::var("USER")
1232            .ok()
1233            .filter(|value| !value.trim().is_empty())
1234            .unwrap_or_else(|| "harn-pack".to_string()),
1235        bundle_hash.to_string(),
1236        harn_version.to_string(),
1237        parent_trust_record_id,
1238        format!("harnpack-release-{}", uuid::Uuid::now_v7()),
1239        if signed {
1240            AutonomyTier::ActAuto
1241        } else {
1242            AutonomyTier::Suggest
1243        },
1244    );
1245    record
1246        .metadata
1247        .insert("signed".to_string(), serde_json::json!(signed));
1248    futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
1249        PackError::new(
1250            "pack.trust_record_failed",
1251            format!("failed to append OpenTrustGraph release record: {err}"),
1252        )
1253    })
1254}
1255
1256fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
1257    match &bundle.signature {
1258        Some(signature) => PackSignatureSummary {
1259            algorithm: signature.algorithm.clone(),
1260            key_id: signature.key_id.clone(),
1261            present: true,
1262        },
1263        None => PackSignatureSummary {
1264            algorithm: "ed25519".to_string(),
1265            key_id: None,
1266            present: false,
1267        },
1268    }
1269}
1270
1271fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
1272    let stdlib_modules = bundle
1273        .sbom
1274        .packages
1275        .iter()
1276        .filter(|package| package.name.starts_with("std/"))
1277        .count();
1278    let providers = bundle
1279        .sbom
1280        .packages
1281        .iter()
1282        .filter(|package| package.name.starts_with("provider:"))
1283        .count();
1284    PackSbomSummary {
1285        components: bundle.sbom.packages.len(),
1286        stdlib_modules,
1287        providers,
1288        tools: bundle.tool_manifest.len(),
1289    }
1290}
1291
1292#[derive(Debug)]
1293struct ImportedAsset {
1294    path: PathBuf,
1295    rel: PathBuf,
1296}
1297
1298#[derive(Debug, Serialize, Deserialize)]
1299struct SkippedAsset {
1300    path: String,
1301    reason: String,
1302}
1303
1304fn discover_import_assets(
1305    graph: &harn_modules::ModuleGraph,
1306    module_paths: &[PathBuf],
1307    project_root: &Path,
1308) -> Result<Vec<ImportedAsset>, PackError> {
1309    let mut assets = BTreeMap::<PathBuf, ImportedAsset>::new();
1310    for module_path in module_paths {
1311        if module_path.to_string_lossy().starts_with("<std>/") {
1312            continue;
1313        }
1314        for import in graph.imports_for_module(module_path) {
1315            let Some(resolved_path) = import.resolved_path else {
1316                continue;
1317            };
1318            if is_harn_module_path(&resolved_path) {
1319                continue;
1320            }
1321            let canonical = resolved_path
1322                .canonicalize()
1323                .unwrap_or_else(|_| resolved_path.clone());
1324            let rel = relativize(project_root, &canonical).ok_or_else(|| {
1325                PackError::new(
1326                    "asset.outside_root",
1327                    format!(
1328                        "imported asset {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
1329                        canonical.display(),
1330                        project_root.display()
1331                    ),
1332                )
1333            })?;
1334            assets.entry(canonical.clone()).or_insert(ImportedAsset {
1335                path: canonical,
1336                rel,
1337            });
1338        }
1339    }
1340    Ok(assets.into_values().collect())
1341}
1342
1343fn is_harn_module_path(path: &Path) -> bool {
1344    path.to_string_lossy().starts_with("<std>/")
1345        || path.extension().and_then(|ext| ext.to_str()) == Some("harn")
1346}
1347
1348fn sort_sbom_doc(sbom: &mut SBOMDoc) {
1349    sbom.packages.sort_by(|left, right| {
1350        (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
1351            &right.name,
1352            &right.version,
1353            &right.package_hash_blake3,
1354        ))
1355    });
1356    sbom.relationships.sort_by(|left, right| {
1357        (&left.from, &left.to, &left.relationship_type).cmp(&(
1358            &right.from,
1359            &right.to,
1360            &right.relationship_type,
1361        ))
1362    });
1363}
1364
1365fn assemble_bundle(
1366    entrypoint_rel: &Path,
1367    transitive_modules: Vec<ModuleEntry>,
1368    stdlib_version: String,
1369    harn_version: String,
1370    provider_catalog_hash: String,
1371    tool_manifest: Vec<ToolEntry>,
1372    sbom: SBOMDoc,
1373    prior: Option<&WorkflowBundle>,
1374) -> WorkflowBundle {
1375    let stem = entrypoint_rel
1376        .file_stem()
1377        .map(|s| s.to_string_lossy().into_owned())
1378        .unwrap_or_else(|| "harnpack".to_string());
1379
1380    let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
1381        id: stem.clone(),
1382        name: Some(stem.clone()),
1383        version: "0.0.0".to_string(),
1384        workflow: degenerate_workflow(&stem),
1385        triggers: vec![WorkflowBundleTrigger {
1386            id: "manual".to_string(),
1387            kind: "manual".to_string(),
1388            node_id: Some("entry".to_string()),
1389            ..WorkflowBundleTrigger::default()
1390        }],
1391        policy: WorkflowBundlePolicy {
1392            autonomy_tier: "act_with_approval".to_string(),
1393            tool_policy: BTreeMap::new(),
1394            approval_required: Vec::new(),
1395            retry: RetryPolicySpec {
1396                max_attempts: 1,
1397                backoff: "none".to_string(),
1398            },
1399            catchup: CatchupPolicySpec {
1400                mode: "none".to_string(),
1401                max_events: None,
1402            },
1403        },
1404        connectors: Vec::<ConnectorRequirement>::new(),
1405        environment: EnvironmentRequirements::default(),
1406        receipts: WorkflowBundleReplayMetadata::default(),
1407        ..WorkflowBundle::default()
1408    });
1409
1410    bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
1411    bundle.entrypoint = entrypoint_rel.to_path_buf();
1412    bundle.transitive_modules = transitive_modules;
1413    bundle.stdlib_version = stdlib_version;
1414    bundle.harn_version = harn_version;
1415    bundle.provider_catalog_hash = provider_catalog_hash;
1416    bundle.tool_manifest = tool_manifest;
1417    bundle.sbom = sbom;
1418    bundle.signature = None;
1419    bundle
1420}
1421
1422fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
1423    use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
1424    let mut nodes = BTreeMap::new();
1425    nodes.insert(
1426        "entry".to_string(),
1427        WorkflowNode {
1428            id: Some("entry".to_string()),
1429            kind: "action".to_string(),
1430            task_label: Some(stem.to_string()),
1431            ..WorkflowNode::default()
1432        },
1433    );
1434    WorkflowGraph {
1435        type_name: "workflow_graph".to_string(),
1436        id: format!("{stem}_pack"),
1437        name: Some(stem.to_string()),
1438        version: 1,
1439        entry: "entry".to_string(),
1440        nodes,
1441        ..WorkflowGraph::default()
1442    }
1443}
1444
1445fn type_check_or_fail(
1446    source: &str,
1447    path: &str,
1448    program: &[harn_parser::SNode],
1449) -> Result<(), PackError> {
1450    let mut had_error = false;
1451    let mut messages = String::new();
1452    for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
1453        let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
1454        if matches!(diag.severity, DiagnosticSeverity::Error) {
1455            had_error = true;
1456        }
1457        messages.push_str(&rendered);
1458    }
1459    if had_error {
1460        return Err(PackError::new(
1461            "module.type_error",
1462            format!("type errors in {path}:\n{messages}"),
1463        ));
1464    }
1465    if !messages.is_empty() {
1466        eprint!("{messages}");
1467    }
1468    Ok(())
1469}
1470
1471/// Carry the package's host-surface extension data into the signed bundle's
1472/// metadata. Reads the nearest `harn.toml`, serializing the `[[contributes]]`
1473/// block under the `contributes` key and package identity/permissions under
1474/// `extension`. No-op when there is no manifest, it fails to parse, or there
1475/// are no contributions — packing a plain workflow is unaffected.
1476fn carry_extension_metadata(
1477    project_root: &Path,
1478    bundle: &mut WorkflowBundle,
1479) -> Result<Vec<HarnpackEntry>, PackError> {
1480    let manifest_path = project_root.join("harn.toml");
1481    if !manifest_path.is_file() {
1482        return Ok(Vec::new());
1483    }
1484    let Ok(text) = std::fs::read_to_string(&manifest_path) else {
1485        return Ok(Vec::new());
1486    };
1487    let manifest: crate::package::Manifest = match toml::from_str(&text) {
1488        Ok(manifest) => manifest,
1489        Err(_) => return Ok(Vec::new()),
1490    };
1491    // Validate before sealing into the signed bundle so a malformed
1492    // `[[contributes]]` block fails the pack rather than shipping silently.
1493    crate::package::validate_contributions(&manifest)
1494        .map_err(|err| PackError::new("pack.invalid_contributes", err.message().to_string()))?;
1495    if !manifest.contributes.is_empty() {
1496        bundle.metadata.insert(
1497            "contributes".to_string(),
1498            serde_json::to_value(&manifest.contributes).map_err(|err| {
1499                PackError::new(
1500                    "pack.metadata_failed",
1501                    format!("failed to render contributions: {err}"),
1502                )
1503            })?,
1504        );
1505    }
1506    if let Some(pkg) = manifest.package.as_ref() {
1507        bundle.metadata.insert(
1508            "extension".to_string(),
1509            serde_json::json!({
1510                "name": pkg.name,
1511                "version": pkg.version,
1512                "publisher": pkg.publisher,
1513                "contact": pkg.contact,
1514                "created": pkg.created,
1515                "description": pkg.description,
1516                "license": pkg.license,
1517                "permissions": pkg.permissions,
1518                "host_requirements": pkg.host_requirements,
1519            }),
1520        );
1521    }
1522    collect_contribution_assets(project_root, &manifest.contributes)
1523}
1524
1525/// Bundle files referenced by `[[contributes]]` config (`entry`/`file`/`path`)
1526/// at their bundle-relative archive paths so a host can load them after import
1527/// (the preview HTML, theme JSON, canon dir, skill, …). `harn pack` otherwise
1528/// only walks the entrypoint's transitive imports, which never reach these.
1529/// Paths must stay inside the archive root; `..` escapes are skipped.
1530fn collect_contribution_assets(
1531    project_root: &Path,
1532    contributes: &[crate::package::ContributionEntry],
1533) -> Result<Vec<HarnpackEntry>, PackError> {
1534    const ASSET_KEYS: [&str; 3] = ["entry", "file", "path"];
1535    let mut seen: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1536    let mut entries: Vec<HarnpackEntry> = Vec::new();
1537    for contribution in contributes {
1538        for key in ASSET_KEYS {
1539            let Some(rel) = contribution.config.get(key).and_then(|v| v.as_str()) else {
1540                continue;
1541            };
1542            let rel_path = PathBuf::from(rel);
1543            // Reject absolute paths and `..` escapes.
1544            if rel_path.is_absolute()
1545                || rel_path
1546                    .components()
1547                    .any(|c| matches!(c, Component::ParentDir | Component::RootDir))
1548            {
1549                continue;
1550            }
1551            collect_path(project_root, &rel_path, &mut seen, &mut entries)?;
1552        }
1553    }
1554    Ok(entries)
1555}
1556
1557/// Recursively add `rel` (file or directory, relative to `project_root`) to the
1558/// archive at its bundle-relative path. No-op when the path is missing.
1559fn collect_path(
1560    project_root: &Path,
1561    rel: &Path,
1562    seen: &mut std::collections::BTreeSet<PathBuf>,
1563    entries: &mut Vec<HarnpackEntry>,
1564) -> Result<(), PackError> {
1565    let abs = project_root.join(rel);
1566    let metadata = match std::fs::symlink_metadata(&abs) {
1567        Ok(metadata) => metadata,
1568        Err(_) => return Ok(()),
1569    };
1570    if metadata.file_type().is_symlink() {
1571        return Ok(()); // never follow symlinks out of the archive root
1572    }
1573    if metadata.is_dir() {
1574        let mut children: Vec<PathBuf> = std::fs::read_dir(&abs)
1575            .map_err(|err| {
1576                PackError::new(
1577                    "asset.read_failed",
1578                    format!("failed to read contribution dir {}: {err}", abs.display()),
1579                )
1580            })?
1581            .filter_map(|e| e.ok().map(|e| e.file_name()))
1582            .map(|name| rel.join(name))
1583            .collect();
1584        children.sort();
1585        for child in children {
1586            collect_path(project_root, &child, seen, entries)?;
1587        }
1588        return Ok(());
1589    }
1590    if !metadata.is_file() || !seen.insert(rel.to_path_buf()) {
1591        return Ok(());
1592    }
1593    let bytes = std::fs::read(&abs).map_err(|err| {
1594        PackError::new(
1595            "asset.read_failed",
1596            format!("failed to read contribution asset {}: {err}", abs.display()),
1597        )
1598    })?;
1599    entries.push(HarnpackEntry::new(
1600        PathBuf::from(logical_bundle_path(rel)),
1601        bytes,
1602    ));
1603    Ok(())
1604}
1605
1606fn pack_archive_root(entrypoint: &Path) -> PathBuf {
1607    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1608    harn_modules::asset_paths::find_project_root(parent).unwrap_or_else(|| parent.to_path_buf())
1609}
1610
1611fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
1612    let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1613    let target_canon = target
1614        .canonicalize()
1615        .unwrap_or_else(|_| target.to_path_buf());
1616    if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
1617        return Some(rel.to_path_buf());
1618    }
1619    None
1620}
1621
1622fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
1623    let stem = rel.file_stem()?.to_string_lossy().into_owned();
1624    if stem.is_empty() {
1625        return None;
1626    }
1627    let parent_components: Vec<Component<'_>> = rel
1628        .parent()
1629        .map(|p| p.components().collect())
1630        .unwrap_or_default();
1631    let mut adjacent = PathBuf::new();
1632    for component in parent_components {
1633        adjacent.push(component.as_os_str());
1634    }
1635    let mut filename = stem;
1636    filename.push('.');
1637    filename.push_str(extension);
1638    adjacent.push(filename);
1639    Some(adjacent)
1640}
1641
1642fn blake3_hash(bytes: &[u8]) -> String {
1643    format!("blake3:{}", blake3::hash(bytes))
1644}
1645
1646fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
1647    if let Some(path) = out {
1648        return path.clone();
1649    }
1650    let stem = entrypoint
1651        .file_stem()
1652        .map(|s| s.to_string_lossy().into_owned())
1653        .unwrap_or_else(|| "bundle".to_string());
1654    let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1655    parent.join(format!("{stem}.harnpack"))
1656}
1657
1658/// Heuristic gate for `--exclude-secrets`. Matches `.env`, `.env.*`,
1659/// `*.pem`, `*.key`, `credentials*`, and any path under a `secrets/`
1660/// directory. Kept conservative so false positives don't strand
1661/// legitimate bundles; mirrors common git secret-scanning policies.
1662pub(crate) fn path_looks_like_secret(path: &Path) -> bool {
1663    let lower_name = path
1664        .file_name()
1665        .map(|s| s.to_string_lossy().to_ascii_lowercase())
1666        .unwrap_or_default();
1667    if lower_name == ".env" || lower_name.starts_with(".env.") {
1668        return true;
1669    }
1670    if lower_name.starts_with("credentials") {
1671        return true;
1672    }
1673    if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1674        let ext = ext.to_ascii_lowercase();
1675        if ext == "pem" || ext == "key" {
1676            return true;
1677        }
1678    }
1679    for component in path.components() {
1680        if let Component::Normal(part) = component {
1681            if part.to_string_lossy().eq_ignore_ascii_case("secrets") {
1682                return true;
1683            }
1684        }
1685    }
1686    false
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691    use super::*;
1692    use std::fs;
1693
1694    fn build_args(entrypoint: PathBuf, out: PathBuf) -> BuildArgs {
1695        BuildArgs {
1696            entrypoint,
1697            out: Some(out),
1698            upgrade: None,
1699            sign: false,
1700            key: None,
1701            unsigned: true,
1702            exclude_secrets: false,
1703            json: true,
1704        }
1705    }
1706
1707    #[test]
1708    fn carry_extension_metadata_injects_contributes_and_identity() {
1709        let temp = tempfile::tempdir().unwrap();
1710        fs::write(
1711            temp.path().join("harn.toml"),
1712            r#"
1713[package]
1714name = "harn-latex"
1715version = "0.1.0"
1716publisher = "Burin Labs"
1717permissions = ["workspace:read_text"]
1718
1719[[contributes]]
1720kind = "editor.language"
1721id = "latex"
1722scopes = ["workspace:read_text"]
1723languageId = "latex"
1724"#,
1725        )
1726        .unwrap();
1727        let mut bundle = WorkflowBundle::default();
1728        carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1729
1730        let contributes = bundle
1731            .metadata
1732            .get("contributes")
1733            .expect("contributes carried");
1734        assert_eq!(contributes.as_array().unwrap().len(), 1);
1735        assert_eq!(contributes[0]["kind"], "editor.language");
1736        // kind-specific keys are flattened into the contribution object
1737        assert_eq!(contributes[0]["languageId"], "latex");
1738
1739        let ext = bundle.metadata.get("extension").expect("identity carried");
1740        assert_eq!(ext["name"], "harn-latex");
1741        assert_eq!(ext["publisher"], "Burin Labs");
1742        assert_eq!(ext["permissions"][0], "workspace:read_text");
1743    }
1744
1745    #[test]
1746    fn carry_extension_metadata_bundles_contribution_assets() {
1747        let temp = tempfile::tempdir().unwrap();
1748        fs::create_dir_all(temp.path().join("assets")).unwrap();
1749        fs::write(temp.path().join("assets/preview.html"), "<html></html>").unwrap();
1750        fs::create_dir_all(temp.path().join("canon/latex")).unwrap();
1751        fs::write(temp.path().join("canon/latex/invariants.harn"), "// rules").unwrap();
1752        fs::write(temp.path().join("SKILL.md"), "# skill").unwrap();
1753        fs::write(
1754            temp.path().join("harn.toml"),
1755            r#"
1756[package]
1757name = "harn-latex"
1758permissions = ["workspace:read_text"]
1759
1760[[contributes]]
1761kind = "editor.preview"
1762id = "p"
1763scopes = ["workspace:read_text"]
1764entry = "assets/preview.html"
1765
1766[[contributes]]
1767kind = "harn.canon"
1768id = "c"
1769path = "canon/latex"
1770
1771[[contributes]]
1772kind = "harn.skill"
1773id = "s"
1774path = "SKILL.md"
1775"#,
1776        )
1777        .unwrap();
1778        let mut bundle = WorkflowBundle::default();
1779        let assets = carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1780        let paths: std::collections::BTreeSet<String> = assets
1781            .iter()
1782            .map(|e| crate::format::slash_path(&e.path))
1783            .collect();
1784        assert!(paths.contains("assets/preview.html"), "{paths:?}");
1785        assert!(paths.contains("canon/latex/invariants.harn"), "{paths:?}");
1786        assert!(paths.contains("SKILL.md"), "{paths:?}");
1787    }
1788
1789    #[test]
1790    fn carry_extension_metadata_skips_parent_escape_assets() {
1791        let temp = tempfile::tempdir().unwrap();
1792        fs::write(
1793            temp.path().join("harn.toml"),
1794            "[package]\nname = \"x\"\npermissions = []\n\n[[contributes]]\nkind = \"editor.preview\"\nid = \"p\"\nentry = \"../escape.html\"\n",
1795        )
1796        .unwrap();
1797        let mut bundle = WorkflowBundle::default();
1798        let assets = carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1799        assert!(assets.is_empty());
1800    }
1801
1802    #[test]
1803    fn carry_extension_metadata_is_noop_without_manifest() {
1804        let temp = tempfile::tempdir().unwrap();
1805        let mut bundle = WorkflowBundle::default();
1806        carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1807        assert!(!bundle.metadata.contains_key("contributes"));
1808    }
1809
1810    #[test]
1811    fn pack_uses_nearest_harn_toml_root_for_nested_entrypoint_assets() {
1812        let temp = tempfile::tempdir().unwrap();
1813        fs::write(
1814            temp.path().join("harn.toml"),
1815            "[package]\nname = \"pack-root\"\n",
1816        )
1817        .unwrap();
1818        fs::create_dir_all(temp.path().join("scripts")).unwrap();
1819        fs::create_dir_all(temp.path().join("assets")).unwrap();
1820        fs::write(temp.path().join("assets/prompt.txt"), "prompt asset\n").unwrap();
1821        fs::write(
1822            temp.path().join("scripts/entry.harn"),
1823            "import \"../assets/prompt.txt\"\n__io_println(\"packed\")\n",
1824        )
1825        .unwrap();
1826
1827        let outcome = build(&build_args(
1828            temp.path().join("scripts/entry.harn"),
1829            temp.path().join("bundle.harnpack"),
1830        ))
1831        .unwrap();
1832
1833        assert_eq!(
1834            outcome.json.manifest.entrypoint,
1835            PathBuf::from("scripts/entry.harn")
1836        );
1837        assert!(outcome
1838            .json
1839            .manifest
1840            .sbom
1841            .packages
1842            .iter()
1843            .any(|package| package.name == "asset:assets/prompt.txt"));
1844    }
1845
1846    #[test]
1847    fn pack_rejects_imported_asset_outside_archive_root() {
1848        let temp = tempfile::tempdir().unwrap();
1849        let root = temp.path().join("root");
1850        let outside = temp.path().join("outside");
1851        fs::create_dir_all(&root).unwrap();
1852        fs::create_dir_all(&outside).unwrap();
1853        fs::write(outside.join("prompt.txt"), "outside asset\n").unwrap();
1854        fs::write(
1855            root.join("entry.harn"),
1856            "import \"../outside/prompt.txt\"\n__io_println(\"packed\")\n",
1857        )
1858        .unwrap();
1859
1860        let err = build(&build_args(
1861            root.join("entry.harn"),
1862            root.join("bundle.harnpack"),
1863        ))
1864        .unwrap_err();
1865
1866        assert_eq!(err.code, "asset.outside_root");
1867        assert!(!root.join("bundle.harnpack").exists());
1868    }
1869}
1870
1871// --- `harn pack verify` -----------------------------------------------------
1872
1873/// Stable schema version for the `harn pack verify --json` envelope.
1874/// Bump when [`PackVerifyJsonData`] changes shape in a way agents need
1875/// to detect.
1876pub const PACK_VERIFY_SCHEMA_VERSION: u32 = 1;
1877
1878/// JSON payload emitted under `JsonEnvelope.data` for `harn pack verify`.
1879#[derive(Debug, Clone, Serialize)]
1880pub struct PackVerifyJsonData {
1881    pub bundle: PathBuf,
1882    pub bundle_hash: String,
1883    pub recorded_bundle_hash: Option<String>,
1884    pub signature_present: bool,
1885    pub signature_verified: bool,
1886    pub key_id: Option<String>,
1887    pub schema_version: u32,
1888    pub entrypoint: PathBuf,
1889    pub module_count: usize,
1890    pub content_entry_count: usize,
1891}
1892
1893struct PackVerifyJsonOutput(PackVerifyJsonData);
1894
1895impl JsonOutput for PackVerifyJsonOutput {
1896    const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
1897    type Data = PackVerifyJsonData;
1898    fn into_envelope(self) -> JsonEnvelope<Self::Data> {
1899        JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
1900    }
1901}
1902
1903/// JSON schema for `harn pack verify --json`. Mirrors the runtime
1904/// envelope so agents can validate output before consuming it.
1905pub fn verify_json_schema() -> serde_json::Value {
1906    serde_json::json!({
1907        "$schema": "https://json-schema.org/draft/2020-12/schema",
1908        "title": "harn pack verify --json",
1909        "type": "object",
1910        "required": ["schemaVersion", "ok", "data", "warnings"],
1911        "properties": {
1912            "schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
1913            "ok": { "type": "boolean" },
1914            "warnings": { "type": "array" },
1915            "data": {
1916                "type": "object",
1917                "required": [
1918                    "bundle",
1919                    "bundle_hash",
1920                    "signature_present",
1921                    "signature_verified",
1922                    "recorded_bundle_hash",
1923                    "key_id",
1924                    "schema_version",
1925                    "entrypoint",
1926                    "module_count",
1927                    "content_entry_count"
1928                ],
1929                "properties": {
1930                    "bundle": { "type": "string", "minLength": 1 },
1931                    "bundle_hash": { "type": "string", "pattern": "^blake3:" },
1932                    "recorded_bundle_hash": { "type": ["string", "null"] },
1933                    "signature_present": { "type": "boolean" },
1934                    "signature_verified": { "type": "boolean" },
1935                    "key_id": { "type": ["string", "null"] },
1936                    "schema_version": { "type": "integer", "minimum": 1 },
1937                    "entrypoint": { "type": "string", "minLength": 1 },
1938                    "module_count": { "type": "integer", "minimum": 1 },
1939                    "content_entry_count": { "type": "integer", "minimum": 1 }
1940                }
1941            }
1942        }
1943    })
1944}
1945
1946/// Dispatcher for [`PackCommand::Verify`]: prints a human-readable
1947/// line or a `JsonEnvelope` and exits non-zero on verification failure.
1948pub fn run_verify(args: PackVerifyArgs) {
1949    match verify(&args) {
1950        Ok(outcome) => {
1951            if args.json {
1952                let envelope = PackVerifyJsonOutput(outcome).into_envelope();
1953                println!("{}", to_string_pretty(&envelope));
1954            } else {
1955                println!(
1956                    "ok {} (bundle_hash {}, signature_verified={})",
1957                    outcome.bundle.display(),
1958                    outcome.bundle_hash,
1959                    outcome.signature_verified
1960                );
1961            }
1962        }
1963        Err(err) => {
1964            if args.json {
1965                let envelope: JsonEnvelope<PackVerifyJsonData> =
1966                    JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
1967                println!("{}", to_string_pretty(&envelope));
1968                process::exit(1);
1969            }
1970            command_error(&err.message);
1971        }
1972    }
1973}
1974
1975/// Programmatic verify entry point used by tests so they can read the
1976/// envelope structurally instead of parsing stdout.
1977pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
1978    match verify(args) {
1979        Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
1980        Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
1981    }
1982}
1983
1984/// Verify the bundle at `args.bundle`:
1985///
1986/// 1. Read the archive (`tar.zst`) and decode the manifest.
1987/// 2. Recompute the canonical bundle hash from manifest + contents.
1988/// 3. If the manifest carries an Ed25519 signature, run the existing
1989///    [`verify_workflow_bundle_signature`] check; refuse unsigned
1990///    bundles unless `--allow-unsigned` was passed.
1991/// 4. Walk each `ModuleEntry` and verify its `source_hash_blake3` /
1992///    `harnbc_hash_blake3` match the in-archive payload.
1993///
1994/// Any mismatch yields a [`PackError`] with a stable structured code
1995/// suitable for JSON consumers.
1996pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
1997    let bytes = std::fs::read(&args.bundle).map_err(|err| {
1998        PackError::new(
1999            "verify.read_failed",
2000            format!("failed to read {}: {err}", args.bundle.display()),
2001        )
2002    })?;
2003    let archive = read_harnpack(&bytes).map_err(|err| {
2004        PackError::new(
2005            "verify.archive_failed",
2006            format!("failed to parse {}: {err}", args.bundle.display()),
2007        )
2008    })?;
2009    let manifest = &archive.manifest;
2010    let contents = &archive.contents;
2011
2012    let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
2013        PackError::new(
2014            "verify.hash_failed",
2015            format!("failed to recompute bundle hash: {err}"),
2016        )
2017    })?;
2018
2019    let trust_policy = args
2020        .trust_policy
2021        .as_deref()
2022        .map(skill_provenance::load_trust_policy)
2023        .transpose()
2024        .map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
2025    let signature_present = manifest.signature.is_some();
2026    let mut signature_verified = false;
2027    let mut key_id = None;
2028    if let Some(signature) = manifest.signature.as_ref() {
2029        key_id = signature.key_id.clone();
2030        verify_workflow_bundle_signature(manifest, contents)
2031            .map_err(|err| PackError::new("verify.signature_failed", err.message))?;
2032        if args.require_trusted_signer {
2033            let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
2034                PackError::new(
2035                    "verify.signature_failed",
2036                    format!("invalid bundle signer: {err}"),
2037                )
2038            })?;
2039            match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
2040                .map_err(|err| PackError::new("verify.trust_policy_failed", err))?
2041            {
2042                skill_provenance::TrustedSignerStatus::Trusted => {}
2043                skill_provenance::TrustedSignerStatus::MissingSigner => {
2044                    return Err(PackError::new(
2045                        "verify.untrusted_signer",
2046                        format!(
2047                            "bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
2048                            args.bundle.display(),
2049                            signer_fingerprint
2050                        ),
2051                    ));
2052                }
2053                skill_provenance::TrustedSignerStatus::UntrustedSigner => {
2054                    return Err(PackError::new(
2055                        "verify.untrusted_signer",
2056                        format!(
2057                            "bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
2058                            args.bundle.display(),
2059                            signer_fingerprint
2060                        ),
2061                    ));
2062                }
2063            }
2064        }
2065        signature_verified = true;
2066        key_id.get_or_insert(
2067            signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
2068                PackError::new(
2069                    "verify.signature_failed",
2070                    format!("invalid bundle signer: {err}"),
2071                )
2072            })?,
2073        );
2074    } else if args.require_trusted_signer {
2075        return Err(PackError::new(
2076            "verify.untrusted_signer",
2077            format!(
2078                "bundle {} is unsigned and cannot satisfy --require-trusted-signer",
2079                args.bundle.display()
2080            ),
2081        ));
2082    } else if !args.allow_unsigned {
2083        return Err(PackError::new(
2084            "verify.unsigned",
2085            format!(
2086                "refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
2087                args.bundle.display()
2088            ),
2089        ));
2090    }
2091
2092    let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
2093    let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
2094    let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
2095    for entry in contents {
2096        archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
2097        if let Ok(rel) = entry.path.strip_prefix("sources") {
2098            source_map.insert(rel.to_path_buf(), entry);
2099        } else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
2100            bytecode_map.insert(rel.to_path_buf(), entry);
2101        }
2102    }
2103
2104    for module in &manifest.transitive_modules {
2105        let source_entry = source_map.get(&module.path).ok_or_else(|| {
2106            PackError::new(
2107                "verify.module_missing",
2108                format!(
2109                    "manifest lists module {} but archive has no sources/{} entry",
2110                    module.path.display(),
2111                    module.path.display()
2112                ),
2113            )
2114        })?;
2115        let actual_source = blake3_hash(&source_entry.bytes);
2116        if actual_source != module.source_hash_blake3 {
2117            return Err(PackError::new(
2118                "verify.source_mismatch",
2119                format!(
2120                    "source hash mismatch for {}: manifest {}, archive {}",
2121                    module.path.display(),
2122                    module.source_hash_blake3,
2123                    actual_source
2124                ),
2125            ));
2126        }
2127        let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
2128            .ok_or_else(|| {
2129                PackError::new(
2130                    "verify.module_invalid_path",
2131                    format!("module {} has no stem", module.path.display()),
2132                )
2133            })?;
2134        let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
2135            PackError::new(
2136                "verify.module_missing",
2137                format!(
2138                    "manifest lists bytecode for {} but archive has no bytecode/{} entry",
2139                    module.path.display(),
2140                    chunk_rel.display()
2141                ),
2142            )
2143        })?;
2144        let actual_harnbc = blake3_hash(&chunk_entry.bytes);
2145        if actual_harnbc != module.harnbc_hash_blake3 {
2146            return Err(PackError::new(
2147                "verify.bytecode_mismatch",
2148                format!(
2149                    "bytecode hash mismatch for {}: manifest {}, archive {}",
2150                    module.path.display(),
2151                    module.harnbc_hash_blake3,
2152                    actual_harnbc
2153                ),
2154            ));
2155        }
2156    }
2157
2158    if args.strict {
2159        verify_sbom_package_hashes(manifest, &archive_hashes)?;
2160    }
2161
2162    // Cross-check the recorded signature hash against the recomputed
2163    // canonical hash. `verify_workflow_bundle_signature` already does
2164    // this for signed bundles; for unsigned bundles we report the
2165    // recomputed hash so callers can compare against external
2166    // attestations.
2167    let recorded_bundle_hash = manifest
2168        .signature
2169        .as_ref()
2170        .map(|sig| sig.manifest_hash_blake3.clone());
2171    if let Some(recorded) = &recorded_bundle_hash {
2172        if recorded != &expected_hash {
2173            return Err(PackError::new(
2174                "verify.recorded_hash_mismatch",
2175                format!(
2176                    "recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
2177                ),
2178            ));
2179        }
2180    }
2181
2182    Ok(PackVerifyJsonData {
2183        bundle: args.bundle.clone(),
2184        bundle_hash: expected_hash,
2185        recorded_bundle_hash,
2186        signature_present,
2187        signature_verified,
2188        key_id,
2189        schema_version: manifest.schema_version,
2190        entrypoint: manifest.entrypoint.clone(),
2191        module_count: manifest.transitive_modules.len(),
2192        content_entry_count: contents.len(),
2193    })
2194}
2195
2196fn verify_sbom_package_hashes(
2197    manifest: &WorkflowBundle,
2198    archive_hashes: &BTreeMap<PathBuf, String>,
2199) -> Result<(), PackError> {
2200    let module_hashes: BTreeMap<&Path, &str> = manifest
2201        .transitive_modules
2202        .iter()
2203        .map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
2204        .collect();
2205
2206    for package in &manifest.sbom.packages {
2207        let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
2208            continue;
2209        };
2210
2211        if let Some(rel) = package.name.strip_prefix("module:") {
2212            let module_path = Path::new(rel);
2213            let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
2214                PackError::new(
2215                    "verify.sbom_mismatch",
2216                    format!(
2217                        "SBOM package {} does not match any manifest transitive module",
2218                        package.name
2219                    ),
2220                )
2221            })?;
2222            if *manifest_hash != expected_hash {
2223                return Err(PackError::new(
2224                    "verify.sbom_mismatch",
2225                    format!(
2226                        "SBOM package {} recorded hash {} but manifest module {} uses {}",
2227                        package.name,
2228                        expected_hash,
2229                        module_path.display(),
2230                        manifest_hash
2231                    ),
2232                ));
2233            }
2234            let source_archive_path = PathBuf::from("sources").join(module_path);
2235            let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
2236                PackError::new(
2237                    "verify.sbom_mismatch",
2238                    format!(
2239                        "SBOM package {} refers to {}, but archive is missing {}",
2240                        package.name,
2241                        module_path.display(),
2242                        source_archive_path.display()
2243                    ),
2244                )
2245            })?;
2246            if archive_hash != expected_hash {
2247                return Err(PackError::new(
2248                    "verify.sbom_mismatch",
2249                    format!(
2250                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
2251                        package.name,
2252                        expected_hash,
2253                        source_archive_path.display(),
2254                        archive_hash
2255                    ),
2256                ));
2257            }
2258            continue;
2259        }
2260
2261        if let Some(rel) = package.name.strip_prefix("asset:") {
2262            let asset_archive_path = PathBuf::from("sources").join(rel);
2263            let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
2264                PackError::new(
2265                    "verify.sbom_mismatch",
2266                    format!(
2267                        "SBOM package {} refers to {}, but archive is missing {}",
2268                        package.name,
2269                        rel,
2270                        asset_archive_path.display()
2271                    ),
2272                )
2273            })?;
2274            if archive_hash != expected_hash {
2275                return Err(PackError::new(
2276                    "verify.sbom_mismatch",
2277                    format!(
2278                        "SBOM package {} recorded hash {} but archive {} hashes to {}",
2279                        package.name,
2280                        expected_hash,
2281                        asset_archive_path.display(),
2282                        archive_hash
2283                    ),
2284                ));
2285            }
2286            continue;
2287        }
2288
2289        let candidate_path = Path::new(&package.name);
2290        let Some(archive_hash) = archive_hashes.get(candidate_path) else {
2291            continue;
2292        };
2293        if archive_hash != expected_hash {
2294            return Err(PackError::new(
2295                "verify.sbom_mismatch",
2296                format!(
2297                    "SBOM package {} recorded hash {} but archive {} hashes to {}",
2298                    package.name,
2299                    expected_hash,
2300                    candidate_path.display(),
2301                    archive_hash
2302                ),
2303            ));
2304        }
2305    }
2306
2307    Ok(())
2308}
2309
2310fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
2311    match signature.key_id.as_deref() {
2312        Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
2313        _ => signer_fingerprint_from_public_key(&signature.public_key),
2314    }
2315}
2316
2317fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
2318    let public_key_bytes = decode_hex_32(public_key_hex)?;
2319    let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
2320        format!("workflow bundle signature public_key is invalid Ed25519: {error}")
2321    })?;
2322    Ok(skill_provenance::fingerprint_for_key(&verifying_key))
2323}
2324
2325fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
2326    let trimmed = raw.trim();
2327    if trimmed.len() != 64 {
2328        return Err(format!(
2329            "workflow bundle signature public_key must be 64 hex characters, found {}",
2330            trimmed.len()
2331        ));
2332    }
2333    let mut bytes = [0_u8; 32];
2334    for (idx, slot) in bytes.iter_mut().enumerate() {
2335        let start = idx * 2;
2336        let end = start + 2;
2337        *slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
2338            format!(
2339                "workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
2340            )
2341        })?;
2342    }
2343    Ok(bytes)
2344}