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(&source);
478 bytecode_cache::serialize_module_artifact(&module_cache_key, artifact).map_err(
479 |err| {
480 PackError::new(
481 "module.serialize_failed",
482 format!(
483 "failed to serialize module artifact for {}: {err}",
484 module_path.display()
485 ),
486 )
487 },
488 )
489 })
490 .transpose()?;
491
492 let rel = relativize(&project_root, module_path).ok_or_else(|| {
493 PackError::new(
494 "module.outside_root",
495 format!(
496 "module {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
497 module_path.display(),
498 project_root.display()
499 ),
500 )
501 })?;
502 let source_archive_path = PathBuf::from("sources").join(&rel);
503 let chunk_archive_path = adjacent_with_extension(&rel, bytecode_cache::CACHE_EXTENSION)
504 .ok_or_else(|| {
505 PackError::new(
506 "module.invalid_path",
507 format!("module path has no stem: {}", module_path.display()),
508 )
509 })?;
510 let chunk_archive_path = PathBuf::from("bytecode").join(chunk_archive_path);
511
512 let source_hash = blake3_hash(source.as_bytes());
513 let harnbc_hash = blake3_hash(&chunk_bytes);
514 debug_symbol_metadata.harnbc_count += 1;
515 debug_symbol_metadata.total_bytes += chunk_bytes.len() as u64;
516
517 transitive_modules.push(ModuleEntry {
518 path: rel.clone(),
519 source_hash_blake3: source_hash.clone(),
520 harnbc_hash_blake3: harnbc_hash.clone(),
521 });
522
523 contents.push(HarnpackEntry::new(
524 source_archive_path,
525 source.as_bytes().to_vec(),
526 ));
527 contents.push(HarnpackEntry::new(chunk_archive_path, chunk_bytes));
528 if let Some(artifact_bytes) = module_artifact_bytes {
529 debug_symbol_metadata.total_bytes += artifact_bytes.len() as u64;
530 let module_rel = adjacent_with_extension(&rel, bytecode_cache::MODULE_CACHE_EXTENSION)
531 .ok_or_else(|| {
532 PackError::new(
533 "module.invalid_path",
534 format!("module path has no stem: {}", module_path.display()),
535 )
536 })?;
537 let module_archive_path = PathBuf::from("bytecode").join(module_rel);
538 contents.push(HarnpackEntry::new(module_archive_path, artifact_bytes));
539 }
540
541 let module_id = logical_bundle_path(&rel);
542 if module_path != &entrypoint {
543 sbom_relationships.push(SBOMRelationship {
544 from: format!("entrypoint:{entrypoint_id}"),
545 to: format!("module:{module_id}"),
546 relationship_type: "depends_on".to_string(),
547 });
548 }
549 sbom_packages.push(SBOMPackage {
550 name: format!("module:{module_id}"),
551 version: Some(harn_version.clone()),
552 package_hash_blake3: Some(source_hash),
553 license: None,
554 });
555 }
556
557 for asset in discover_import_assets(&graph, &module_paths, &project_root)? {
558 if args.exclude_secrets && path_looks_like_secret(&asset.path) {
559 warnings.push(JsonWarning {
560 code: "pack.asset_skipped_secret".to_string(),
561 message: format!(
562 "skipped imported asset {} because it matches a secret-bearing path pattern",
563 asset.rel.display()
564 ),
565 });
566 skipped_assets.push(SkippedAsset {
567 path: logical_bundle_path(&asset.rel),
568 reason: "secret_path".to_string(),
569 });
570 continue;
571 }
572
573 let bytes = std::fs::read(&asset.path).map_err(|err| {
574 PackError::new(
575 "asset.read_failed",
576 format!(
577 "failed to read imported asset {}: {err}",
578 asset.path.display()
579 ),
580 )
581 })?;
582 let asset_hash = blake3_hash(&bytes);
583 let asset_id = logical_bundle_path(&asset.rel);
584 contents.push(HarnpackEntry::new(
585 PathBuf::from("sources").join(&asset.rel),
586 bytes,
587 ));
588 sbom_packages.push(SBOMPackage {
589 name: format!("asset:{asset_id}"),
590 version: Some(harn_version.clone()),
591 package_hash_blake3: Some(asset_hash),
592 license: None,
593 });
594 sbom_relationships.push(SBOMRelationship {
595 from: format!("entrypoint:{entrypoint_id}"),
596 to: format!("asset:{asset_id}"),
597 relationship_type: "depends_on".to_string(),
598 });
599 }
600
601 if transitive_modules.is_empty() {
602 return Err(PackError::new(
603 "pack.no_modules",
604 format!(
605 "no Harn modules resolved from entrypoint {}",
606 entrypoint.display()
607 ),
608 ));
609 }
610
611 let provider_catalog = harn_vm::provider_catalog::artifact();
612 let provider_catalog_bytes = serde_json::to_vec(&provider_catalog).map_err(|err| {
613 PackError::new(
614 "provider_catalog.failed",
615 format!("failed to serialize provider catalog snapshot: {err}"),
616 )
617 })?;
618 let provider_catalog_hash = blake3_hash(&provider_catalog_bytes);
619 sbom_packages.push(SBOMPackage {
620 name: "harn-provider-catalog".to_string(),
621 version: Some(harn_version.clone()),
622 package_hash_blake3: Some(provider_catalog_hash.clone()),
623 license: None,
624 });
625 sbom_relationships.push(SBOMRelationship {
626 from: format!("entrypoint:{entrypoint_id}"),
627 to: "harn-provider-catalog".to_string(),
628 relationship_type: "depends_on".to_string(),
629 });
630 for provider in &provider_catalog.providers {
631 let provider_name = format!("provider:{}", provider.id);
632 sbom_packages.push(SBOMPackage {
633 name: provider_name.clone(),
634 version: None,
635 package_hash_blake3: None,
636 license: None,
637 });
638 sbom_relationships.push(SBOMRelationship {
639 from: "harn-provider-catalog".to_string(),
640 to: provider_name,
641 relationship_type: "contains".to_string(),
642 });
643 }
644
645 let tool_manifest: Vec<ToolEntry> = Vec::new();
648 for tool in &tool_manifest {
649 sbom_packages.push(SBOMPackage {
650 name: format!("tool:{}", tool.name),
651 version: None,
652 package_hash_blake3: tool.schema_hash_blake3.clone(),
653 license: None,
654 });
655 sbom_relationships.push(SBOMRelationship {
656 from: format!("entrypoint:{entrypoint_id}"),
657 to: format!("tool:{}", tool.name),
658 relationship_type: "depends_on".to_string(),
659 });
660 }
661 let mut bundle = assemble_bundle(
662 &entrypoint_rel,
663 transitive_modules,
664 stdlib_version,
665 harn_version,
666 provider_catalog_hash,
667 tool_manifest,
668 SBOMDoc {
669 format: "spdx-lite".to_string(),
670 version: "2.3".to_string(),
671 packages: sbom_packages,
672 relationships: sbom_relationships,
673 },
674 prior.as_ref(),
675 );
676 if !skipped_assets.is_empty() {
677 bundle.metadata.insert(
678 "skipped_assets".to_string(),
679 serde_json::to_value(&skipped_assets).map_err(|err| {
680 PackError::new(
681 "pack.metadata_failed",
682 format!("failed to render skipped asset metadata: {err}"),
683 )
684 })?,
685 );
686 }
687 contents.extend(carry_extension_metadata(&project_root, &mut bundle)?);
697 sort_sbom_doc(&mut bundle.sbom);
698 let sbom_bytes = serde_json::to_vec_pretty(&bundle.sbom).map_err(|err| {
699 PackError::new(
700 "pack.sbom_failed",
701 format!("failed to render SBOM document: {err}"),
702 )
703 })?;
704 contents.push(HarnpackEntry::new(PACK_SBOM_ARCHIVE_PATH, sbom_bytes));
705
706 if args.sign {
707 let key_path = args.key.as_ref().expect("checked above");
708 sign_bundle(&mut bundle, &contents, key_path)?;
709 }
710
711 let bundle_hash = workflow_bundle_hash(&bundle, &contents).map_err(|err| {
712 PackError::new(
713 "pack.hash_failed",
714 format!("failed to compute bundle hash: {err}"),
715 )
716 })?;
717 let archive_bytes = build_harnpack(&bundle, &contents).map_err(|err| {
718 PackError::new(
719 "pack.archive_failed",
720 format!("failed to assemble .harnpack archive: {err}"),
721 )
722 })?;
723
724 let output_path = resolve_output_path(&args.out, &entrypoint);
725 if let Some(parent) = output_path.parent() {
726 if !parent.as_os_str().is_empty() {
727 std::fs::create_dir_all(parent).map_err(|err| {
728 PackError::new(
729 "pack.output_dir_failed",
730 format!("failed to create output dir {}: {err}", parent.display()),
731 )
732 })?;
733 }
734 }
735 std::fs::write(&output_path, &archive_bytes).map_err(|err| {
736 PackError::new(
737 "pack.write_failed",
738 format!("failed to write {}: {err}", output_path.display()),
739 )
740 })?;
741 let size_bytes = archive_bytes.len() as u64;
742 emit_release_trust_record(&project_root, &bundle_hash, &bundle.harn_version, args.sign)?;
743
744 Ok(PackOutcome {
745 bundle_hash: bundle_hash.clone(),
746 output_path: output_path.clone(),
747 size_bytes,
748 json: PackJsonData {
749 bundle_hash,
750 output_path,
751 size_bytes,
752 signature: signature_summary(&bundle),
753 sbom_summary: sbom_summary(&bundle),
754 debug_symbol_metadata,
755 manifest: bundle,
756 },
757 warnings,
758 })
759}
760
761fn sign_bundle(
762 bundle: &mut WorkflowBundle,
763 contents: &[HarnpackEntry],
764 key_path: &Path,
765) -> Result<(), PackError> {
766 let signing_key = skill_provenance::load_ed25519_signing_key(key_path).map_err(|err| {
767 PackError::new(
768 "pack.sign_key_failed",
769 format!("failed to load signing key {}: {err}", key_path.display()),
770 )
771 })?;
772 let bundle_hash = workflow_bundle_hash(bundle, contents).map_err(|err| {
773 PackError::new(
774 "pack.hash_failed",
775 format!("failed to compute bundle hash before signing: {err}"),
776 )
777 })?;
778 let verifying_key = signing_key.verifying_key();
779 let signature = signing_key.sign(bundle_hash.as_bytes());
780 bundle.signature = Some(Ed25519Signature {
781 key_id: Some(skill_provenance::fingerprint_for_key(&verifying_key)),
782 public_key: hex::encode(verifying_key.to_bytes()),
783 signature: hex::encode(signature.to_bytes()),
784 manifest_hash_blake3: bundle_hash,
785 algorithm: "ed25519".to_string(),
786 });
787 Ok(())
788}
789
790fn emit_release_trust_record(
791 project_root: &Path,
792 bundle_hash: &str,
793 harn_version: &str,
794 signed: bool,
795) -> Result<TrustRecord, PackError> {
796 let log = harn_vm::event_log::install_default_for_base_dir(project_root).map_err(|err| {
797 PackError::new(
798 "pack.trust_log_failed",
799 format!(
800 "failed to open OpenTrustGraph event log under {}: {err}",
801 project_root.display()
802 ),
803 )
804 })?;
805 let parent_trust_record_id = futures::executor::block_on(harn_vm::query_trust_records(
806 &log,
807 &harn_vm::TrustQueryFilters::default(),
808 ))
809 .map_err(|err| {
810 PackError::new(
811 "pack.trust_query_failed",
812 format!("failed to query prior OpenTrustGraph records: {err}"),
813 )
814 })?
815 .last()
816 .map(|record| record.record_id.clone());
817 let mut record = TrustRecord::release(
818 std::env::var("USER")
819 .ok()
820 .filter(|value| !value.trim().is_empty())
821 .unwrap_or_else(|| "harn-pack".to_string()),
822 bundle_hash.to_string(),
823 harn_version.to_string(),
824 parent_trust_record_id,
825 format!("harnpack-release-{}", uuid::Uuid::now_v7()),
826 if signed {
827 AutonomyTier::ActAuto
828 } else {
829 AutonomyTier::Suggest
830 },
831 );
832 record
833 .metadata
834 .insert("signed".to_string(), serde_json::json!(signed));
835 futures::executor::block_on(harn_vm::append_trust_record(&log, &record)).map_err(|err| {
836 PackError::new(
837 "pack.trust_record_failed",
838 format!("failed to append OpenTrustGraph release record: {err}"),
839 )
840 })
841}
842
843fn signature_summary(bundle: &WorkflowBundle) -> PackSignatureSummary {
844 match &bundle.signature {
845 Some(signature) => PackSignatureSummary {
846 algorithm: signature.algorithm.clone(),
847 key_id: signature.key_id.clone(),
848 present: true,
849 },
850 None => PackSignatureSummary {
851 algorithm: "ed25519".to_string(),
852 key_id: None,
853 present: false,
854 },
855 }
856}
857
858fn sbom_summary(bundle: &WorkflowBundle) -> PackSbomSummary {
859 let stdlib_modules = bundle
860 .sbom
861 .packages
862 .iter()
863 .filter(|package| package.name.starts_with("std/"))
864 .count();
865 let providers = bundle
866 .sbom
867 .packages
868 .iter()
869 .filter(|package| package.name.starts_with("provider:"))
870 .count();
871 PackSbomSummary {
872 components: bundle.sbom.packages.len(),
873 stdlib_modules,
874 providers,
875 tools: bundle.tool_manifest.len(),
876 }
877}
878
879#[derive(Debug)]
880struct ImportedAsset {
881 path: PathBuf,
882 rel: PathBuf,
883}
884
885#[derive(Debug, Serialize, Deserialize)]
886struct SkippedAsset {
887 path: String,
888 reason: String,
889}
890
891fn discover_import_assets(
892 graph: &harn_modules::ModuleGraph,
893 module_paths: &[PathBuf],
894 project_root: &Path,
895) -> Result<Vec<ImportedAsset>, PackError> {
896 let mut assets = BTreeMap::<PathBuf, ImportedAsset>::new();
897 for module_path in module_paths {
898 if module_path.to_string_lossy().starts_with("<std>/") {
899 continue;
900 }
901 for import in graph.imports_for_module(module_path) {
902 let Some(resolved_path) = import.resolved_path else {
903 continue;
904 };
905 if is_harn_module_path(&resolved_path) {
906 continue;
907 }
908 let canonical = resolved_path
909 .canonicalize()
910 .unwrap_or_else(|_| resolved_path.clone());
911 let rel = relativize(project_root, &canonical).ok_or_else(|| {
912 PackError::new(
913 "asset.outside_root",
914 format!(
915 "imported asset {} resolves outside pack archive root {}; add a harn.toml at the intended project root or keep imports inside it",
916 canonical.display(),
917 project_root.display()
918 ),
919 )
920 })?;
921 assets.entry(canonical.clone()).or_insert(ImportedAsset {
922 path: canonical,
923 rel,
924 });
925 }
926 }
927 Ok(assets.into_values().collect())
928}
929
930fn is_harn_module_path(path: &Path) -> bool {
931 path.to_string_lossy().starts_with("<std>/")
932 || path.extension().and_then(|ext| ext.to_str()) == Some("harn")
933}
934
935fn sort_sbom_doc(sbom: &mut SBOMDoc) {
936 sbom.packages.sort_by(|left, right| {
937 (&left.name, &left.version, &left.package_hash_blake3).cmp(&(
938 &right.name,
939 &right.version,
940 &right.package_hash_blake3,
941 ))
942 });
943 sbom.relationships.sort_by(|left, right| {
944 (&left.from, &left.to, &left.relationship_type).cmp(&(
945 &right.from,
946 &right.to,
947 &right.relationship_type,
948 ))
949 });
950}
951
952fn assemble_bundle(
953 entrypoint_rel: &Path,
954 transitive_modules: Vec<ModuleEntry>,
955 stdlib_version: String,
956 harn_version: String,
957 provider_catalog_hash: String,
958 tool_manifest: Vec<ToolEntry>,
959 sbom: SBOMDoc,
960 prior: Option<&WorkflowBundle>,
961) -> WorkflowBundle {
962 let stem = entrypoint_rel
963 .file_stem()
964 .map(|s| s.to_string_lossy().into_owned())
965 .unwrap_or_else(|| "harnpack".to_string());
966
967 let mut bundle = prior.cloned().unwrap_or_else(|| WorkflowBundle {
968 id: stem.clone(),
969 name: Some(stem.clone()),
970 version: "0.0.0".to_string(),
971 workflow: degenerate_workflow(&stem),
972 triggers: vec![WorkflowBundleTrigger {
973 id: "manual".to_string(),
974 kind: "manual".to_string(),
975 node_id: Some("entry".to_string()),
976 ..WorkflowBundleTrigger::default()
977 }],
978 policy: WorkflowBundlePolicy {
979 autonomy_tier: "act_with_approval".to_string(),
980 tool_policy: BTreeMap::new(),
981 approval_required: Vec::new(),
982 retry: RetryPolicySpec {
983 max_attempts: 1,
984 backoff: "none".to_string(),
985 },
986 catchup: CatchupPolicySpec {
987 mode: "none".to_string(),
988 max_events: None,
989 },
990 },
991 connectors: Vec::<ConnectorRequirement>::new(),
992 environment: EnvironmentRequirements::default(),
993 receipts: WorkflowBundleReplayMetadata::default(),
994 ..WorkflowBundle::default()
995 });
996
997 bundle.schema_version = WORKFLOW_BUNDLE_SCHEMA_VERSION;
998 bundle.entrypoint = entrypoint_rel.to_path_buf();
999 bundle.transitive_modules = transitive_modules;
1000 bundle.stdlib_version = stdlib_version;
1001 bundle.harn_version = harn_version;
1002 bundle.provider_catalog_hash = provider_catalog_hash;
1003 bundle.tool_manifest = tool_manifest;
1004 bundle.sbom = sbom;
1005 bundle.signature = None;
1006 bundle
1007}
1008
1009fn degenerate_workflow(stem: &str) -> harn_vm::orchestration::WorkflowGraph {
1010 use harn_vm::orchestration::{WorkflowGraph, WorkflowNode};
1011 let mut nodes = BTreeMap::new();
1012 nodes.insert(
1013 "entry".to_string(),
1014 WorkflowNode {
1015 id: Some("entry".to_string()),
1016 kind: "action".to_string(),
1017 task_label: Some(stem.to_string()),
1018 ..WorkflowNode::default()
1019 },
1020 );
1021 WorkflowGraph {
1022 type_name: "workflow_graph".to_string(),
1023 id: format!("{stem}_pack"),
1024 name: Some(stem.to_string()),
1025 version: 1,
1026 entry: "entry".to_string(),
1027 nodes,
1028 ..WorkflowGraph::default()
1029 }
1030}
1031
1032fn type_check_or_fail(
1033 source: &str,
1034 path: &str,
1035 program: &[harn_parser::SNode],
1036) -> Result<(), PackError> {
1037 let mut had_error = false;
1038 let mut messages = String::new();
1039 for diag in harn_parser::TypeChecker::new().check_with_source(program, source) {
1040 let rendered = harn_parser::diagnostic::render_type_diagnostic(source, path, &diag);
1041 if matches!(diag.severity, DiagnosticSeverity::Error) {
1042 had_error = true;
1043 }
1044 messages.push_str(&rendered);
1045 }
1046 if had_error {
1047 return Err(PackError::new(
1048 "module.type_error",
1049 format!("type errors in {path}:\n{messages}"),
1050 ));
1051 }
1052 if !messages.is_empty() {
1053 eprint!("{messages}");
1054 }
1055 Ok(())
1056}
1057
1058fn carry_extension_metadata(
1064 project_root: &Path,
1065 bundle: &mut WorkflowBundle,
1066) -> Result<Vec<HarnpackEntry>, PackError> {
1067 let manifest_path = project_root.join("harn.toml");
1068 if !manifest_path.is_file() {
1069 return Ok(Vec::new());
1070 }
1071 let Ok(text) = std::fs::read_to_string(&manifest_path) else {
1072 return Ok(Vec::new());
1073 };
1074 let manifest: crate::package::Manifest = match toml::from_str(&text) {
1075 Ok(manifest) => manifest,
1076 Err(_) => return Ok(Vec::new()),
1077 };
1078 crate::package::validate_contributions(&manifest)
1081 .map_err(|err| PackError::new("pack.invalid_contributes", err.message().to_string()))?;
1082 if !manifest.contributes.is_empty() {
1083 bundle.metadata.insert(
1084 "contributes".to_string(),
1085 serde_json::to_value(&manifest.contributes).map_err(|err| {
1086 PackError::new(
1087 "pack.metadata_failed",
1088 format!("failed to render contributions: {err}"),
1089 )
1090 })?,
1091 );
1092 }
1093 if let Some(pkg) = manifest.package.as_ref() {
1094 bundle.metadata.insert(
1095 "extension".to_string(),
1096 serde_json::json!({
1097 "name": pkg.name,
1098 "version": pkg.version,
1099 "publisher": pkg.publisher,
1100 "contact": pkg.contact,
1101 "created": pkg.created,
1102 "description": pkg.description,
1103 "license": pkg.license,
1104 "permissions": pkg.permissions,
1105 "host_requirements": pkg.host_requirements,
1106 }),
1107 );
1108 }
1109 collect_contribution_assets(project_root, &manifest.contributes)
1110}
1111
1112fn collect_contribution_assets(
1118 project_root: &Path,
1119 contributes: &[crate::package::ContributionEntry],
1120) -> Result<Vec<HarnpackEntry>, PackError> {
1121 const ASSET_KEYS: [&str; 3] = ["entry", "file", "path"];
1122 let mut seen: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
1123 let mut entries: Vec<HarnpackEntry> = Vec::new();
1124 for contribution in contributes {
1125 for key in ASSET_KEYS {
1126 let Some(rel) = contribution.config.get(key).and_then(|v| v.as_str()) else {
1127 continue;
1128 };
1129 let rel_path = PathBuf::from(rel);
1130 if rel_path.is_absolute()
1132 || rel_path
1133 .components()
1134 .any(|c| matches!(c, Component::ParentDir | Component::RootDir))
1135 {
1136 continue;
1137 }
1138 collect_path(project_root, &rel_path, &mut seen, &mut entries)?;
1139 }
1140 }
1141 Ok(entries)
1142}
1143
1144fn collect_path(
1147 project_root: &Path,
1148 rel: &Path,
1149 seen: &mut std::collections::BTreeSet<PathBuf>,
1150 entries: &mut Vec<HarnpackEntry>,
1151) -> Result<(), PackError> {
1152 let abs = project_root.join(rel);
1153 let metadata = match std::fs::symlink_metadata(&abs) {
1154 Ok(metadata) => metadata,
1155 Err(_) => return Ok(()),
1156 };
1157 if metadata.file_type().is_symlink() {
1158 return Ok(()); }
1160 if metadata.is_dir() {
1161 let mut children: Vec<PathBuf> = std::fs::read_dir(&abs)
1162 .map_err(|err| {
1163 PackError::new(
1164 "asset.read_failed",
1165 format!("failed to read contribution dir {}: {err}", abs.display()),
1166 )
1167 })?
1168 .filter_map(|e| e.ok().map(|e| e.file_name()))
1169 .map(|name| rel.join(name))
1170 .collect();
1171 children.sort();
1172 for child in children {
1173 collect_path(project_root, &child, seen, entries)?;
1174 }
1175 return Ok(());
1176 }
1177 if !metadata.is_file() || !seen.insert(rel.to_path_buf()) {
1178 return Ok(());
1179 }
1180 let bytes = std::fs::read(&abs).map_err(|err| {
1181 PackError::new(
1182 "asset.read_failed",
1183 format!("failed to read contribution asset {}: {err}", abs.display()),
1184 )
1185 })?;
1186 entries.push(HarnpackEntry::new(
1187 PathBuf::from(logical_bundle_path(rel)),
1188 bytes,
1189 ));
1190 Ok(())
1191}
1192
1193fn pack_archive_root(entrypoint: &Path) -> PathBuf {
1194 let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1195 harn_modules::asset_paths::find_project_root(parent).unwrap_or_else(|| parent.to_path_buf())
1196}
1197
1198fn relativize(root: &Path, target: &Path) -> Option<PathBuf> {
1199 let root_canon = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1200 let target_canon = target
1201 .canonicalize()
1202 .unwrap_or_else(|_| target.to_path_buf());
1203 if let Ok(rel) = target_canon.strip_prefix(&root_canon) {
1204 return Some(rel.to_path_buf());
1205 }
1206 None
1207}
1208
1209pub(super) fn adjacent_with_extension(rel: &Path, extension: &str) -> Option<PathBuf> {
1210 let stem = rel.file_stem()?.to_string_lossy().into_owned();
1211 if stem.is_empty() {
1212 return None;
1213 }
1214 let parent_components: Vec<Component<'_>> = rel
1215 .parent()
1216 .map(|p| p.components().collect())
1217 .unwrap_or_default();
1218 let mut adjacent = PathBuf::new();
1219 for component in parent_components {
1220 adjacent.push(component.as_os_str());
1221 }
1222 let mut filename = stem;
1223 filename.push('.');
1224 filename.push_str(extension);
1225 adjacent.push(filename);
1226 Some(adjacent)
1227}
1228
1229pub(super) fn blake3_hash(bytes: &[u8]) -> String {
1230 format!("blake3:{}", blake3::hash(bytes))
1231}
1232
1233fn resolve_output_path(out: &Option<PathBuf>, entrypoint: &Path) -> PathBuf {
1234 if let Some(path) = out {
1235 return path.clone();
1236 }
1237 let stem = entrypoint
1238 .file_stem()
1239 .map(|s| s.to_string_lossy().into_owned())
1240 .unwrap_or_else(|| "bundle".to_string());
1241 let parent = entrypoint.parent().unwrap_or_else(|| Path::new("."));
1242 parent.join(format!("{stem}.harnpack"))
1243}
1244
1245pub(crate) fn path_looks_like_secret(path: &Path) -> bool {
1250 let lower_name = path
1251 .file_name()
1252 .map(|s| s.to_string_lossy().to_ascii_lowercase())
1253 .unwrap_or_default();
1254 if lower_name == ".env" || lower_name.starts_with(".env.") {
1255 return true;
1256 }
1257 if lower_name.starts_with("credentials") {
1258 return true;
1259 }
1260 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
1261 let ext = ext.to_ascii_lowercase();
1262 if ext == "pem" || ext == "key" {
1263 return true;
1264 }
1265 }
1266 for component in path.components() {
1267 if let Component::Normal(part) = component {
1268 if part.to_string_lossy().eq_ignore_ascii_case("secrets") {
1269 return true;
1270 }
1271 }
1272 }
1273 false
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278 use super::*;
1279 use std::fs;
1280
1281 fn build_args(entrypoint: PathBuf, out: PathBuf) -> BuildArgs {
1282 BuildArgs {
1283 entrypoint,
1284 out: Some(out),
1285 upgrade: None,
1286 sign: false,
1287 key: None,
1288 unsigned: true,
1289 exclude_secrets: false,
1290 json: true,
1291 }
1292 }
1293
1294 #[test]
1295 fn carry_extension_metadata_injects_contributes_and_identity() {
1296 let temp = tempfile::tempdir().unwrap();
1297 fs::write(
1298 temp.path().join("harn.toml"),
1299 r#"
1300[package]
1301name = "harn-latex"
1302version = "0.1.0"
1303publisher = "Burin Labs"
1304permissions = ["workspace:read_text"]
1305
1306[[contributes]]
1307kind = "editor.language"
1308id = "latex"
1309scopes = ["workspace:read_text"]
1310languageId = "latex"
1311"#,
1312 )
1313 .unwrap();
1314 let mut bundle = WorkflowBundle::default();
1315 carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1316
1317 let contributes = bundle
1318 .metadata
1319 .get("contributes")
1320 .expect("contributes carried");
1321 assert_eq!(contributes.as_array().unwrap().len(), 1);
1322 assert_eq!(contributes[0]["kind"], "editor.language");
1323 assert_eq!(contributes[0]["languageId"], "latex");
1325
1326 let ext = bundle.metadata.get("extension").expect("identity carried");
1327 assert_eq!(ext["name"], "harn-latex");
1328 assert_eq!(ext["publisher"], "Burin Labs");
1329 assert_eq!(ext["permissions"][0], "workspace:read_text");
1330 }
1331
1332 #[test]
1333 fn carry_extension_metadata_bundles_contribution_assets() {
1334 let temp = tempfile::tempdir().unwrap();
1335 fs::create_dir_all(temp.path().join("assets")).unwrap();
1336 fs::write(temp.path().join("assets/preview.html"), "<html></html>").unwrap();
1337 fs::create_dir_all(temp.path().join("canon/latex")).unwrap();
1338 fs::write(temp.path().join("canon/latex/invariants.harn"), "// rules").unwrap();
1339 fs::write(temp.path().join("SKILL.md"), "# skill").unwrap();
1340 fs::write(
1341 temp.path().join("harn.toml"),
1342 r#"
1343[package]
1344name = "harn-latex"
1345permissions = ["workspace:read_text"]
1346
1347[[contributes]]
1348kind = "editor.preview"
1349id = "p"
1350scopes = ["workspace:read_text"]
1351entry = "assets/preview.html"
1352
1353[[contributes]]
1354kind = "harn.canon"
1355id = "c"
1356path = "canon/latex"
1357
1358[[contributes]]
1359kind = "harn.skill"
1360id = "s"
1361path = "SKILL.md"
1362"#,
1363 )
1364 .unwrap();
1365 let mut bundle = WorkflowBundle::default();
1366 let assets = carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1367 let paths: std::collections::BTreeSet<String> = assets
1368 .iter()
1369 .map(|e| crate::format::slash_path(&e.path))
1370 .collect();
1371 assert!(paths.contains("assets/preview.html"), "{paths:?}");
1372 assert!(paths.contains("canon/latex/invariants.harn"), "{paths:?}");
1373 assert!(paths.contains("SKILL.md"), "{paths:?}");
1374 }
1375
1376 #[test]
1377 fn carry_extension_metadata_skips_parent_escape_assets() {
1378 let temp = tempfile::tempdir().unwrap();
1379 fs::write(
1380 temp.path().join("harn.toml"),
1381 "[package]\nname = \"x\"\npermissions = []\n\n[[contributes]]\nkind = \"editor.preview\"\nid = \"p\"\nentry = \"../escape.html\"\n",
1382 )
1383 .unwrap();
1384 let mut bundle = WorkflowBundle::default();
1385 let assets = carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1386 assert!(assets.is_empty());
1387 }
1388
1389 #[test]
1390 fn carry_extension_metadata_is_noop_without_manifest() {
1391 let temp = tempfile::tempdir().unwrap();
1392 let mut bundle = WorkflowBundle::default();
1393 carry_extension_metadata(temp.path(), &mut bundle).unwrap();
1394 assert!(!bundle.metadata.contains_key("contributes"));
1395 }
1396
1397 #[test]
1398 fn pack_uses_nearest_harn_toml_root_for_nested_entrypoint_assets() {
1399 let temp = tempfile::tempdir().unwrap();
1400 fs::write(
1401 temp.path().join("harn.toml"),
1402 "[package]\nname = \"pack-root\"\n",
1403 )
1404 .unwrap();
1405 fs::create_dir_all(temp.path().join("scripts")).unwrap();
1406 fs::create_dir_all(temp.path().join("assets")).unwrap();
1407 fs::write(temp.path().join("assets/prompt.txt"), "prompt asset\n").unwrap();
1408 fs::write(
1409 temp.path().join("scripts/entry.harn"),
1410 "import \"../assets/prompt.txt\"\n__io_println(\"packed\")\n",
1411 )
1412 .unwrap();
1413
1414 let outcome = build(&build_args(
1415 temp.path().join("scripts/entry.harn"),
1416 temp.path().join("bundle.harnpack"),
1417 ))
1418 .unwrap();
1419
1420 assert_eq!(
1421 outcome.json.manifest.entrypoint,
1422 PathBuf::from("scripts/entry.harn")
1423 );
1424 assert!(outcome
1425 .json
1426 .manifest
1427 .sbom
1428 .packages
1429 .iter()
1430 .any(|package| package.name == "asset:assets/prompt.txt"));
1431 }
1432
1433 #[test]
1434 fn pack_rejects_imported_asset_outside_archive_root() {
1435 let temp = tempfile::tempdir().unwrap();
1436 let root = temp.path().join("root");
1437 let outside = temp.path().join("outside");
1438 fs::create_dir_all(&root).unwrap();
1439 fs::create_dir_all(&outside).unwrap();
1440 fs::write(outside.join("prompt.txt"), "outside asset\n").unwrap();
1441 fs::write(
1442 root.join("entry.harn"),
1443 "import \"../outside/prompt.txt\"\n__io_println(\"packed\")\n",
1444 )
1445 .unwrap();
1446
1447 let err = build(&build_args(
1448 root.join("entry.harn"),
1449 root.join("bundle.harnpack"),
1450 ))
1451 .unwrap_err();
1452
1453 assert_eq!(err.code, "asset.outside_root");
1454 assert!(!root.join("bundle.harnpack").exists());
1455 }
1456}