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