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