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