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