Skip to main content

harn_cli/commands/
pack.rs

1//! `harn pack <entrypoint>` — build a signed-ready `.harnpack` from a
2//! Harn entrypoint.
3//!
4//! Walks the entrypoint's transitive imports, links one closed-program
5//! artifact, snapshots the provider catalog and stdlib pin, generates a
6//! minimal SBOM, assembles a v3 `WorkflowBundle`
7//! manifest, and emits a deterministic tar.zst archive.
8//!
9//! `harn pack verify <bundle.harnpack>` (#1779) reads a bundle back,
10//! recomputes its canonical hash, verifies the embedded Ed25519 signature (if
11//! any), and cross-checks every source and linked-artifact BLAKE3 identity.
12
13use 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
36/// Stable schema version for the `harn pack --json` envelope. Bump when
37/// [`PackJsonData`] changes shape in a way that agents need to detect.
38pub 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/// JSON payload emitted under `JsonEnvelope.data` for `harn pack`.
43#[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
145/// Programmatic entrypoint used by tests and other CLI command code
146/// that needs the JSON envelope without going through stdout.
147pub 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/// Plain-data input to [`build`]: a flattened copy of [`PackArgs`]
176/// without the subcommand surface. Tests can construct this directly
177/// instead of going through the CLI parser.
178#[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/// Outcome of [`build`]. Used by tests; the dispatcher consumes it
261/// directly via [`run`].
262#[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    // The entrypoint is always present in the graph; ensure deterministic order.
389    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    // Tool entries use the same manifest/SBOM path as modules and
578    // providers, keeping the archive representation centralized.
579    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    // Carry host-surface extension data — the `[[contributes]]` block plus
632    // package identity/permissions — into the signed bundle's metadata so a
633    // host (e.g. an IDE) can discover and gate contributions from the verified
634    // artifact alone, with no separate descriptor. Harn stays agnostic about
635    // the kind-specific payload; it only ferries it. See `docs` and the
636    // `ContributionEntry` schema.
637    // Also bundle files referenced by the `[[contributes]]` block (preview
638    // HTML, theme JSON, canon dir, skill, …) so an imported pack is
639    // self-contained for the host.
640    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
1002/// Carry the package's host-surface extension data into the signed bundle's
1003/// metadata. Reads the nearest `harn.toml`, serializing the `[[contributes]]`
1004/// block under the `contributes` key and package identity/permissions under
1005/// `extension`. No-op when there is no manifest, it fails to parse, or there
1006/// are no contributions — packing a plain workflow is unaffected.
1007fn 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    // Validate before sealing into the signed bundle so a malformed
1023    // `[[contributes]]` block fails the pack rather than shipping silently.
1024    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
1056/// Bundle files referenced by `[[contributes]]` config (`entry`/`file`/`path`)
1057/// at their bundle-relative archive paths so a host can load them after import
1058/// (the preview HTML, theme JSON, canon dir, skill, …). `harn pack` otherwise
1059/// only walks the entrypoint's transitive imports, which never reach these.
1060/// Paths must stay inside the archive root; `..` escapes are skipped.
1061fn 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            // Reject absolute paths and `..` escapes.
1075            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
1088/// Recursively add `rel` (file or directory, relative to `project_root`) to the
1089/// archive at its bundle-relative path. No-op when the path is missing.
1090fn 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(()); // never follow symlinks out of the archive root
1103    }
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
1189/// Heuristic gate for `--exclude-secrets`. Matches `.env`, `.env.*`,
1190/// `*.pem`, `*.key`, `credentials*`, and any path under a `secrets/`
1191/// directory. Kept conservative so false positives don't strand
1192/// legitimate bundles; mirrors common git secret-scanning policies.
1193pub(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        // kind-specific keys are flattened into the contribution object
1268        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}