Skip to main content

harn_cli/commands/run/
harnpack.rs

1//! `harn run <bundle.harnpack>` — verify the embedded OpenTrustGraph
2//! signature, replay the archive into the content-addressed pack cache,
3//! and execute the bundled entrypoint.
4//!
5//! See issue #1784 (epic #1779). The verify path reuses the helpers
6//! shipped with E6.1/E6.3 (`workflow_bundle.rs`) so signing and
7//! verification share the same canonical-hash code path.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::fmt::Write;
11use std::fs;
12use std::io;
13use std::path::{Component, Path, PathBuf};
14use std::time::{Duration, Instant};
15
16use harn_vm::bytecode_cache;
17use harn_vm::orchestration::{
18    read_harnpack, verify_workflow_bundle_signature, workflow_bundle_hash,
19    ExecutionArtifactFallback, HarnpackEntry, WorkflowBundle, WorkflowBundleError,
20};
21
22/// Zstandard magic prefix. `.harnpack` archives are zstd-compressed tar
23/// streams, so the on-disk byte signature is the zstd frame header.
24const ZSTD_MAGIC: &[u8; 4] = &[0x28, 0xb5, 0x2f, 0xfd];
25
26/// Options for [`prepare_harnpack`].
27#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct HarnpackRunOptions {
29    /// Run the pack even when it carries no Ed25519 signature.
30    pub allow_unsigned: bool,
31    /// Verify-only mode: stop after the cache replay and emit a
32    /// `pack_run` event without executing the entrypoint.
33    pub dry_run_verify: bool,
34}
35
36/// Outcome of [`prepare_harnpack`]. The CLI surface uses this to (a)
37/// emit the `pack_run` event before the run starts, (b) decide whether
38/// to short-circuit on `--dry-run-verify`, and (c) hand off the unpacked
39/// entrypoint path to the existing source-execution code path.
40#[derive(Debug)]
41pub struct PreparedHarnpack {
42    pub bundle_hash: String,
43    pub signature_verified: bool,
44    pub key_id: Option<String>,
45    pub cache_hit: bool,
46    pub cache_dir: PathBuf,
47    pub entrypoint_path: PathBuf,
48    pub manifest: WorkflowBundle,
49    pub linked_program: Option<harn_vm::linked_program::LinkedProgramArtifact>,
50    pub execution_artifact_state: &'static str,
51    pub fallback_reason: Option<String>,
52    pub artifact_decode_elapsed: Duration,
53}
54
55#[derive(Debug)]
56pub struct HarnpackError {
57    pub code: &'static str,
58    pub message: String,
59}
60
61impl HarnpackError {
62    fn new(code: &'static str, message: impl Into<String>) -> Self {
63        Self {
64            code,
65            message: message.into(),
66        }
67    }
68}
69
70impl std::fmt::Display for HarnpackError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        write!(f, "{}", self.message)
73    }
74}
75
76impl std::error::Error for HarnpackError {}
77
78impl From<WorkflowBundleError> for HarnpackError {
79    fn from(error: WorkflowBundleError) -> Self {
80        Self::new("harnpack.archive", error.message)
81    }
82}
83
84/// Detect whether `path` references a `.harnpack` bundle by extension
85/// or zstd magic header. The magic-header path keeps detection robust
86/// for renamed bundles (`./bundle` without extension) which is the
87/// failure mode that bit us when users curl bundles without `-o`.
88pub fn looks_like_harnpack(path: &Path) -> bool {
89    if path.extension().and_then(|ext| ext.to_str()) == Some("harnpack") {
90        return true;
91    }
92    match fs::File::open(path) {
93        Ok(mut file) => {
94            use std::io::Read;
95            let mut buf = [0u8; 4];
96            file.read_exact(&mut buf).is_ok() && &buf == ZSTD_MAGIC
97        }
98        Err(_) => false,
99    }
100}
101
102/// Verify the bundle at `path`, replay it into the content-addressed
103/// pack cache, and return the unpacked entrypoint to execute.
104///
105/// Errors map to user-facing exit-code-1 messages on the CLI; the
106/// [`HarnpackError::code`] discriminates failure modes for JSON
107/// callers and tests.
108pub fn prepare_harnpack<W: Write>(
109    path: &Path,
110    options: &HarnpackRunOptions,
111    stderr: &mut W,
112) -> Result<PreparedHarnpack, HarnpackError> {
113    let bytes = fs::read(path).map_err(|err| {
114        HarnpackError::new(
115            "harnpack.read_failed",
116            format!("failed to read {}: {err}", path.display()),
117        )
118    })?;
119    let archive = read_harnpack(&bytes)?;
120    let manifest = archive.manifest;
121    let contents = archive.contents;
122
123    let (signature_verified, key_id) = match manifest.signature.as_ref() {
124        Some(signature) => {
125            verify_workflow_bundle_signature(&manifest, &contents)?;
126            (true, signature.key_id.clone())
127        }
128        None => {
129            if !options.allow_unsigned {
130                return Err(HarnpackError::new(
131                    "harnpack.unsigned",
132                    format!(
133                        "refusing to run unsigned bundle {} \
134                         (re-run with --allow-unsigned to override)",
135                        path.display()
136                    ),
137                ));
138            }
139            (false, None)
140        }
141    };
142
143    // Preserve the public unsafe-path error contract before validating payload
144    // identity: the entrypoint is host-synthesized replay state, not an archive
145    // member that the shared pack verifier owns.
146    let entrypoint_rel = join_safe_nonempty(Path::new(""), &manifest.entrypoint)?;
147    crate::commands::pack::verify_runtime_payloads(&manifest, &contents, signature_verified)
148        .map_err(|error| HarnpackError::new("harnpack.archive_validation", error.message))?;
149
150    check_harn_version_compat(&manifest.harn_version, stderr)?;
151    let decode_started = Instant::now();
152    let (linked_program, execution_artifact_state, fallback_reason) =
153        prepare_execution_artifact(&manifest, &contents)?;
154    let artifact_decode_elapsed = decode_started.elapsed();
155    let bundle_hash = workflow_bundle_hash(&manifest, &contents)?;
156    let cache_dir = bytecode_cache::packs_cache_dir().join(sanitize_bundle_hash(&bundle_hash));
157    let replay_plan = plan_replay(&contents)?;
158    let cache_hit = manifest_already_replayed(&cache_dir, &manifest)?;
159    if !cache_hit {
160        replay_archive(&cache_dir, &manifest, &replay_plan)?;
161    }
162    ensure_replay_projection(&cache_dir, &manifest, &replay_plan)?;
163
164    let entrypoint_path = cache_dir.join("sources").join(entrypoint_rel);
165    if !entrypoint_path.exists() {
166        return Err(HarnpackError::new(
167            "harnpack.missing_entrypoint",
168            format!(
169                "manifest entrypoint {} not present in unpacked bundle at {}",
170                manifest.entrypoint.display(),
171                entrypoint_path.display()
172            ),
173        ));
174    }
175
176    Ok(PreparedHarnpack {
177        bundle_hash,
178        signature_verified,
179        key_id,
180        cache_hit,
181        cache_dir,
182        entrypoint_path,
183        manifest,
184        linked_program,
185        execution_artifact_state,
186        fallback_reason,
187        artifact_decode_elapsed,
188    })
189}
190
191fn prepare_execution_artifact(
192    manifest: &WorkflowBundle,
193    contents: &[HarnpackEntry],
194) -> Result<
195    (
196        Option<harn_vm::linked_program::LinkedProgramArtifact>,
197        &'static str,
198        Option<String>,
199    ),
200    HarnpackError,
201> {
202    for module in &manifest.transitive_modules {
203        let source_path = PathBuf::from("sources").join(&module.path);
204        let source = contents
205            .iter()
206            .find(|entry| entry.path == source_path)
207            .ok_or_else(|| {
208                HarnpackError::new(
209                    "harnpack.source_missing",
210                    format!("archive is missing {}", source_path.display()),
211                )
212            })?;
213        let actual = format!("blake3:{}", blake3::hash(&source.bytes).to_hex());
214        if actual != module.source_hash_blake3 {
215            return Err(HarnpackError::new(
216                "harnpack.source_mismatch",
217                format!("source hash mismatch for {}", module.path.display()),
218            ));
219        }
220    }
221    let Some(descriptor) = manifest.execution_artifact.as_ref() else {
222        if manifest.schema_version >= harn_vm::orchestration::WORKFLOW_BUNDLE_SCHEMA_VERSION {
223            return Err(HarnpackError::new(
224                "harnpack.linked_artifact_missing",
225                "schema-v3 bundle is missing its execution_artifact descriptor",
226            ));
227        }
228        return Ok((None, "legacy_v2", None));
229    };
230    if descriptor.format != "harn.linked_program.v1" {
231        return Err(HarnpackError::new(
232            "harnpack.linked_artifact_incompatible",
233            format!(
234                "unsupported execution artifact format {}",
235                descriptor.format
236            ),
237        ));
238    }
239    let entry = contents
240        .iter()
241        .find(|entry| entry.path == descriptor.path)
242        .ok_or_else(|| {
243            HarnpackError::new(
244                "harnpack.linked_artifact_missing",
245                format!("archive is missing {}", descriptor.path.display()),
246            )
247        })?;
248    let actual_hash = format!("blake3:{}", blake3::hash(&entry.bytes).to_hex());
249    if actual_hash != descriptor.hash_blake3 {
250        return Err(HarnpackError::new(
251            "harnpack.linked_artifact_mismatch",
252            format!(
253                "linked artifact hash mismatch: manifest {}, archive {}",
254                descriptor.hash_blake3, actual_hash
255            ),
256        ));
257    }
258    harn_vm::linked_program::verify_graph_binding(
259        &descriptor.link_report,
260        &descriptor.graph_digest_blake3,
261        |path| {
262            let source_path = PathBuf::from("sources").join(path);
263            contents
264                .iter()
265                .find(|entry| entry.path == source_path)
266                .map(|entry| entry.bytes.clone())
267        },
268    )
269    .map_err(|error| HarnpackError::new("harnpack.linked_graph_mismatch", error.message))?;
270    let decoded = harn_vm::linked_program::LinkedProgramArtifact::decode(&entry.bytes);
271    let linked = match decoded {
272        Ok(linked) => linked,
273        Err(error)
274            if error.code == "linked_program.incompatible"
275                && descriptor.fallback == ExecutionArtifactFallback::ExactSources =>
276        {
277            return Ok((None, "source_fallback", Some(error.message)));
278        }
279        Err(error) => return Err(HarnpackError::new(error.code, error.message)),
280    };
281    if linked.entrypoint != manifest.entrypoint
282        || linked.identity.graph_digest_blake3 != descriptor.graph_digest_blake3
283        || linked.report != descriptor.link_report
284    {
285        return Err(HarnpackError::new(
286            "harnpack.linked_artifact_mismatch",
287            "linked artifact identity, entrypoint, or report disagrees with the manifest",
288        ));
289    }
290    Ok((Some(linked), "linked", None))
291}
292
293/// Translate a `blake3:<hex>` digest into a filename-safe directory
294/// component. `:` is illegal in some path layers (Windows, `tar`
295/// member names), so swap it for `_` while keeping the algorithm
296/// prefix for forensic readability.
297fn sanitize_bundle_hash(hash: &str) -> String {
298    hash.replace(':', "_")
299}
300
301/// `harn_version` compatibility check: refuse on a major or minor
302/// mismatch, warn on a patch mismatch. The contract is documented on
303/// issue #1784.
304fn check_harn_version_compat<W: Write>(
305    bundle_version: &str,
306    stderr: &mut W,
307) -> Result<(), HarnpackError> {
308    let current_version = env!("CARGO_PKG_VERSION");
309    if bundle_version == current_version {
310        return Ok(());
311    }
312    let (Some(bundle), Some(current)) = (
313        parse_semver_triplet(bundle_version),
314        parse_semver_triplet(current_version),
315    ) else {
316        let _ = writeln!(
317            stderr,
318            "warning: harnpack harn_version {bundle_version} is not parseable; running anyway"
319        );
320        return Ok(());
321    };
322    if bundle.0 != current.0 || bundle.1 != current.1 {
323        return Err(HarnpackError::new(
324            "harnpack.version_mismatch",
325            format!(
326                "harnpack was built for harn {bundle_version}; \
327                 this runtime is {current_version} (major/minor mismatch refused)"
328            ),
329        ));
330    }
331    let _ = writeln!(
332        stderr,
333        "warning: harnpack was built for harn {bundle_version}; \
334         this runtime is {current_version} (patch mismatch)"
335    );
336    Ok(())
337}
338
339/// Parse the `major.minor.patch` triplet from a version string,
340/// ignoring any pre-release or build metadata. Returns `None` when the
341/// string can't be parsed as `<u32>.<u32>.<u32>` at the front — callers
342/// fall back to a permissive warning so unusual version pins don't
343/// strand a working bundle.
344fn parse_semver_triplet(input: &str) -> Option<(u32, u32, u32)> {
345    let core = input.split_once('-').map(|(head, _)| head).unwrap_or(input);
346    let core = core.split_once('+').map(|(head, _)| head).unwrap_or(core);
347    let mut parts = core.split('.');
348    let major = parts.next()?.parse().ok()?;
349    let minor = parts.next()?.parse().ok()?;
350    let patch = parts.next()?.parse().ok()?;
351    Some((major, minor, patch))
352}
353
354/// Returns true when `cache_dir` already holds a previously-replayed
355/// archive whose `harnpack.json` matches `manifest`. Content addressing
356/// (`bundle_hash` in the directory name) makes a single positive match
357/// sufficient; we still cross-check the manifest payload to defend
358/// against partial writes from a prior crash.
359fn manifest_already_replayed(
360    cache_dir: &Path,
361    manifest: &WorkflowBundle,
362) -> Result<bool, HarnpackError> {
363    let manifest_path = cache_dir.join("harnpack.json");
364    let Ok(bytes) = fs::read(&manifest_path) else {
365        return Ok(false);
366    };
367    let cached: WorkflowBundle = match serde_json::from_slice(&bytes) {
368        Ok(value) => value,
369        Err(_) => return Ok(false),
370    };
371    Ok(&cached == manifest)
372}
373
374/// Unpack the bundle into a fresh staging directory and then rename
375/// into the content-addressed cache slot atomically. The intermediate
376/// directory keeps a crash mid-extract from leaving a half-populated
377/// `<bundle_hash>/` that future runs would mistake for a cache hit.
378fn replay_archive(
379    cache_dir: &Path,
380    manifest: &WorkflowBundle,
381    entries: &[ReplayEntry<'_>],
382) -> Result<(), HarnpackError> {
383    let parent = cache_dir.parent().ok_or_else(|| {
384        HarnpackError::new(
385            "harnpack.replay_failed",
386            format!("pack cache path has no parent: {}", cache_dir.display()),
387        )
388    })?;
389    fs::create_dir_all(parent).map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
390    let staging = tempfile::Builder::new()
391        .prefix(".staging-")
392        .tempdir_in(parent)
393        .map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
394    let staging_path = staging.path().to_path_buf();
395
396    for replay in entries {
397        let dest = join_safe(&staging_path, &replay.destination)?;
398        if let Some(parent) = dest.parent() {
399            fs::create_dir_all(parent)
400                .map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
401        }
402        fs::write(&dest, &replay.entry.bytes)
403            .map_err(|err| io_err("harnpack.replay_failed", err, &dest))?;
404    }
405
406    let manifest_bytes = serde_json::to_vec(manifest).map_err(|err| {
407        HarnpackError::new(
408            "harnpack.replay_failed",
409            format!("failed to encode manifest for cache: {err}"),
410        )
411    })?;
412    let manifest_path = staging_path.join("harnpack.json");
413    fs::write(&manifest_path, &manifest_bytes)
414        .map_err(|err| io_err("harnpack.replay_failed", err, &manifest_path))?;
415
416    // `rename` is atomic on the same filesystem. Two concurrent runs
417    // unpacking the same bundle hash will both attempt the rename;
418    // whichever loses sees the destination already present. The idempotent
419    // verification step after this function checks the winner byte-for-byte
420    // before any replayed path reaches execution.
421    // `TempDir::into_path()` defuses the auto-cleanup so the rename
422    // owns the directory.
423    let staged = staging.keep();
424    match fs::rename(&staged, cache_dir) {
425        Ok(()) => Ok(()),
426        Err(err) if cache_dir.join("harnpack.json").exists() => {
427            let _ = fs::remove_dir_all(&staged);
428            // The other writer's tree is now in place. It is not trusted until
429            // `ensure_replay_projection` validates and repairs it.
430            let _ = err;
431            Ok(())
432        }
433        Err(err) => {
434            let _ = fs::remove_dir_all(&staged);
435            Err(io_err("harnpack.replay_failed", err, cache_dir))
436        }
437    }
438}
439
440#[derive(Debug)]
441struct ReplayEntry<'a> {
442    entry: &'a HarnpackEntry,
443    destination: PathBuf,
444}
445
446/// Map generated bytecode to the adjacent paths the canonical loaders own,
447/// while leaving the archive's authoritative `bytecode/` layout untouched.
448/// One archive entry produces one replay file; destination collisions fail
449/// before any staging directory is written.
450fn plan_replay(contents: &[HarnpackEntry]) -> Result<Vec<ReplayEntry<'_>>, HarnpackError> {
451    let source_paths = contents
452        .iter()
453        .filter_map(|entry| {
454            entry
455                .path
456                .strip_prefix("sources")
457                .ok()
458                .map(Path::to_path_buf)
459        })
460        .collect::<BTreeSet<_>>();
461    let mut destinations = BTreeMap::<PathBuf, PathBuf>::new();
462    let mut plan = Vec::with_capacity(contents.len());
463
464    for entry in contents {
465        let destination = projected_artifact_path(&entry.path, &source_paths)
466            .unwrap_or_else(|| entry.path.clone());
467        if let Some(existing) = destinations.insert(destination.clone(), entry.path.clone()) {
468            return Err(HarnpackError::new(
469                "harnpack.replay_collision",
470                format!(
471                    "archive entries {} and {} both replay to {}",
472                    existing.display(),
473                    entry.path.display(),
474                    destination.display()
475                ),
476            ));
477        }
478        plan.push(ReplayEntry { entry, destination });
479    }
480    Ok(plan)
481}
482
483fn projected_artifact_path(
484    archive_path: &Path,
485    source_paths: &BTreeSet<PathBuf>,
486) -> Option<PathBuf> {
487    let artifact_rel = archive_path.strip_prefix("bytecode").ok()?;
488    let extension = artifact_rel.extension()?.to_str()?;
489    if extension != bytecode_cache::CACHE_EXTENSION
490        && extension != bytecode_cache::MODULE_CACHE_EXTENSION
491    {
492        return None;
493    }
494    let mut source_rel = artifact_rel.to_path_buf();
495    source_rel.set_extension("harn");
496    source_paths
497        .contains(&source_rel)
498        .then(|| PathBuf::from("sources").join(artifact_rel))
499}
500
501/// Make the verified archive bytes authoritative over an existing replay slot.
502/// This both upgrades old `bytecode/` layouts and repairs tampered or partial
503/// cache hits. Every payload and the synthetic manifest are compared exactly;
504/// atomic writes make parallel repairs converge on the same bytes.
505fn ensure_replay_projection(
506    cache_dir: &Path,
507    manifest: &WorkflowBundle,
508    entries: &[ReplayEntry<'_>],
509) -> Result<(), HarnpackError> {
510    let manifest_bytes = serde_json::to_vec(manifest).map_err(|error| {
511        HarnpackError::new(
512            "harnpack.replay_failed",
513            format!("failed to encode manifest for cache: {error}"),
514        )
515    })?;
516    ensure_exact_replay_file(cache_dir, &cache_dir.join("harnpack.json"), &manifest_bytes)?;
517    for replay in entries {
518        let target = join_safe(cache_dir, &replay.destination)?;
519        ensure_exact_replay_file(cache_dir, &target, &replay.entry.bytes)?;
520    }
521    Ok(())
522}
523
524fn ensure_exact_replay_file(
525    cache_dir: &Path,
526    target: &Path,
527    expected: &[u8],
528) -> Result<(), HarnpackError> {
529    ensure_real_parent_dirs(cache_dir, target)?;
530    match fs::symlink_metadata(target) {
531        Ok(metadata) if metadata.file_type().is_file() => {
532            let actual = fs::read(target)
533                .map_err(|error| io_err("harnpack.replay_failed", error, target))?;
534            if actual == expected {
535                return Ok(());
536            }
537        }
538        Ok(_) => {
539            return Err(HarnpackError::new(
540                "harnpack.replay_collision",
541                format!(
542                    "refusing to replace non-file replay target {}",
543                    target.display()
544                ),
545            ));
546        }
547        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
548        Err(error) => return Err(io_err("harnpack.replay_failed", error, target)),
549    }
550    harn_vm::atomic_io::atomic_write(target, expected)
551        .map_err(|error| io_err("harnpack.replay_failed", error, target))
552}
553
554/// Validate every existing path component without following symlinks. This is
555/// intentionally stricter than `create_dir_all`: replay repair must never
556/// escape its content-addressed slot through a cached `sources/` symlink.
557fn ensure_real_parent_dirs(cache_dir: &Path, target: &Path) -> Result<(), HarnpackError> {
558    let relative = target.strip_prefix(cache_dir).map_err(|_| {
559        HarnpackError::new(
560            "harnpack.unsafe_path",
561            format!("replay target escapes cache slot: {}", target.display()),
562        )
563    })?;
564    require_real_directory(cache_dir)?;
565    let mut current = cache_dir.to_path_buf();
566    for component in relative.parent().unwrap_or(Path::new("")).components() {
567        let Component::Normal(part) = component else {
568            return Err(HarnpackError::new(
569                "harnpack.unsafe_path",
570                format!("unsafe replay parent: {}", target.display()),
571            ));
572        };
573        current.push(part);
574        match fs::symlink_metadata(&current) {
575            Ok(metadata) if metadata.file_type().is_dir() => {}
576            Ok(_) => {
577                return Err(HarnpackError::new(
578                    "harnpack.replay_collision",
579                    format!(
580                        "replay parent is not a real directory: {}",
581                        current.display()
582                    ),
583                ));
584            }
585            Err(error) if error.kind() == io::ErrorKind::NotFound => {
586                match fs::create_dir(&current) {
587                    Ok(()) => {}
588                    Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
589                        require_real_directory(&current)?;
590                    }
591                    Err(error) => {
592                        return Err(io_err("harnpack.replay_failed", error, &current));
593                    }
594                }
595            }
596            Err(error) => return Err(io_err("harnpack.replay_failed", error, &current)),
597        }
598    }
599    Ok(())
600}
601
602fn require_real_directory(path: &Path) -> Result<(), HarnpackError> {
603    let metadata = fs::symlink_metadata(path)
604        .map_err(|error| io_err("harnpack.replay_failed", error, path))?;
605    if metadata.file_type().is_dir() {
606        return Ok(());
607    }
608    Err(HarnpackError::new(
609        "harnpack.replay_collision",
610        format!("replay parent is not a real directory: {}", path.display()),
611    ))
612}
613
614fn io_err(code: &'static str, err: io::Error, path: &Path) -> HarnpackError {
615    HarnpackError::new(code, format!("{}: {err}", path.display()))
616}
617
618/// Join an archive-relative path onto `base` while refusing anything
619/// that would escape via `..` or absolute components. `read_harnpack`
620/// already rejects unsafe entries at archive parse time; this is
621/// belt-and-braces defense for paths we synthesize on the host side.
622fn join_safe(base: &Path, rel: &Path) -> Result<PathBuf, HarnpackError> {
623    let mut out = base.to_path_buf();
624    for component in rel.components() {
625        match component {
626            Component::Normal(part) => out.push(part),
627            Component::CurDir => {}
628            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
629                return Err(HarnpackError::new(
630                    "harnpack.unsafe_path",
631                    format!("refusing to unpack unsafe path: {}", rel.display()),
632                ));
633            }
634        }
635    }
636    Ok(out)
637}
638
639fn join_safe_nonempty(base: &Path, rel: &Path) -> Result<PathBuf, HarnpackError> {
640    let out = join_safe(base, rel)?;
641    if out == base {
642        return Err(HarnpackError::new(
643            "harnpack.unsafe_path",
644            "refusing to use empty harnpack entrypoint",
645        ));
646    }
647    Ok(out)
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653
654    #[test]
655    fn semver_triplet_parses_release_versions() {
656        assert_eq!(parse_semver_triplet("1.2.3"), Some((1, 2, 3)));
657        assert_eq!(parse_semver_triplet("0.10.42"), Some((0, 10, 42)));
658        assert_eq!(parse_semver_triplet("1.2.3-rc.1"), Some((1, 2, 3)));
659        assert_eq!(parse_semver_triplet("1.2.3+build.4"), Some((1, 2, 3)));
660        assert_eq!(parse_semver_triplet("garbage"), None);
661        assert_eq!(parse_semver_triplet("1.2"), None);
662    }
663
664    #[test]
665    fn sanitize_bundle_hash_replaces_colon() {
666        assert_eq!(sanitize_bundle_hash("blake3:abc"), "blake3_abc");
667        assert_eq!(sanitize_bundle_hash("nohash"), "nohash");
668    }
669
670    #[test]
671    fn check_harn_version_compat_warns_on_patch_mismatch() {
672        let current = env!("CARGO_PKG_VERSION");
673        let (major, minor, patch) = parse_semver_triplet(current).expect("current parses");
674        let other_patch = format!("{major}.{minor}.{}", patch.wrapping_add(1));
675        let mut stderr = String::new();
676        check_harn_version_compat(&other_patch, &mut stderr).expect("patch mismatch warns");
677        assert!(stderr.contains("patch mismatch"), "stderr was {stderr}");
678    }
679
680    #[test]
681    fn check_harn_version_compat_refuses_on_minor_mismatch() {
682        let current = env!("CARGO_PKG_VERSION");
683        let (major, minor, _patch) = parse_semver_triplet(current).expect("current parses");
684        let other_minor = format!("{major}.{}.0", minor.wrapping_add(1));
685        let mut stderr = String::new();
686        let err = check_harn_version_compat(&other_minor, &mut stderr)
687            .expect_err("minor mismatch must refuse");
688        assert_eq!(err.code, "harnpack.version_mismatch");
689    }
690
691    #[test]
692    fn check_harn_version_compat_is_lenient_with_unparseable_bundle_version() {
693        let mut stderr = String::new();
694        check_harn_version_compat("not-a-version", &mut stderr).expect("permissive on parse fail");
695        assert!(stderr.contains("not parseable"));
696    }
697
698    #[test]
699    fn join_safe_refuses_traversal() {
700        let base = PathBuf::from("/tmp/cache");
701        assert!(join_safe(&base, Path::new("../escape")).is_err());
702        assert!(join_safe(&base, Path::new("/abs/path")).is_err());
703        assert_eq!(
704            join_safe(&base, Path::new("sources/hello.harn")).unwrap(),
705            base.join("sources").join("hello.harn"),
706        );
707    }
708
709    #[test]
710    fn fresh_replay_keeps_one_projected_copy_of_generated_artifacts() {
711        let temp = tempfile::tempdir().expect("tempdir");
712        let cache_dir = temp.path().join("slot");
713        let contents = vec![
714            HarnpackEntry::new("sources/hello.harn", b"fn main() {}\n"),
715            HarnpackEntry::new("bytecode/hello.harnbc", b"entry-bytecode"),
716            HarnpackEntry::new("bytecode/hello.harnmod", b"module-bytecode"),
717        ];
718        let plan = plan_replay(&contents).expect("plan replay");
719
720        replay_archive(&cache_dir, &WorkflowBundle::default(), &plan).expect("replay");
721
722        assert_eq!(
723            fs::read(cache_dir.join("sources/hello.harnbc")).unwrap(),
724            b"entry-bytecode"
725        );
726        assert_eq!(
727            fs::read(cache_dir.join("sources/hello.harnmod")).unwrap(),
728            b"module-bytecode"
729        );
730        assert!(
731            !cache_dir.join("bytecode").exists(),
732            "a fresh replay must project rather than duplicate generated artifacts"
733        );
734    }
735
736    #[test]
737    fn matching_old_layout_cache_gets_missing_adjacent_projection() {
738        let temp = tempfile::tempdir().expect("tempdir");
739        let cache_dir = temp.path().join("slot");
740        fs::create_dir_all(cache_dir.join("bytecode")).unwrap();
741        fs::create_dir_all(cache_dir.join("sources")).unwrap();
742        fs::write(cache_dir.join("sources/hello.harn"), "tampered source\n").unwrap();
743        fs::write(cache_dir.join("harnpack.json"), b"{}").unwrap();
744        fs::write(
745            cache_dir.join("bytecode/hello.harnbc"),
746            b"old-layout-bytecode",
747        )
748        .unwrap();
749        let contents = vec![
750            HarnpackEntry::new("sources/hello.harn", b"fn main() {}\n"),
751            HarnpackEntry::new("bytecode/hello.harnbc", b"old-layout-bytecode"),
752        ];
753        let plan = plan_replay(&contents).expect("plan replay");
754        let manifest = WorkflowBundle::default();
755
756        ensure_replay_projection(&cache_dir, &manifest, &plan).expect("upgrade old cache");
757        ensure_replay_projection(&cache_dir, &manifest, &plan).expect("idempotent retry");
758
759        assert_eq!(
760            fs::read(cache_dir.join("sources/hello.harnbc")).unwrap(),
761            b"old-layout-bytecode"
762        );
763        assert_eq!(
764            fs::read(cache_dir.join("sources/hello.harn")).unwrap(),
765            b"fn main() {}\n",
766            "verified archive source repairs a tampered cache hit"
767        );
768        assert_eq!(
769            fs::read(cache_dir.join("harnpack.json")).unwrap(),
770            serde_json::to_vec(&manifest).unwrap(),
771            "synthetic manifest is repaired to its canonical replay bytes"
772        );
773    }
774
775    #[cfg(unix)]
776    #[test]
777    fn replay_repair_rejects_symlinked_parent() {
778        use std::os::unix::fs::symlink;
779
780        let temp = tempfile::tempdir().expect("tempdir");
781        let cache_dir = temp.path().join("slot");
782        let outside = temp.path().join("outside");
783        fs::create_dir_all(&cache_dir).unwrap();
784        fs::create_dir_all(&outside).unwrap();
785        symlink(&outside, cache_dir.join("sources")).unwrap();
786        let contents = vec![HarnpackEntry::new("sources/hello.harn", b"fn main() {}\n")];
787        let plan = plan_replay(&contents).expect("plan replay");
788
789        let error = ensure_replay_projection(&cache_dir, &WorkflowBundle::default(), &plan)
790            .expect_err("symlinked parent must fail closed");
791        assert_eq!(error.code, "harnpack.replay_collision");
792        assert!(
793            !outside.join("hello.harn").exists(),
794            "repair must not follow the cached parent symlink"
795        );
796    }
797
798    #[test]
799    fn projected_artifact_collision_fails_before_replay() {
800        let contents = vec![
801            HarnpackEntry::new("sources/hello.harn", b"fn main() {}\n"),
802            HarnpackEntry::new("sources/hello.harnbc", b"user asset"),
803            HarnpackEntry::new("bytecode/hello.harnbc", b"generated artifact"),
804        ];
805        let error = plan_replay(&contents).expect_err("collision must fail closed");
806        assert_eq!(error.code, "harnpack.replay_collision");
807    }
808
809    #[test]
810    fn incompatible_linked_program_fails_closed_or_reports_explicit_fallback() {
811        let temp = tempfile::tempdir().expect("tempdir");
812        let entry_path = temp.path().join("entry.harn");
813        let source = "fn main(harness: Harness) { harness.stdio.println(\"linked\") }\n";
814        fs::write(&entry_path, source).expect("entry source");
815        let linked =
816            harn_vm::linked_program::link_program(&entry_path, temp.path()).expect("link program");
817        let mut bytes = linked.encode().expect("encode linked program");
818        bytes[8..12].copy_from_slice(&2_u32.to_le_bytes());
819        let hash = format!("blake3:{}", blake3::hash(&bytes).to_hex());
820        let descriptor = harn_vm::orchestration::ExecutionArtifact {
821            format: "harn.linked_program.v1".to_string(),
822            path: PathBuf::from(harn_vm::linked_program::LINKED_PROGRAM_ARCHIVE_PATH),
823            hash_blake3: hash,
824            graph_digest_blake3: linked.identity.graph_digest_blake3.clone(),
825            fallback: ExecutionArtifactFallback::Deny,
826            link_report: linked.report,
827        };
828        let mut manifest = WorkflowBundle {
829            entrypoint: PathBuf::from("entry.harn"),
830            execution_artifact: Some(descriptor),
831            transitive_modules: vec![harn_vm::orchestration::ModuleEntry {
832                path: PathBuf::from("entry.harn"),
833                source_hash_blake3: format!("blake3:{}", blake3::hash(source.as_bytes()).to_hex()),
834                harnbc_hash_blake3: String::new(),
835            }],
836            ..WorkflowBundle::default()
837        };
838        let contents = vec![
839            HarnpackEntry::new("sources/entry.harn", source.as_bytes()),
840            HarnpackEntry::new(harn_vm::linked_program::LINKED_PROGRAM_ARCHIVE_PATH, bytes),
841        ];
842
843        let error = prepare_execution_artifact(&manifest, &contents)
844            .expect_err("default policy must fail closed");
845        assert_eq!(error.code, "linked_program.incompatible");
846
847        manifest.execution_artifact.as_mut().unwrap().fallback =
848            ExecutionArtifactFallback::ExactSources;
849        let (artifact, state, reason) = prepare_execution_artifact(&manifest, &contents)
850            .expect("signed policy explicitly allows exact sources");
851        assert!(artifact.is_none());
852        assert_eq!(state, "source_fallback");
853        assert!(reason.is_some_and(|reason| reason.contains("schema 2")));
854    }
855
856    #[test]
857    fn prepare_harnpack_rejects_absolute_manifest_entrypoint() {
858        let temp = tempfile::tempdir().expect("tempdir");
859        let external = temp.path().join("outside.harn");
860        fs::write(&external, "fn main() {}\n").expect("external source");
861
862        let mut bundle = WorkflowBundle {
863            entrypoint: external,
864            ..WorkflowBundle::default()
865        };
866        bundle.harn_version = env!("CARGO_PKG_VERSION").to_string();
867        let bytes = harn_vm::orchestration::build_harnpack(
868            &bundle,
869            &[HarnpackEntry::new("sources/inside.harn", b"fn main() {}\n")],
870        )
871        .expect("build pack");
872        let pack_path = temp.path().join("unsafe.harnpack");
873        fs::write(&pack_path, bytes).expect("pack file");
874
875        let mut stderr = String::new();
876        let err = prepare_harnpack(
877            &pack_path,
878            &HarnpackRunOptions {
879                allow_unsigned: true,
880                dry_run_verify: false,
881            },
882            &mut stderr,
883        )
884        .expect_err("absolute entrypoint must be rejected");
885        assert_eq!(err.code, "harnpack.unsafe_path");
886    }
887
888    #[test]
889    fn prepare_harnpack_rejects_traversing_manifest_entrypoint() {
890        let temp = tempfile::tempdir().expect("tempdir");
891        let mut bundle = WorkflowBundle {
892            entrypoint: PathBuf::from("../outside.harn"),
893            ..WorkflowBundle::default()
894        };
895        bundle.harn_version = env!("CARGO_PKG_VERSION").to_string();
896        let bytes = harn_vm::orchestration::build_harnpack(
897            &bundle,
898            &[HarnpackEntry::new("outside.harn", b"fn main() {}\n")],
899        )
900        .expect("build pack");
901        let pack_path = temp.path().join("traversal.harnpack");
902        fs::write(&pack_path, bytes).expect("pack file");
903
904        let mut stderr = String::new();
905        let err = prepare_harnpack(
906            &pack_path,
907            &HarnpackRunOptions {
908                allow_unsigned: true,
909                dry_run_verify: false,
910            },
911            &mut stderr,
912        )
913        .expect_err("traversing entrypoint must be rejected");
914        assert_eq!(err.code, "harnpack.unsafe_path");
915    }
916}