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