1use std::collections::BTreeMap;
14use std::path::{Component, Path, PathBuf};
15use std::process;
16
17use ed25519_dalek::Signer;
18use ed25519_dalek::VerifyingKey;
19use harn_parser::DiagnosticSeverity;
20use harn_vm::bytecode_cache;
21use harn_vm::module_artifact;
22use harn_vm::orchestration::{
23 build_harnpack, load_workflow_bundle_any_version, read_harnpack,
24 verify_workflow_bundle_signature, workflow_bundle_hash, CatchupPolicySpec,
25 ConnectorRequirement, Ed25519Signature, EnvironmentRequirements, HarnpackEntry, ModuleEntry,
26 RetryPolicySpec, SBOMDoc, SBOMPackage, SBOMRelationship, ToolEntry, WorkflowBundle,
27 WorkflowBundlePolicy, WorkflowBundleReplayMetadata, WorkflowBundleTrigger,
28 WORKFLOW_BUNDLE_SCHEMA_VERSION,
29};
30use harn_vm::Compiler;
31use harn_vm::{AutonomyTier, TrustRecord};
32use serde::{Deserialize, Serialize};
33
34use crate::cli::{PackArgs, PackCommand, PackVerifyArgs};
35use crate::command_error;
36use crate::json_envelope::{to_string_pretty, JsonEnvelope, JsonOutput, JsonWarning};
37use crate::parse_source_file;
38use crate::skill_provenance;
39
40pub const PACK_SCHEMA_VERSION: u32 = 2;
43pub const PACK_SBOM_ARCHIVE_PATH: &str = "sbom.spdx.json";
44
45#[derive(Debug, Clone, Serialize)]
47pub struct PackJsonData {
48 pub bundle_hash: String,
49 pub output_path: PathBuf,
50 pub size_bytes: u64,
51 pub signature: PackSignatureSummary,
52 pub sbom_summary: PackSbomSummary,
53 pub debug_symbol_metadata: PackDebugSymbolMetadata,
54 pub manifest: WorkflowBundle,
55}
56
57#[derive(Debug, Clone, Serialize)]
58pub struct PackSignatureSummary {
59 pub algorithm: String,
60 pub key_id: Option<String>,
61 pub present: bool,
62}
63
64#[derive(Debug, Clone, Serialize)]
65pub struct PackSbomSummary {
66 pub components: usize,
67 pub stdlib_modules: usize,
68 pub providers: usize,
69 pub tools: usize,
70}
71
72#[derive(Debug, Clone, Serialize)]
73pub struct PackDebugSymbolMetadata {
74 pub harnbc_count: usize,
75 pub total_bytes: u64,
76}
77
78struct PackJsonOutput {
79 data: PackJsonData,
80 warnings: Vec<JsonWarning>,
81}
82
83impl JsonOutput for PackJsonOutput {
84 const SCHEMA_VERSION: u32 = PACK_SCHEMA_VERSION;
85 type Data = PackJsonData;
86 fn into_envelope(self) -> JsonEnvelope<Self::Data> {
87 let mut envelope = JsonEnvelope::ok(Self::SCHEMA_VERSION, self.data);
88 envelope.warnings = self.warnings;
89 envelope
90 }
91}
92
93pub fn run(args: PackArgs) {
94 if let Some(command) = args.command {
95 match command {
96 PackCommand::Verify(verify_args) => return run_verify(verify_args),
97 }
98 }
99 let Some(entrypoint) = args.entrypoint.clone() else {
100 command_error("harn pack requires an entrypoint or a subcommand (see `harn pack --help`)");
101 };
102 let build_args = BuildArgs {
103 entrypoint,
104 out: args.out,
105 upgrade: args.upgrade,
106 sign: args.sign,
107 key: args.key,
108 unsigned: args.unsigned,
109 exclude_secrets: args.exclude_secrets,
110 json: args.json,
111 };
112 match build(&build_args) {
113 Ok(outcome) => {
114 if build_args.json {
115 let envelope = PackJsonOutput {
116 data: outcome.json,
117 warnings: outcome.warnings,
118 }
119 .into_envelope();
120 println!("{}", to_string_pretty(&envelope));
121 } else {
122 for warning in &outcome.warnings {
123 eprintln!("warning[{}]: {}", warning.code, warning.message);
124 }
125 println!(
126 "wrote {} ({} bytes, bundle_hash {})",
127 outcome.output_path.display(),
128 outcome.size_bytes,
129 outcome.bundle_hash
130 );
131 }
132 }
133 Err(err) => {
134 if build_args.json {
135 let envelope: JsonEnvelope<PackJsonData> =
136 JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message);
137 println!("{}", to_string_pretty(&envelope));
138 process::exit(1);
139 }
140 command_error(&err.message);
141 }
142 }
143}
144
145pub fn run_to_envelope(args: &PackArgs) -> JsonEnvelope<PackJsonData> {
148 let Some(entrypoint) = args.entrypoint.clone() else {
149 return JsonEnvelope::err(
150 PACK_SCHEMA_VERSION,
151 "pack.missing_entrypoint",
152 "harn pack requires an entrypoint or a subcommand".to_string(),
153 );
154 };
155 let build_args = BuildArgs {
156 entrypoint,
157 out: args.out.clone(),
158 upgrade: args.upgrade.clone(),
159 sign: args.sign,
160 key: args.key.clone(),
161 unsigned: args.unsigned,
162 exclude_secrets: args.exclude_secrets,
163 json: args.json,
164 };
165 match build(&build_args) {
166 Ok(outcome) => PackJsonOutput {
167 data: outcome.json,
168 warnings: outcome.warnings,
169 }
170 .into_envelope(),
171 Err(err) => JsonEnvelope::err(PACK_SCHEMA_VERSION, err.code, err.message),
172 }
173}
174
175#[derive(Debug, Clone)]
179pub struct BuildArgs {
180 pub entrypoint: PathBuf,
181 pub out: Option<PathBuf>,
182 pub upgrade: Option<PathBuf>,
183 pub sign: bool,
184 pub key: Option<PathBuf>,
185 pub unsigned: bool,
186 pub exclude_secrets: bool,
187 pub json: bool,
188}
189
190pub fn json_schema() -> serde_json::Value {
191 serde_json::json!({
192 "$schema": "https://json-schema.org/draft/2020-12/schema",
193 "title": "harn pack --json",
194 "type": "object",
195 "required": ["schemaVersion", "ok", "data", "warnings"],
196 "properties": {
197 "schemaVersion": { "const": PACK_SCHEMA_VERSION },
198 "ok": { "const": true },
199 "warnings": { "type": "array" },
200 "data": {
201 "type": "object",
202 "required": [
203 "bundle_hash",
204 "output_path",
205 "size_bytes",
206 "signature",
207 "sbom_summary",
208 "debug_symbol_metadata",
209 "manifest"
210 ],
211 "properties": {
212 "bundle_hash": { "type": "string", "pattern": "^blake3:" },
213 "output_path": { "type": "string", "minLength": 1 },
214 "size_bytes": { "type": "integer", "minimum": 1 },
215 "signature": {
216 "type": "object",
217 "required": ["algorithm", "key_id", "present"],
218 "properties": {
219 "algorithm": { "const": "ed25519" },
220 "key_id": { "type": ["string", "null"] },
221 "present": { "type": "boolean" }
222 }
223 },
224 "sbom_summary": {
225 "type": "object",
226 "required": ["components", "stdlib_modules", "providers", "tools"],
227 "properties": {
228 "components": { "type": "integer", "minimum": 1 },
229 "stdlib_modules": { "type": "integer", "minimum": 0 },
230 "providers": { "type": "integer", "minimum": 0 },
231 "tools": { "type": "integer", "minimum": 0 }
232 }
233 },
234 "debug_symbol_metadata": {
235 "type": "object",
236 "required": ["harnbc_count", "total_bytes"],
237 "properties": {
238 "harnbc_count": { "type": "integer", "minimum": 1 },
239 "total_bytes": { "type": "integer", "minimum": 1 }
240 }
241 },
242 "manifest": { "type": "object" }
243 }
244 }
245 }
246 })
247}
248
249#[derive(Debug)]
252pub struct PackOutcome {
253 pub bundle_hash: String,
254 pub output_path: PathBuf,
255 pub size_bytes: u64,
256 pub json: PackJsonData,
257 pub warnings: Vec<JsonWarning>,
258}
259
260#[derive(Debug)]
261pub struct PackError {
262 pub code: &'static str,
263 pub message: String,
264}
265
266impl std::fmt::Display for PackError {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 write!(f, "{}: {}", self.code, self.message)
269 }
270}
271
272impl std::error::Error for PackError {}
273
274impl PackError {
275 fn new(code: &'static str, message: impl Into<String>) -> Self {
276 Self {
277 code,
278 message: message.into(),
279 }
280 }
281}
282
283pub fn build(args: &BuildArgs) -> Result<PackOutcome, PackError> {
284 if args.sign && args.unsigned {
285 return Err(PackError::new(
286 "pack.sign_conflict",
287 "--sign and --unsigned cannot be used together",
288 ));
289 }
290 if args.sign && args.key.is_none() {
291 return Err(PackError::new(
292 "pack.sign_missing_key",
293 "--sign requires --key <path>",
294 ));
295 }
296 if !args.sign && args.key.is_some() {
297 return Err(PackError::new(
298 "pack.key_without_sign",
299 "--key requires --sign",
300 ));
301 }
302 if let Some(upgrade) = &args.upgrade {
303 if !upgrade.exists() {
304 return Err(PackError::new(
305 "upgrade.not_found",
306 format!(
307 "--upgrade source bundle does not exist: {}",
308 upgrade.display()
309 ),
310 ));
311 }
312 }
313 let entrypoint_input = args.entrypoint.clone();
314 let entrypoint = entrypoint_input
315 .canonicalize()
316 .unwrap_or_else(|_| entrypoint_input.clone());
317 if !entrypoint.exists() {
318 return Err(PackError::new(
319 "entrypoint.not_found",
320 format!("entrypoint does not exist: {}", entrypoint_input.display()),
321 ));
322 }
323 if !entrypoint.is_file() || entrypoint.extension().and_then(|ext| ext.to_str()) != Some("harn")
324 {
325 return Err(PackError::new(
326 "entrypoint.invalid",
327 format!(
328 "entrypoint must be a .harn file: {}",
329 entrypoint_input.display()
330 ),
331 ));
332 }
333 if args.exclude_secrets && path_looks_like_secret(&entrypoint) {
334 return Err(PackError::new(
335 "pack.secret_blocked",
336 format!(
337 "entrypoint {} matches a secret-bearing path pattern; \
338 re-run with --include-secrets to override",
339 entrypoint_input.display()
340 ),
341 ));
342 }
343 let project_root = pack_archive_root(&entrypoint);
344 let entrypoint_rel = relativize(&project_root, &entrypoint).ok_or_else(|| {
345 PackError::new(
346 "entrypoint.outside_root",
347 format!(
348 "entrypoint {} could not be relativized against {}",
349 entrypoint.display(),
350 project_root.display()
351 ),
352 )
353 })?;
354
355 let prior = match &args.upgrade {
356 Some(path) => Some(load_workflow_bundle_any_version(path).map_err(|err| {
357 PackError::new(
358 "upgrade.read_failed",
359 format!("failed to read --upgrade source {}: {err}", path.display()),
360 )
361 })?),
362 None => None,
363 };
364
365 let graph = harn_modules::build(std::slice::from_ref(&entrypoint));
366 let mut graph_paths = graph.module_paths();
367 graph_paths.sort();
369 let mut module_paths: Vec<PathBuf> = graph_paths
370 .iter()
371 .filter(|path| is_harn_module_path(path))
372 .cloned()
373 .collect();
374 module_paths.sort();
375
376 let mut transitive_modules = Vec::new();
377 let mut contents = Vec::new();
378 let mut sbom_packages = Vec::new();
379 let mut sbom_relationships = Vec::new();
380 let mut warnings = Vec::new();
381 let mut skipped_assets = Vec::new();
382 let mut debug_symbol_metadata = PackDebugSymbolMetadata {
383 harnbc_count: 0,
384 total_bytes: 0,
385 };
386
387 let stdlib_version = bytecode_cache::HARN_VERSION.to_string();
388 let harn_version = bytecode_cache::HARN_VERSION.to_string();
389
390 sbom_packages.push(SBOMPackage {
391 name: "harn-stdlib".to_string(),
392 version: Some(stdlib_version.clone()),
393 package_hash_blake3: None,
394 license: None,
395 });
396
397 for module_path in &module_paths {
398 let module_str = module_path.to_string_lossy().to_string();
399 if module_str.starts_with("<std>/") {
400 let stdlib_name = module_str.trim_start_matches("<std>/").to_string();
401 sbom_packages.push(SBOMPackage {
402 name: format!("std/{stdlib_name}"),
403 version: Some(stdlib_version.clone()),
404 package_hash_blake3: None,
405 license: None,
406 });
407 sbom_relationships.push(SBOMRelationship {
408 from: format!("entrypoint:{}", entrypoint_rel.display()),
409 to: format!("std/{stdlib_name}"),
410 relationship_type: "depends_on".to_string(),
411 });
412 continue;
413 }
414
415 let source = std::fs::read_to_string(module_path).map_err(|err| {
416 PackError::new(
417 "module.read_failed",
418 format!("failed to read {}: {err}", module_path.display()),
419 )
420 })?;
421
422 let (parsed_source, program) = parse_source_file(&module_str);
423 debug_assert_eq!(parsed_source, source);
424 type_check_or_fail(&source, &module_str, &program)?;
425
426 let entry_chunk = Compiler::new().compile(&program).map_err(|err| {
427 PackError::new(
428 "module.compile_failed",
429 format!("compile error in {}: {err}", module_path.display()),
430 )
431 })?;
432
433 let module_artifact_opt =
434 module_artifact::compile_module_artifact(&program, Some(module_str.clone())).ok();
435
436 let cache_key = bytecode_cache::CacheKey::from_source(module_path, &source);
437 let chunk_bytes = bytecode_cache::serialize_chunk_artifact(&cache_key, &entry_chunk)
438 .map_err(|err| {
439 PackError::new(
440 "module.serialize_failed",
441 format!(
442 "failed to serialize chunk for {}: {err}",
443 module_path.display()
444 ),
445 )
446 })?;
447
448 let module_artifact_bytes = match module_artifact_opt.as_ref() {
449 Some(artifact) => Some(
450 bytecode_cache::serialize_module_artifact(&cache_key, artifact).map_err(|err| {
451 PackError::new(
452 "module.serialize_failed",
453 format!(
454 "failed to serialize module artifact for {}: {err}",
455 module_path.display()
456 ),
457 )
458 })?,
459 ),
460 None => None,
461 };
462
463 let rel = relativize(&project_root, module_path).ok_or_else(|| {
464 PackError::new(
465 "module.outside_root",
466 format!(
467 "module {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
468 module_path.display(),
469 project_root.display()
470 ),
471 )
472 })?;
473 let source_archive_path = PathBuf::from("sources").join(&rel);
474 let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
475 .ok_or_else(|| {
476 PackError::new(
477 "module.invalid_path",
478 format!("module path has no stem: {}", module_path.display()),
479 )
480 })?;
481 let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
482
483 let source_hash = blake3_hash(source.as_bytes());
484 let harnbc_hash = blake3_hash(&chunk_bytes);
485 debug_symbol_metadata.harnbc_count += 1;
486 debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
487
488 transitive_modules.push(ModuleEntry {
489 path: rel.clone(),
490 source_hash_blake3: source_hash.clone(),
491 harnbc_hash_blake3: harnbc_hash.clone(),
492 });
493
494 contents.push(HarnpackEntry::new(
495 source_archive_path,
496 source.as_bytes().to_vec(),
497 ));
498 contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
499 if let Some(artifact_bytes) = module_artifact_bytes {
500 debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
501 let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
502 .ok_or_else(|| {
503 PackError::new(
504 "module.invalid_path",
505 format!("module path has no stem: {}", module_path.display()),
506 )
507 })?;
508 let module_archive_path = PathBuf::from("bytecode").join(module_rel);
509 contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
510 }
511
512 if module_path != &entrypoint {
513 sbom_relationships.push(SBOMRelationship {
514 from: format!("entrypoint:{}", entrypoint_rel.display()),
515 to: format!("module:{}", rel.display()),
516 relationship_type: "depends_on".to_string(),
517 });
518 }
519 sbom_packages.push(SBOMPackage {
520 name: format!("module:{}", rel.display()),
521 version: Some(harn_version.clone()),
522 package_hash_blake3: Some(source_hash),
523 license: None,
524 });
525 }
526
527 for asset in discover_import_assets(&graph, &module_paths, &project_root)? {
528 if args.exclude_secrets && path_looks_like_secret(&asset.path) {
529 warnings.push(JsonWarning {
530 code: "pack.asset_skipped_secret".to_string(),
531 message: format!(
532 "skipped imported asset {} because it matches a secret-bearing path pattern",
533 asset.rel.display()
534 ),
535 });
536 skipped_assets.push(SkippedAsset {
537 path: asset.rel.clone(),
538 reason: "secret_path".to_string(),
539 });
540 continue;
541 }
542
543 let bytes = std::fs::read(&asset.path).map_err(|err| {
544 PackError::new(
545 "asset.read_failed",
546 format!(
547 "failed to read imported asset {}: {err}",
548 asset.path.display()
549 ),
550 )
551 })?;
552 let asset_hash = blake3_hash(&bytes);
553 contents.push(HarnpackEntry::new(
554 PathBuf::from("sources").join(&asset.rel),
555 bytes,
556 ));
557 sbom_packages.push(SBOMPackage {
558 name: format!("asset:{}", asset.rel.display()),
559 version: Some(harn_version.clone()),
560 package_hash_blake3: Some(asset_hash),
561 license: None,
562 });
563 sbom_relationships.push(SBOMRelationship {
564 from: format!("entrypoint:{}", entrypoint_rel.display()),
565 to: format!("asset:{}", asset.rel.display()),
566 relationship_type: "depends_on".to_string(),
567 });
568 }
569
570 if transitive_modules.is_empty() {
571 return Err(PackError::new(
572 "pack.no_modules",
573 format!(
574 "no Harn modules resolved from entrypoint {}",
575 entrypoint.display()
576 ),
577 ));
578 }
579
580 let provider_catalog = harn_vm::provider_catalog::artifact();
581 let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
582 PackError::new(
583 "provider_catalog.failed",
584 format!("failed to serialize provider catalog snapshot: {err}"),
585 )
586 })?;
587 let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
588 sbom_packages.push(SBOMPackage {
589 name: "harn-provider-catalog".to_string(),
590 version: Some(harn_version.clone()),
591 package_hash_blake3: Some(provider_catalog_hash.clone()),
592 license: None,
593 });
594 sbom_relationships.push(SBOMRelationship {
595 from: format!("entrypoint:{}", entrypoint_rel.display()),
596 to: "harn-provider-catalog".to_string(),
597 relationship_type: "depends_on".to_string(),
598 });
599 for provider in &provider_catalog.providers {
600 let provider_name = format!("provider:{}", provider.id);
601 sbom_packages.push(SBOMPackage {
602 name: provider_name.clone(),
603 version: None,
604 package_hash_blake3: None,
605 license: None,
606 });
607 sbom_relationships.push(SBOMRelationship {
608 from: "harn-provider-catalog".to_string(),
609 to: provider_name,
610 relationship_type: "contains".to_string(),
611 });
612 }
613
614 let tool_manifest: Vec<ToolEntry> = Vec::new();
617 for tool in &tool_manifest {
618 sbom_packages.push(SBOMPackage {
619 name: format!("tool:{}", tool.name),
620 version: None,
621 package_hash_blake3: tool.schema_hash_blake3.clone(),
622 license: None,
623 });
624 sbom_relationships.push(SBOMRelationship {
625 from: format!("entrypoint:{}", entrypoint_rel.display()),
626 to: format!("tool:{}", tool.name),
627 relationship_type: "depends_on".to_string(),
628 });
629 }
630 let mut bundle = assemble_bundle(
631 &entrypoint_rel,
632 transitive_modules,
633 stdlib_version,
634 harn_version,
635 provider_catalog_hash,
636 tool_manifest,
637 SBOMDoc {
638 format: "spdx-lite".to_string(),
639 version: "2.3".to_string(),
640 packages: sbom_packages,
641 relationships: sbom_relationships,
642 },
643 prior.as_ref(),
644 );
645 if !skipped_assets.is_empty() {
646 bundle.metadata.insert(
647 "skipped_assets".to_string(),
648 serde_json::to_value(&skipped_assets).map_err(|err| {
649 PackError::new(
650 "pack.metadata_failed",
651 format!("failed to render skipped asset metadata: {err}"),
652 )
653 })?,
654 );
655 }
656 sort_sbom_doc(&mut bundle.sbom);
657 let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
658 PackError::new(
659 "pack.sbom_failed",
660 format!("failed to render SBOM document: {err}"),
661 )
662 })?;
663 contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
664
665 if args.sign {
666 let key_path = args.key.as_ref().expect("checked above");
667 sign_bundle(&mut bundle, &contents, key_path)?;
668 }
669
670 let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
671 PackError::new(
672 "pack.hash_failed",
673 format!("failed to compute bundle hash: {err}"),
674 )
675 })?;
676 let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
677 PackError::new(
678 "pack.archive_failed",
679 format!("failed to assemble .harnpack archive: {err}"),
680 )
681 })?;
682
683 let output_path = resolve_output_path(&args.out, &entrypoint);
684 if let Some(parent) = output_path.parent() {
685 if !parent.as_os_str().is_empty() {
686 std::fs::create_dir_all(parent).map_err(|err| {
687 PackError::new(
688 "pack.output_dir_failed",
689 format!("failed to create output dir {}: {err}", parent.display()),
690 )
691 })?;
692 }
693 }
694 std::fs::write(&output_path, &archive_bytes).map_err(|err| {
695 PackError::new(
696 "pack.write_failed",
697 format!("failed to write {}: {err}", output_path.display()),
698 )
699 })?;
700 let size_bytes = archive_bytes.len() as u64;
701 emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
702
703 Ok(PackOutcome {
704 bundle_hash: bundle_hash.clone(),
705 output_path: output_path.clone(),
706 size_bytes,
707 json: PackJsonData {
708 bundle_hash,
709 output_path,
710 size_bytes,
711 signature: signature_summary(&bundle),
712 sbom_summary: sbom_summary(&bundle),
713 debug_symbol_metadata,
714 manifest: bundle,
715 },
716 warnings,
717 })
718}
719
720fn sign_bundle(
721 bundle: &mut WorkflowBundle,
722 contents: &[HarnpackEntry],
723 key_path: &Path,
724) -> Result<(), PackError> {
725 let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
726 PackError::new(
727 "pack.sign_key_failed",
728 format!("failed to load signing key {}: {err}", key_path.display()),
729 )
730 })?;
731 let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
732 PackError::new(
733 "pack.hash_failed",
734 format!("failed to compute bundle hash before signing: {err}"),
735 )
736 })?;
737 let verifying_key = signing_key.verifying_key();
738 let signature = signing_key.sign(bundle_hash.as_bytes());
739 bundle.signature = Some(Ed25519Signature {
740 key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
741 public_key: hex_encode(&verifying_key.to_bytes()),
742 signature: hex_encode(&signature.to_bytes()),
743 manifest_hash_blake3: bundle_hash,
744 algorithm: "ed25519".to_string(),
745 });
746 Ok(())
747}
748
749fn hex_encode(bytes: &[u8]) -> String {
750 let mut out = String::with_capacity(bytes.len() * 2);
751 for byte in bytes {
752 out.push_str(&format!("{byte:02x}"));
753 }
754 out
755}
756
757fn emit_release_trust_record(
758 project_root: &Path,
759 bundle_hash: &str,
760 harn_version: &str,
761 signed: bool,
762) -> Result<TrustRecord, PackError> {
763 let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
764 PackError::new(
765 "pack.trust_log_failed",
766 format!(
767 "failed to open OpenTrustGraph event log under {}: {err}",
768 project_root.display()
769 ),
770 )
771 })?;
772 let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
773 &log,
774 &harn_vm::TrustQueryFilters::default(),
775 ))
776 .map_err(|err| {
777 PackError::new(
778 "pack.trust_query_failed",
779 format!("failed to query prior OpenTrustGraph records: {err}"),
780 )
781 })?
782 .last()
783 .map(|record| record.record_id.clone());
784 let mut record = TrustRecord::release(
785 std::env::var("USER")
786 .ok()
787 .filter(|value| !value.trim().is_empty())
788 .unwrap_or_else(|| "harn-pack".to_string()),
789 bundle_hash.to_string(),
790 harn_version.to_string(),
791 parent_trust_record_id,
792 format!("harnpack-release-{}", uuid::Uuid::now_v7()),
793 if signed {
794 AutonomyTier::ActAuto
795 } else {
796 AutonomyTier::Suggest
797 },
798 );
799 record
800 .metadata
801 .insert("signed".to_string(), serde_json::json!(signed));
802 futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
803 PackError::new(
804 "pack.trust_record_failed",
805 format!("failed to append OpenTrustGraph release record: {err}"),
806 )
807 })
808}
809
810fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
811 match &bundle.signature {
812 Some(signature) => PackSignatureSummary {
813 algorithm: signature.algorithm.clone(),
814 key_id: signature.key_id.clone(),
815 present: true,
816 },
817 None => PackSignatureSummary {
818 algorithm: "ed25519".to_string(),
819 key_id: None,
820 present: false,
821 },
822 }
823}
824
825fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
826 let stdlib_modules = bundle
827 .sbom
828 .packages
829 .iter()
830 .filter(|package| package.name.starts_with("std/"))
831 .count();
832 let providers = bundle
833 .sbom
834 .packages
835 .iter()
836 .filter(|package| package.name.starts_with("provider:"))
837 .count();
838 PackSbomSummary {
839 components: bundle.sbom.packages.len(),
840 stdlib_modules,
841 providers,
842 tools: bundle.tool_manifest.len(),
843 }
844}
845
846#[derive(Debug)]
847struct ImportedAsset {
848 path: PathBuf,
849 rel: PathBuf,
850}
851
852#[derive(Debug, Serialize, Deserialize)]
853struct SkippedAsset {
854 path: PathBuf,
855 reason: String,
856}
857
858fn discover_import_assets(
859 graph: &harn_modules::ModuleGraph,
860 module_paths: &[PathBuf],
861 project_root: &Path,
862) -> Result<Vec<ImportedAsset>, PackError> {
863 let mut assets = BTreeMap::<PathBuf, ImportedAsset>::new();
864 for module_path in module_paths {
865 if module_path.to_string_lossy().starts_with("<std>/") {
866 continue;
867 }
868 for import in graph.imports_for_module(module_path) {
869 let Some(resolved_path) = import.resolved_path else {
870 continue;
871 };
872 if is_harn_module_path(&resolved_path) {
873 continue;
874 }
875 let canonical = resolved_path
876 .canonicalize()
877 .unwrap_or_else(|_| resolved_path.clone());
878 let rel = relativize(project_root, &canonical).ok_or_else(|| {
879 PackError::new(
880 "asset.outside_root",
881 format!(
882 "imported asset {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
883 canonical.display(),
884 project_root.display()
885 ),
886 )
887 })?;
888 assets.entry(canonical.clone()).or_insert(ImportedAsset {
889 path: canonical,
890 rel,
891 });
892 }
893 }
894 Ok(assets.into_values().collect())
895}
896
897fn is_harn_module_path(path: &Path) -> bool {
898 path.to_string_lossy().starts_with("<std>/")
899 || path.extension().and_then(|ext| ext.to_str()) == Some("harn")
900}
901
902fn sort_sbom_doc(sbom: &mut SBOMDoc) {
903 sbom.packages.sort_by(|left, right| {
904 (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
905 &right.name,
906 &right.version,
907 &right.package_hash_blake3,
908 ))
909 });
910 sbom.relationships.sort_by(|left, right| {
911 (&left.from, &left.to, &left.relationship_type).cmp(&(
912 &right.from,
913 &right.to,
914 &right.relationship_type,
915 ))
916 });
917}
918
919fn assemble_bundle(
920 entrypoint_rel: &Path,
921 transitive_modules: Vec<ModuleEntry>,
922 stdlib_version: String,
923 harn_version: String,
924 provider_catalog_hash: String,
925 tool_manifest: Vec<ToolEntry>,
926 sbom: SBOMDoc,
927 prior: Option<&WorkflowBundle>,
928) -> WorkflowBundle {
929 let stem = entrypoint_rel
930 .file_stem()
931 .map(|s| s.to_string_lossy().into_owned())
932 .unwrap_or_else(|| "harnpack".to_string());
933
934 let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
935 id: stem.clone(),
936 name: Some(stem.clone()),
937 version: "0.0.0".to_string(),
938 workflow: degenerate_workflow(&stem),
939 triggers: vec![WorkflowBundleTrigger {
940 id: "manual".to_string(),
941 kind: "manual".to_string(),
942 node_id: Some("entry".to_string()),
943 ..WorkflowBundleTrigger::default()
944 }],
945 policy: WorkflowBundlePolicy {
946 autonomy_tier: "act_with_approval".to_string(),
947 tool_policy: BTreeMap::new(),
948 approval_required: Vec::new(),
949 retry: RetryPolicySpec {
950 max_attempts: 1,
951 backoff: "none".to_string(),
952 },
953 catchup: CatchupPolicySpec {
954 mode: "none".to_string(),
955 max_events: None,
956 },
957 },
958 connectors: Vec::<ConnectorRequirement>::new(),
959 environment: EnvironmentRequirements::default(),
960 receipts: WorkflowBundleReplayMetadata::default(),
961 ..WorkflowBundle::default()
962 });
963
964 bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
965 bundle.entrypoint = entrypoint_rel.to_path_buf();
966 bundle.transitive_modules = transitive_modules;
967 bundle.stdlib_version = stdlib_version;
968 bundle.harn_version = harn_version;
969 bundle.provider_catalog_hash = provider_catalog_hash;
970 bundle.tool_manifest = tool_manifest;
971 bundle.sbom = sbom;
972 bundle.signature = None;
973 bundle
974}
975
976fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
977 use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
978 let mut nodes = BTreeMap::new();
979 nodes.insert(
980 "entry".to_string(),
981 WorkflowNode {
982 id: Some("entry".to_string()),
983 kind: "action".to_string(),
984 task_label: Some(stem.to_string()),
985 ..WorkflowNode::default()
986 },
987 );
988 WorkflowGraph {
989 type_name: "workflow_graph".to_string(),
990 id: format!("{stem}_pack"),
991 name: Some(stem.to_string()),
992 version: 1,
993 entry: "entry".to_string(),
994 nodes,
995 ..WorkflowGraph::default()
996 }
997}
998
999fn type_check_or_fail(
1000 source: &str,
1001 path: &str,
1002 program: &[harn_parser::SNode],
1003) -> Result<(), PackError> {
1004 let mut had_error = false;
1005 let mut messages = String::new();
1006 for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
1007 let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
1008 if matches!(diag.severity, DiagnosticSeverity::Error) {
1009 had_error = true;
1010 }
1011 messages.push_str(&rendered);
1012 }
1013 if had_error {
1014 return Err(PackError::new(
1015 "module.type_error",
1016 format!("type errors in {path}:\n{messages}"),
1017 ));
1018 }
1019 if !messages.is_empty() {
1020 eprint!("{messages}");
1021 }
1022 Ok(())
1023}
1024
1025fn pack_archive_root(entrypoint: &Path) -> PathBuf {
1026 let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1027 harn_modules::asset_paths::find_project_root(parent).unwrap_or_else(|| parent.to_path_buf())
1028}
1029
1030fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
1031 let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1032 let target_canon = target
1033 .canonicalize()
1034 .unwrap_or_else(|_| target.to_path_buf());
1035 if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
1036 return Some(rel.to_path_buf());
1037 }
1038 None
1039}
1040
1041fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
1042 let stem = rel.file_stem()?.to_string_lossy().into_owned();
1043 if stem.is_empty() {
1044 return None;
1045 }
1046 let parent_components: Vec<Component<'_>> = rel
1047 .parent()
1048 .map(|p| p.components().collect())
1049 .unwrap_or_default();
1050 let mut adjacent = PathBuf::new();
1051 for component in parent_components {
1052 adjacent.push(component.as_os_str());
1053 }
1054 let mut filename = stem;
1055 filename.push('.');
1056 filename.push_str(extension);
1057 adjacent.push(filename);
1058 Some(adjacent)
1059}
1060
1061fn blake3_hash(bytes: &[u8]) -> String {
1062 format!("blake3:{}", blake3::hash(bytes))
1063}
1064
1065fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
1066 if let Some(path) = out {
1067 return path.clone();
1068 }
1069 let stem = entrypoint
1070 .file_stem()
1071 .map(|s| s.to_string_lossy().into_owned())
1072 .unwrap_or_else(|| "bundle".to_string());
1073 let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1074 parent.join(format!("{stem}.harnpack"))
1075}
1076
1077pub(crate) fn path_looks_like_secret(path: &Path) -> bool {
1082 let lower_name = path
1083 .file_name()
1084 .map(|s| s.to_string_lossy().to_ascii_lowercase())
1085 .unwrap_or_default();
1086 if lower_name == ".env" || lower_name.starts_with(".env.") {
1087 return true;
1088 }
1089 if lower_name.starts_with("credentials") {
1090 return true;
1091 }
1092 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1093 let ext = ext.to_ascii_lowercase();
1094 if ext == "pem" || ext == "key" {
1095 return true;
1096 }
1097 }
1098 for component in path.components() {
1099 if let Component::Normal(part) = component {
1100 if part.to_string_lossy().eq_ignore_ascii_case("secrets") {
1101 return true;
1102 }
1103 }
1104 }
1105 false
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111 use std::fs;
1112
1113 fn build_args(entrypoint: PathBuf, out: PathBuf) -> BuildArgs {
1114 BuildArgs {
1115 entrypoint,
1116 out: Some(out),
1117 upgrade: None,
1118 sign: false,
1119 key: None,
1120 unsigned: true,
1121 exclude_secrets: false,
1122 json: true,
1123 }
1124 }
1125
1126 #[test]
1127 fn pack_uses_nearest_harn_toml_root_for_nested_entrypoint_assets() {
1128 let temp = tempfile::tempdir().unwrap();
1129 fs::write(
1130 temp.path().join("harn.toml"),
1131 "[package]\nname = \"pack-root\"\n",
1132 )
1133 .unwrap();
1134 fs::create_dir_all(temp.path().join("scripts")).unwrap();
1135 fs::create_dir_all(temp.path().join("assets")).unwrap();
1136 fs::write(temp.path().join("assets/prompt.txt"), "prompt asset\n").unwrap();
1137 fs::write(
1138 temp.path().join("scripts/entry.harn"),
1139 "import \"../assets/prompt.txt\"\n__io_println(\"packed\")\n",
1140 )
1141 .unwrap();
1142
1143 let outcome = build(&build_args(
1144 temp.path().join("scripts/entry.harn"),
1145 temp.path().join("bundle.harnpack"),
1146 ))
1147 .unwrap();
1148
1149 assert_eq!(
1150 outcome.json.manifest.entrypoint,
1151 PathBuf::from("scripts/entry.harn")
1152 );
1153 assert!(outcome
1154 .json
1155 .manifest
1156 .sbom
1157 .packages
1158 .iter()
1159 .any(|package| package.name == "asset:assets/prompt.txt"));
1160 }
1161
1162 #[test]
1163 fn pack_rejects_imported_asset_outside_archive_root() {
1164 let temp = tempfile::tempdir().unwrap();
1165 let root = temp.path().join("root");
1166 let outside = temp.path().join("outside");
1167 fs::create_dir_all(&root).unwrap();
1168 fs::create_dir_all(&outside).unwrap();
1169 fs::write(outside.join("prompt.txt"), "outside asset\n").unwrap();
1170 fs::write(
1171 root.join("entry.harn"),
1172 "import \"../outside/prompt.txt\"\n__io_println(\"packed\")\n",
1173 )
1174 .unwrap();
1175
1176 let err = build(&build_args(
1177 root.join("entry.harn"),
1178 root.join("bundle.harnpack"),
1179 ))
1180 .unwrap_err();
1181
1182 assert_eq!(err.code, "asset.outside_root");
1183 assert!(!root.join("bundle.harnpack").exists());
1184 }
1185}
1186
1187pub const PACK_VERIFY_SCHEMA_VERSION: u32 = 1;
1193
1194#[derive(Debug, Clone, Serialize)]
1196pub struct PackVerifyJsonData {
1197 pub bundle: PathBuf,
1198 pub bundle_hash: String,
1199 pub recorded_bundle_hash: Option<String>,
1200 pub signature_present: bool,
1201 pub signature_verified: bool,
1202 pub key_id: Option<String>,
1203 pub schema_version: u32,
1204 pub entrypoint: PathBuf,
1205 pub module_count: usize,
1206 pub content_entry_count: usize,
1207}
1208
1209struct PackVerifyJsonOutput(PackVerifyJsonData);
1210
1211impl JsonOutput for PackVerifyJsonOutput {
1212 const SCHEMA_VERSION: u32 = PACK_VERIFY_SCHEMA_VERSION;
1213 type Data = PackVerifyJsonData;
1214 fn into_envelope(self) -> JsonEnvelope<Self::Data> {
1215 JsonEnvelope::ok(Self::SCHEMA_VERSION, self.0)
1216 }
1217}
1218
1219pub fn verify_json_schema() -> serde_json::Value {
1222 serde_json::json!({
1223 "$schema": "https://json-schema.org/draft/2020-12/schema",
1224 "title": "harn pack verify --json",
1225 "type": "object",
1226 "required": ["schemaVersion", "ok", "data", "warnings"],
1227 "properties": {
1228 "schemaVersion": { "const": PACK_VERIFY_SCHEMA_VERSION },
1229 "ok": { "type": "boolean" },
1230 "warnings": { "type": "array" },
1231 "data": {
1232 "type": "object",
1233 "required": [
1234 "bundle",
1235 "bundle_hash",
1236 "signature_present",
1237 "signature_verified",
1238 "recorded_bundle_hash",
1239 "key_id",
1240 "schema_version",
1241 "entrypoint",
1242 "module_count",
1243 "content_entry_count"
1244 ],
1245 "properties": {
1246 "bundle": { "type": "string", "minLength": 1 },
1247 "bundle_hash": { "type": "string", "pattern": "^blake3:" },
1248 "recorded_bundle_hash": { "type": ["string", "null"] },
1249 "signature_present": { "type": "boolean" },
1250 "signature_verified": { "type": "boolean" },
1251 "key_id": { "type": ["string", "null"] },
1252 "schema_version": { "type": "integer", "minimum": 1 },
1253 "entrypoint": { "type": "string", "minLength": 1 },
1254 "module_count": { "type": "integer", "minimum": 1 },
1255 "content_entry_count": { "type": "integer", "minimum": 1 }
1256 }
1257 }
1258 }
1259 })
1260}
1261
1262pub fn run_verify(args: PackVerifyArgs) {
1265 match verify(&args) {
1266 Ok(outcome) => {
1267 if args.json {
1268 let envelope = PackVerifyJsonOutput(outcome).into_envelope();
1269 println!("{}", to_string_pretty(&envelope));
1270 } else {
1271 println!(
1272 "ok {} (bundle_hash {}, signature_verified={})",
1273 outcome.bundle.display(),
1274 outcome.bundle_hash,
1275 outcome.signature_verified
1276 );
1277 }
1278 }
1279 Err(err) => {
1280 if args.json {
1281 let envelope: JsonEnvelope<PackVerifyJsonData> =
1282 JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message);
1283 println!("{}", to_string_pretty(&envelope));
1284 process::exit(1);
1285 }
1286 command_error(&err.message);
1287 }
1288 }
1289}
1290
1291pub fn verify_to_envelope(args: &PackVerifyArgs) -> JsonEnvelope<PackVerifyJsonData> {
1294 match verify(args) {
1295 Ok(outcome) => PackVerifyJsonOutput(outcome).into_envelope(),
1296 Err(err) => JsonEnvelope::err(PACK_VERIFY_SCHEMA_VERSION, err.code, err.message),
1297 }
1298}
1299
1300pub fn verify(args: &PackVerifyArgs) -> Result<PackVerifyJsonData, PackError> {
1313 let bytes = std::fs::read(&args.bundle).map_err(|err| {
1314 PackError::new(
1315 "verify.read_failed",
1316 format!("failed to read {}: {err}", args.bundle.display()),
1317 )
1318 })?;
1319 let archive = read_harnpack(&bytes).map_err(|err| {
1320 PackError::new(
1321 "verify.archive_failed",
1322 format!("failed to parse {}: {err}", args.bundle.display()),
1323 )
1324 })?;
1325 let manifest = &archive.manifest;
1326 let contents = &archive.contents;
1327
1328 let expected_hash = workflow_bundle_hash(manifest, contents).map_err(|err| {
1329 PackError::new(
1330 "verify.hash_failed",
1331 format!("failed to recompute bundle hash: {err}"),
1332 )
1333 })?;
1334
1335 let trust_policy = args
1336 .trust_policy
1337 .as_deref()
1338 .map(skill_provenance::load_trust_policy)
1339 .transpose()
1340 .map_err(|err| PackError::new("verify.trust_policy_failed", err))?;
1341 let signature_present = manifest.signature.is_some();
1342 let mut signature_verified = false;
1343 let mut key_id = None;
1344 if let Some(signature) = manifest.signature.as_ref() {
1345 key_id = signature.key_id.clone();
1346 verify_workflow_bundle_signature(manifest, contents)
1347 .map_err(|err| PackError::new("verify.signature_failed", err.message.clone()))?;
1348 if args.require_trusted_signer {
1349 let signer_fingerprint = bundle_signer_fingerprint(signature).map_err(|err| {
1350 PackError::new(
1351 "verify.signature_failed",
1352 format!("invalid bundle signer: {err}"),
1353 )
1354 })?;
1355 match skill_provenance::check_trusted_signer(&signer_fingerprint, trust_policy.as_ref())
1356 .map_err(|err| PackError::new("verify.trust_policy_failed", err))?
1357 {
1358 skill_provenance::TrustedSignerStatus::Trusted => {}
1359 skill_provenance::TrustedSignerStatus::MissingSigner => {
1360 return Err(PackError::new(
1361 "verify.untrusted_signer",
1362 format!(
1363 "bundle {} was signed by {}, but that signer is not present in the trusted signer registry",
1364 args.bundle.display(),
1365 signer_fingerprint
1366 ),
1367 ));
1368 }
1369 skill_provenance::TrustedSignerStatus::UntrustedSigner => {
1370 return Err(PackError::new(
1371 "verify.untrusted_signer",
1372 format!(
1373 "bundle {} was signed by {}, which is not in the trust policy's trusted_signers allowlist",
1374 args.bundle.display(),
1375 signer_fingerprint
1376 ),
1377 ));
1378 }
1379 }
1380 }
1381 signature_verified = true;
1382 key_id.get_or_insert(
1383 signer_fingerprint_from_public_key(&signature.public_key).map_err(|err| {
1384 PackError::new(
1385 "verify.signature_failed",
1386 format!("invalid bundle signer: {err}"),
1387 )
1388 })?,
1389 );
1390 } else if args.require_trusted_signer {
1391 return Err(PackError::new(
1392 "verify.untrusted_signer",
1393 format!(
1394 "bundle {} is unsigned and cannot satisfy --require-trusted-signer",
1395 args.bundle.display()
1396 ),
1397 ));
1398 } else if !args.allow_unsigned {
1399 return Err(PackError::new(
1400 "verify.unsigned",
1401 format!(
1402 "refusing to verify unsigned bundle {} (re-run with --allow-unsigned)",
1403 args.bundle.display()
1404 ),
1405 ));
1406 }
1407
1408 let mut source_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
1409 let mut bytecode_map: BTreeMap<PathBuf, &HarnpackEntry> = BTreeMap::new();
1410 let mut archive_hashes: BTreeMap<PathBuf, String> = BTreeMap::new();
1411 for entry in contents {
1412 archive_hashes.insert(entry.path.clone(), blake3_hash(&entry.bytes));
1413 if let Ok(rel) = entry.path.strip_prefix("sources") {
1414 source_map.insert(rel.to_path_buf(), entry);
1415 } else if let Ok(rel) = entry.path.strip_prefix("bytecode") {
1416 bytecode_map.insert(rel.to_path_buf(), entry);
1417 }
1418 }
1419
1420 for module in &manifest.transitive_modules {
1421 let source_entry = source_map.get(&module.path).ok_or_else(|| {
1422 PackError::new(
1423 "verify.module_missing",
1424 format!(
1425 "manifest lists module {} but archive has no sources/{} entry",
1426 module.path.display(),
1427 module.path.display()
1428 ),
1429 )
1430 })?;
1431 let actual_source = blake3_hash(&source_entry.bytes);
1432 if actual_source != module.source_hash_blake3 {
1433 return Err(PackError::new(
1434 "verify.source_mismatch",
1435 format!(
1436 "source hash mismatch for {}: manifest {}, archive {}",
1437 module.path.display(),
1438 module.source_hash_blake3,
1439 actual_source
1440 ),
1441 ));
1442 }
1443 let chunk_rel = adjacent_with_extension(&module.path, bytecode_cache::CACHE_EXTENSION)
1444 .ok_or_else(|| {
1445 PackError::new(
1446 "verify.module_invalid_path",
1447 format!("module {} has no stem", module.path.display()),
1448 )
1449 })?;
1450 let chunk_entry = bytecode_map.get(&chunk_rel).ok_or_else(|| {
1451 PackError::new(
1452 "verify.module_missing",
1453 format!(
1454 "manifest lists bytecode for {} but archive has no bytecode/{} entry",
1455 module.path.display(),
1456 chunk_rel.display()
1457 ),
1458 )
1459 })?;
1460 let actual_harnbc = blake3_hash(&chunk_entry.bytes);
1461 if actual_harnbc != module.harnbc_hash_blake3 {
1462 return Err(PackError::new(
1463 "verify.bytecode_mismatch",
1464 format!(
1465 "bytecode hash mismatch for {}: manifest {}, archive {}",
1466 module.path.display(),
1467 module.harnbc_hash_blake3,
1468 actual_harnbc
1469 ),
1470 ));
1471 }
1472 }
1473
1474 if args.strict {
1475 verify_sbom_package_hashes(manifest, &archive_hashes)?;
1476 }
1477
1478 let recorded_bundle_hash = manifest
1484 .signature
1485 .as_ref()
1486 .map(|sig| sig.manifest_hash_blake3.clone());
1487 if let Some(recorded) = &recorded_bundle_hash {
1488 if recorded != &expected_hash {
1489 return Err(PackError::new(
1490 "verify.recorded_hash_mismatch",
1491 format!(
1492 "recorded signature manifest hash {recorded} does not match recomputed {expected_hash}"
1493 ),
1494 ));
1495 }
1496 }
1497
1498 Ok(PackVerifyJsonData {
1499 bundle: args.bundle.clone(),
1500 bundle_hash: expected_hash,
1501 recorded_bundle_hash,
1502 signature_present,
1503 signature_verified,
1504 key_id,
1505 schema_version: manifest.schema_version,
1506 entrypoint: manifest.entrypoint.clone(),
1507 module_count: manifest.transitive_modules.len(),
1508 content_entry_count: contents.len(),
1509 })
1510}
1511
1512fn verify_sbom_package_hashes(
1513 manifest: &WorkflowBundle,
1514 archive_hashes: &BTreeMap<PathBuf, String>,
1515) -> Result<(), PackError> {
1516 let module_hashes: BTreeMap<&Path, &str> = manifest
1517 .transitive_modules
1518 .iter()
1519 .map(|module| (module.path.as_path(), module.source_hash_blake3.as_str()))
1520 .collect();
1521
1522 for package in &manifest.sbom.packages {
1523 let Some(expected_hash) = package.package_hash_blake3.as_deref() else {
1524 continue;
1525 };
1526
1527 if let Some(rel) = package.name.strip_prefix("module:") {
1528 let module_path = Path::new(rel);
1529 let manifest_hash = module_hashes.get(module_path).ok_or_else(|| {
1530 PackError::new(
1531 "verify.sbom_mismatch",
1532 format!(
1533 "SBOM package {} does not match any manifest transitive module",
1534 package.name
1535 ),
1536 )
1537 })?;
1538 if *manifest_hash != expected_hash {
1539 return Err(PackError::new(
1540 "verify.sbom_mismatch",
1541 format!(
1542 "SBOM package {} recorded hash {} but manifest module {} uses {}",
1543 package.name,
1544 expected_hash,
1545 module_path.display(),
1546 manifest_hash
1547 ),
1548 ));
1549 }
1550 let source_archive_path = PathBuf::from("sources").join(module_path);
1551 let archive_hash = archive_hashes.get(&source_archive_path).ok_or_else(|| {
1552 PackError::new(
1553 "verify.sbom_mismatch",
1554 format!(
1555 "SBOM package {} refers to {}, but archive is missing {}",
1556 package.name,
1557 module_path.display(),
1558 source_archive_path.display()
1559 ),
1560 )
1561 })?;
1562 if archive_hash != expected_hash {
1563 return Err(PackError::new(
1564 "verify.sbom_mismatch",
1565 format!(
1566 "SBOM package {} recorded hash {} but archive {} hashes to {}",
1567 package.name,
1568 expected_hash,
1569 source_archive_path.display(),
1570 archive_hash
1571 ),
1572 ));
1573 }
1574 continue;
1575 }
1576
1577 if let Some(rel) = package.name.strip_prefix("asset:") {
1578 let asset_archive_path = PathBuf::from("sources").join(rel);
1579 let archive_hash = archive_hashes.get(&asset_archive_path).ok_or_else(|| {
1580 PackError::new(
1581 "verify.sbom_mismatch",
1582 format!(
1583 "SBOM package {} refers to {}, but archive is missing {}",
1584 package.name,
1585 rel,
1586 asset_archive_path.display()
1587 ),
1588 )
1589 })?;
1590 if archive_hash != expected_hash {
1591 return Err(PackError::new(
1592 "verify.sbom_mismatch",
1593 format!(
1594 "SBOM package {} recorded hash {} but archive {} hashes to {}",
1595 package.name,
1596 expected_hash,
1597 asset_archive_path.display(),
1598 archive_hash
1599 ),
1600 ));
1601 }
1602 continue;
1603 }
1604
1605 let candidate_path = Path::new(&package.name);
1606 let Some(archive_hash) = archive_hashes.get(candidate_path) else {
1607 continue;
1608 };
1609 if archive_hash != expected_hash {
1610 return Err(PackError::new(
1611 "verify.sbom_mismatch",
1612 format!(
1613 "SBOM package {} recorded hash {} but archive {} hashes to {}",
1614 package.name,
1615 expected_hash,
1616 candidate_path.display(),
1617 archive_hash
1618 ),
1619 ));
1620 }
1621 }
1622
1623 Ok(())
1624}
1625
1626fn bundle_signer_fingerprint(signature: &Ed25519Signature) -> Result<String, String> {
1627 match signature.key_id.as_deref() {
1628 Some(key_id) if !key_id.trim().is_empty() => Ok(key_id.to_string()),
1629 _ => signer_fingerprint_from_public_key(&signature.public_key),
1630 }
1631}
1632
1633fn signer_fingerprint_from_public_key(public_key_hex: &str) -> Result<String, String> {
1634 let public_key_bytes = decode_hex_32(public_key_hex)?;
1635 let verifying_key = VerifyingKey::from_bytes(&public_key_bytes).map_err(|error| {
1636 format!("workflow bundle signature public_key is invalid Ed25519: {error}")
1637 })?;
1638 Ok(skill_provenance::fingerprint_for_key(&verifying_key))
1639}
1640
1641fn decode_hex_32(raw: &str) -> Result<[u8; 32], String> {
1642 let trimmed = raw.trim();
1643 if trimmed.len() != 64 {
1644 return Err(format!(
1645 "workflow bundle signature public_key must be 64 hex characters, found {}",
1646 trimmed.len()
1647 ));
1648 }
1649 let mut bytes = [0_u8; 32];
1650 for (idx, slot) in bytes.iter_mut().enumerate() {
1651 let start = idx * 2;
1652 let end = start + 2;
1653 *slot = u8::from_str_radix(&trimmed[start..end], 16).map_err(|error| {
1654 format!(
1655 "workflow bundle signature public_key contains invalid hex at byte {idx}: {error}"
1656 )
1657 })?;
1658 }
1659 Ok(bytes)
1660}