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