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