1use 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
454pub const PACK_VERIFY_SCHEMA_VERSION: u32 = 1;
460
461#[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 pub link_report: Option<harn_vm::linked_program::LinkReport>,
477}
478
479struct PackVerifyJsonOutput(PackVerifyJsonData);
480
481impl JsonOutput for PackVerifyJsonOutput {
482 const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
483 type Data = PackVerifyJsonData;
484 fn into_envelope(self) -> JsonEnvelope<Self::Data> {
485 JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
486 }
487}
488
489pub fn verify_json_schema() -> serde_json::Value {
492 serde_json::json!({
493 "$schema": "https://json-schema.org/draft/2020-12/schema",
494 "title": "harn pack verify --json",
495 "type": "object",
496 "required": ["schemaVersion", "ok", "data", "warnings"],
497 "properties": {
498 "schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
499 "ok": { "type": "boolean" },
500 "warnings": { "type": "array" },
501 "data": {
502 "type": "object",
503 "required": [
504 "bundle",
505 "bundle_hash",
506 "signature_present",
507 "signature_verified",
508 "recorded_bundle_hash",
509 "key_id",
510 "schema_version",
511 "entrypoint",
512 "module_count",
513 "content_entry_count"
514 ],
515 "properties": {
516 "bundle": { "type": "string", "minLength": 1 },
517 "bundle_hash": { "type": "string", "pattern": "^blake3:" },
518 "recorded_bundle_hash": { "type": ["string", "null"] },
519 "signature_present": { "type": "boolean" },
520 "signature_verified": { "type": "boolean" },
521 "key_id": { "type": ["string", "null"] },
522 "schema_version": { "type": "integer", "minimum": 1 },
523 "entrypoint": { "type": "string", "minLength": 1 },
524 "module_count": { "type": "integer", "minimum": 1 },
525 "content_entry_count": { "type": "integer", "minimum": 1 }
526 }
527 }
528 }
529 })
530}
531
532pub fn run_verify(args: PackVerifyArgs) {
535 match verify(&args) {
536 Ok(outcome) => {
537 if args.json {
538 let envelope = PackVerifyJsonOutput(outcome).into_envelope();
539 println!("{}", to_string_pretty(&envelope));
540 } else {
541 println!(
542 "ok {} (bundle_hash {}, signature_verified={})",
543 outcome.bundle.display(),
544 outcome.bundle_hash,
545 outcome.signature_verified
546 );
547 }
548 }
549 Err(err) => {
550 if args.json {
551 let envelope: JsonEnvelope<PackVerifyJsonData> =
552 JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
553 println!("{}", to_string_pretty(&envelope));
554 process::exit(1);
555 }
556 command_error(&err.message);
557 }
558 }
559}
560
561pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
564 match verify(args) {
565 Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
566 Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
567 }
568}
569
570pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
583 let bytes = std::fs::read(&args.bundle).map_err(|err| {
584 PackError::new(
585 "verify.read_failed",
586 format!("failed to read {}: {err}", args.bundle.display()),
587 )
588 })?;
589 let archive = read_harnpack(&bytes).map_err(|err| {
590 PackError::new(
591 "verify.archive_failed",
592 format!("failed to parse {}: {err}", args.bundle.display()),
593 )
594 })?;
595 let manifest = &archive.manifest;
596 let contents = &archive.contents;
597
598 let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
599 PackError::new(
600 "verify.hash_failed",
601 format!("failed to recompute bundle hash: {err}"),
602 )
603 })?;
604
605 let trust_policy = args
606 .trust_policy
607 .as_deref()
608 .map(skill_provenance::load_trust_policy)
609 .transpose()
610 .map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
611 let signature_present = manifest.signature.is_some();
612 let mut signature_verified = false;
613 let mut key_id = None;
614 if let Some(signature) = manifest.signature.as_ref() {
615 key_id = signature.key_id.clone();
616 verify_workflow_bundle_signature(manifest, contents)
617 .map_err(|err| PackError::new("verify.signature_failed", err.message))?;
618 if args.require_trusted_signer {
619 let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
620 PackError::new(
621 "verify.signature_failed",
622 format!("invalid bundle signer: {err}"),
623 )
624 })?;
625 match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
626 .map_err(|err| PackError::new("verify.trust_policy_failed", err))?
627 {
628 skill_provenance::TrustedSignerStatus::Trusted => {}
629 skill_provenance::TrustedSignerStatus::MissingSigner => {
630 return Err(PackError::new(
631 "verify.untrusted_signer",
632 format!(
633 "bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
634 args.bundle.display(),
635 signer_fingerprint
636 ),
637 ));
638 }
639 skill_provenance::TrustedSignerStatus::UntrustedSigner => {
640 return Err(PackError::new(
641 "verify.untrusted_signer",
642 format!(
643 "bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
644 args.bundle.display(),
645 signer_fingerprint
646 ),
647 ));
648 }
649 }
650 }
651 signature_verified = true;
652 key_id.get_or_insert(
653 signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
654 PackError::new(
655 "verify.signature_failed",
656 format!("invalid bundle signer: {err}"),
657 )
658 })?,
659 );
660 } else if args.require_trusted_signer {
661 return Err(PackError::new(
662 "verify.untrusted_signer",
663 format!(
664 "bundle {} is unsigned and cannot satisfy --require-trusted-signer",
665 args.bundle.display()
666 ),
667 ));
668 } else if !args.allow_unsigned {
669 return Err(PackError::new(
670 "verify.unsigned",
671 format!(
672 "refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
673 args.bundle.display()
674 ),
675 ));
676 }
677
678 verify_runtime_payloads(manifest, contents, signature_present)?;
679
680 let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
681 for entry in contents {
682 archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
683 }
684
685 if args.strict {
686 verify_sbom_package_hashes(manifest, &archive_hashes, SbomHashScope::Strict)?;
687 }
688
689 let recorded_bundle_hash = manifest
695 .signature
696 .as_ref()
697 .map(|sig| sig.manifest_hash_blake3.clone());
698 if let Some(recorded) = &recorded_bundle_hash {
699 if recorded != &expected_hash {
700 return Err(PackError::new(
701 "verify.recorded_hash_mismatch",
702 format!(
703 "recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
704 ),
705 ));
706 }
707 }
708
709 Ok(PackVerifyJsonData {
710 bundle: args.bundle.clone(),
711 bundle_hash: expected_hash,
712 recorded_bundle_hash,
713 signature_present,
714 signature_verified,
715 key_id,
716 schema_version: manifest.schema_version,
717 entrypoint: manifest.entrypoint.clone(),
718 module_count: manifest.transitive_modules.len(),
719 content_entry_count: contents.len(),
720 link_report: manifest
721 .execution_artifact
722 .as_ref()
723 .map(|artifact| artifact.link_report.clone()),
724 })
725}
726
727pub(crate) fn verify_module_payloads(
732 manifest: &WorkflowBundle,
733 contents: &[HarnpackEntry],
734) -> Result<(), PackError> {
735 let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
736 let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
737 for entry in contents {
738 if let Ok(rel) = entry.path.strip_prefix("sources") {
739 source_map.insert(rel.to_path_buf(), entry);
740 } else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
741 bytecode_map.insert(rel.to_path_buf(), entry);
742 }
743 }
744
745 for module in &manifest.transitive_modules {
746 let source_entry = source_map.get(&module.path).ok_or_else(|| {
747 PackError::new(
748 "verify.module_missing",
749 format!(
750 "manifest lists module {} but archive has no sources/{} entry",
751 module.path.display(),
752 module.path.display()
753 ),
754 )
755 })?;
756 let actual_source = blake3_hash(&source_entry.bytes);
757 if actual_source != module.source_hash_blake3 {
758 return Err(PackError::new(
759 "verify.source_mismatch",
760 format!(
761 "source hash mismatch for {}: manifest {}, archive {}",
762 module.path.display(),
763 module.source_hash_blake3,
764 actual_source
765 ),
766 ));
767 }
768 if manifest.execution_artifact.is_none() {
772 let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
773 .ok_or_else(|| {
774 PackError::new(
775 "verify.module_invalid_path",
776 format!("module {} has no stem", module.path.display()),
777 )
778 })?;
779 let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
780 PackError::new(
781 "verify.module_missing",
782 format!(
783 "manifest lists bytecode for {} but archive has no bytecode/{} entry",
784 module.path.display(),
785 chunk_rel.display()
786 ),
787 )
788 })?;
789 let actual_harnbc = blake3_hash(&chunk_entry.bytes);
790 if actual_harnbc != module.harnbc_hash_blake3 {
791 return Err(PackError::new(
792 "verify.bytecode_mismatch",
793 format!(
794 "bytecode hash mismatch for {}: manifest {}, archive {}",
795 module.path.display(),
796 module.harnbc_hash_blake3,
797 actual_harnbc
798 ),
799 ));
800 }
801 }
802 }
803 if let Some(artifact) = manifest.execution_artifact.as_ref() {
809 let entry = contents
810 .iter()
811 .find(|entry| entry.path == artifact.path)
812 .ok_or_else(|| {
813 PackError::new(
814 "verify.module_missing",
815 format!(
816 "manifest lists execution artifact {} but the archive has no such entry",
817 artifact.path.display()
818 ),
819 )
820 })?;
821 let actual = blake3_hash(&entry.bytes);
822 if actual != artifact.hash_blake3 {
823 return Err(PackError::new(
824 "verify.bytecode_mismatch",
825 format!(
826 "execution artifact hash mismatch for {}: manifest {}, archive {actual}",
827 artifact.path.display(),
828 artifact.hash_blake3
829 ),
830 ));
831 }
832 }
833 Ok(())
834}
835
836pub(crate) fn verify_runtime_payloads(
843 manifest: &WorkflowBundle,
844 contents: &[HarnpackEntry],
845 reject_unbound: bool,
846) -> Result<(), PackError> {
847 verify_module_payloads(manifest, contents)?;
848
849 let archive_hashes = contents
850 .iter()
851 .map(|entry| (entry.path.clone(), blake3_hash(&entry.bytes)))
852 .collect::<BTreeMap<_, _>>();
853 verify_sbom_package_hashes(manifest, &archive_hashes, SbomHashScope::RuntimePaths)?;
854
855 let sbom_entry = contents
856 .iter()
857 .find(|entry| entry.path == Path::new(super::PACK_SBOM_ARCHIVE_PATH))
858 .ok_or_else(|| {
859 PackError::new(
860 "verify.sbom_mismatch",
861 format!("archive is missing {}", super::PACK_SBOM_ARCHIVE_PATH),
862 )
863 })?;
864 let archived_sbom: harn_vm::orchestration::SBOMDoc = serde_json::from_slice(&sbom_entry.bytes)
865 .map_err(|error| {
866 PackError::new(
867 "verify.sbom_mismatch",
868 format!("archive SBOM is not valid JSON: {error}"),
869 )
870 })?;
871 if archived_sbom != manifest.sbom {
872 return Err(PackError::new(
873 "verify.sbom_mismatch",
874 format!(
875 "archive {} does not match the signed manifest SBOM",
876 super::PACK_SBOM_ARCHIVE_PATH
877 ),
878 ));
879 }
880
881 let mut bound_paths = std::collections::BTreeSet::new();
882 bound_paths.insert(PathBuf::from(super::PACK_SBOM_ARCHIVE_PATH));
883 if let Some(artifact) = manifest.execution_artifact.as_ref() {
884 bound_paths.insert(artifact.path.clone());
888 }
889 for module in &manifest.transitive_modules {
890 bound_paths.insert(PathBuf::from("sources").join(&module.path));
891 for extension in [
892 bytecode_cache::CACHE_EXTENSION,
893 bytecode_cache::MODULE_CACHE_EXTENSION,
894 ] {
895 if let Some(relative) = adjacent_with_extension(&module.path, extension) {
896 bound_paths.insert(PathBuf::from("bytecode").join(relative));
897 }
898 }
899 }
900 for package in &manifest.sbom.packages {
901 if package.package_hash_blake3.is_some() {
902 if let Some(relative) = package.name.strip_prefix("asset:") {
903 bound_paths.insert(PathBuf::from("sources").join(relative));
904 }
905 }
906 }
907
908 if reject_unbound {
909 if let Some(entry) = contents
910 .iter()
911 .find(|entry| !bound_paths.contains(&entry.path))
912 {
913 return Err(PackError::new(
914 "verify.unbound_payload",
915 format!(
916 "signed legacy bundle payload {} has no path-bound manifest or SBOM identity",
917 entry.path.display()
918 ),
919 ));
920 }
921 }
922 Ok(())
923}
924
925#[derive(Clone, Copy, Debug, PartialEq, Eq)]
926enum SbomHashScope {
927 RuntimePaths,
930 Strict,
933}
934
935fn verify_sbom_package_hashes(
936 manifest: &WorkflowBundle,
937 archive_hashes: &BTreeMap<PathBuf, String>,
938 scope: SbomHashScope,
939) -> Result<(), PackError> {
940 let module_hashes: BTreeMap<&Path, &str> = manifest
941 .transitive_modules
942 .iter()
943 .map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
944 .collect();
945
946 for package in &manifest.sbom.packages {
947 let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
948 continue;
949 };
950
951 if let Some(rel) = package.name.strip_prefix("module:") {
952 if scope == SbomHashScope::RuntimePaths {
953 continue;
954 }
955 let module_path = Path::new(rel);
956 let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
957 PackError::new(
958 "verify.sbom_mismatch",
959 format!(
960 "SBOM package {} does not match any manifest transitive module",
961 package.name
962 ),
963 )
964 })?;
965 if *manifest_hash != expected_hash {
966 return Err(PackError::new(
967 "verify.sbom_mismatch",
968 format!(
969 "SBOM package {} recorded hash {} but manifest module {} uses {}",
970 package.name,
971 expected_hash,
972 module_path.display(),
973 manifest_hash
974 ),
975 ));
976 }
977 let source_archive_path = PathBuf::from("sources").join(module_path);
978 let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
979 PackError::new(
980 "verify.sbom_mismatch",
981 format!(
982 "SBOM package {} refers to {}, but archive is missing {}",
983 package.name,
984 module_path.display(),
985 source_archive_path.display()
986 ),
987 )
988 })?;
989 if archive_hash != expected_hash {
990 return Err(PackError::new(
991 "verify.sbom_mismatch",
992 format!(
993 "SBOM package {} recorded hash {} but archive {} hashes to {}",
994 package.name,
995 expected_hash,
996 source_archive_path.display(),
997 archive_hash
998 ),
999 ));
1000 }
1001 continue;
1002 }
1003
1004 if let Some(rel) = package.name.strip_prefix("asset:") {
1005 let asset_archive_path = PathBuf::from("sources").join(rel);
1006 let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
1007 PackError::new(
1008 "verify.sbom_mismatch",
1009 format!(
1010 "SBOM package {} refers to {}, but archive is missing {}",
1011 package.name,
1012 rel,
1013 asset_archive_path.display()
1014 ),
1015 )
1016 })?;
1017 if archive_hash != expected_hash {
1018 return Err(PackError::new(
1019 "verify.sbom_mismatch",
1020 format!(
1021 "SBOM package {} recorded hash {} but archive {} hashes to {}",
1022 package.name,
1023 expected_hash,
1024 asset_archive_path.display(),
1025 archive_hash
1026 ),
1027 ));
1028 }
1029 continue;
1030 }
1031
1032 if scope == SbomHashScope::RuntimePaths {
1033 continue;
1034 }
1035 let candidate_path = Path::new(&package.name);
1036 let Some(archive_hash) = archive_hashes.get(candidate_path) else {
1037 continue;
1038 };
1039 if archive_hash != expected_hash {
1040 return Err(PackError::new(
1041 "verify.sbom_mismatch",
1042 format!(
1043 "SBOM package {} recorded hash {} but archive {} hashes to {}",
1044 package.name,
1045 expected_hash,
1046 candidate_path.display(),
1047 archive_hash
1048 ),
1049 ));
1050 }
1051 }
1052
1053 Ok(())
1054}
1055
1056fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
1057 match signature.key_id.as_deref() {
1058 Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
1059 _ => signer_fingerprint_from_public_key(&signature.public_key),
1060 }
1061}
1062
1063fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
1064 let public_key_bytes = decode_hex_32(public_key_hex)?;
1065 let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
1066 format!("workflow bundle signature public_key is invalid Ed25519: {error}")
1067 })?;
1068 Ok(skill_provenance::fingerprint_for_key(&verifying_key))
1069}
1070
1071fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
1072 let trimmed = raw.trim();
1073 if trimmed.len() != 64 {
1074 return Err(format!(
1075 "workflow bundle signature public_key must be 64 hex characters, found {}",
1076 trimmed.len()
1077 ));
1078 }
1079 let mut bytes = [0_u8; 32];
1080 for (idx, slot) in bytes.iter_mut().enumerate() {
1081 let start = idx * 2;
1082 let end = start + 2;
1083 *slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
1084 format!(
1085 "workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
1086 )
1087 })?;
1088 }
1089 Ok(bytes)
1090}