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::fmt::Write;
10use std::fs;
11use std::io;
12use std::path::{Component, Path, PathBuf};
13
14use harn_vm::bytecode_cache;
15use harn_vm::orchestration::{
16    read_harnpack, verify_workflow_bundle_signature, workflow_bundle_hash, HarnpackEntry,
17    WorkflowBundle, WorkflowBundleError,
18};
19
20/// Zstandard magic prefix. `.harnpack` archives are zstd-compressed tar
21/// streams, so the on-disk byte signature is the zstd frame header.
22const ZSTD_MAGIC: &[u8; 4] = &[0x28, 0xb5, 0x2f, 0xfd];
23
24/// Options for [`prepare_harnpack`].
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
26pub struct HarnpackRunOptions {
27    /// Run the pack even when it carries no Ed25519 signature.
28    pub allow_unsigned: bool,
29    /// Verify-only mode: stop after the cache replay and emit a
30    /// `pack_run` event without executing the entrypoint.
31    pub dry_run_verify: bool,
32}
33
34/// Outcome of [`prepare_harnpack`]. The CLI surface uses this to (a)
35/// emit the `pack_run` event before the run starts, (b) decide whether
36/// to short-circuit on `--dry-run-verify`, and (c) hand off the unpacked
37/// entrypoint path to the existing source-execution code path.
38#[derive(Debug)]
39pub struct PreparedHarnpack {
40    pub bundle_hash: String,
41    pub signature_verified: bool,
42    pub key_id: Option<String>,
43    pub cache_hit: bool,
44    pub cache_dir: PathBuf,
45    pub entrypoint_path: PathBuf,
46    pub manifest: WorkflowBundle,
47}
48
49#[derive(Debug)]
50pub struct HarnpackError {
51    pub code: &'static str,
52    pub message: String,
53}
54
55impl HarnpackError {
56    fn new(code: &'static str, message: impl Into<String>) -> Self {
57        Self {
58            code,
59            message: message.into(),
60        }
61    }
62}
63
64impl std::fmt::Display for HarnpackError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}", self.message)
67    }
68}
69
70impl std::error::Error for HarnpackError {}
71
72impl From<WorkflowBundleError> for HarnpackError {
73    fn from(error: WorkflowBundleError) -> Self {
74        Self::new("harnpack.archive", error.message)
75    }
76}
77
78/// Detect whether `path` references a `.harnpack` bundle by extension
79/// or zstd magic header. The magic-header path keeps detection robust
80/// for renamed bundles (`./bundle` without extension) which is the
81/// failure mode that bit us when users curl bundles without `-o`.
82pub fn looks_like_harnpack(path: &Path) -> bool {
83    if path.extension().and_then(|ext| ext.to_str()) == Some("harnpack") {
84        return true;
85    }
86    match fs::File::open(path) {
87        Ok(mut file) => {
88            use std::io::Read;
89            let mut buf = [0u8; 4];
90            file.read_exact(&mut buf).is_ok() && &buf == ZSTD_MAGIC
91        }
92        Err(_) => false,
93    }
94}
95
96/// Verify the bundle at `path`, replay it into the content-addressed
97/// pack cache, and return the unpacked entrypoint to execute.
98///
99/// Errors map to user-facing exit-code-1 messages on the CLI; the
100/// [`HarnpackError::code`] discriminates failure modes for JSON
101/// callers and tests.
102pub fn prepare_harnpack<W: Write>(
103    path: &Path,
104    options: &HarnpackRunOptions,
105    stderr: &mut W,
106) -> Result<PreparedHarnpack, HarnpackError> {
107    let bytes = fs::read(path).map_err(|err| {
108        HarnpackError::new(
109            "harnpack.read_failed",
110            format!("failed to read {}: {err}", path.display()),
111        )
112    })?;
113    let archive = read_harnpack(&bytes)?;
114    let manifest = archive.manifest;
115    let contents = archive.contents;
116
117    let (signature_verified, key_id) = match manifest.signature.as_ref() {
118        Some(signature) => {
119            verify_workflow_bundle_signature(&manifest, &contents)?;
120            (true, signature.key_id.clone())
121        }
122        None => {
123            if !options.allow_unsigned {
124                return Err(HarnpackError::new(
125                    "harnpack.unsigned",
126                    format!(
127                        "refusing to run unsigned bundle {} \
128                         (re-run with --allow-unsigned to override)",
129                        path.display()
130                    ),
131                ));
132            }
133            (false, None)
134        }
135    };
136
137    check_harn_version_compat(&manifest.harn_version, stderr)?;
138    let bundle_hash = workflow_bundle_hash(&manifest, &contents)?;
139    let cache_dir = bytecode_cache::packs_cache_dir().join(sanitize_bundle_hash(&bundle_hash));
140    let cache_hit = manifest_already_replayed(&cache_dir, &manifest)?;
141    if !cache_hit {
142        replay_archive(&cache_dir, &manifest, &contents)?;
143    }
144
145    let entrypoint_rel = join_safe_nonempty(Path::new(""), &manifest.entrypoint)?;
146    let entrypoint_path = cache_dir.join("sources").join(entrypoint_rel);
147    if !entrypoint_path.exists() {
148        return Err(HarnpackError::new(
149            "harnpack.missing_entrypoint",
150            format!(
151                "manifest entrypoint {} not present in unpacked bundle at {}",
152                manifest.entrypoint.display(),
153                entrypoint_path.display()
154            ),
155        ));
156    }
157
158    Ok(PreparedHarnpack {
159        bundle_hash,
160        signature_verified,
161        key_id,
162        cache_hit,
163        cache_dir,
164        entrypoint_path,
165        manifest,
166    })
167}
168
169/// Translate a `blake3:<hex>` digest into a filename-safe directory
170/// component. `:` is illegal in some path layers (Windows, `tar`
171/// member names), so swap it for `_` while keeping the algorithm
172/// prefix for forensic readability.
173fn sanitize_bundle_hash(hash: &str) -> String {
174    hash.replace(':', "_")
175}
176
177/// `harn_version` compatibility check: refuse on a major or minor
178/// mismatch, warn on a patch mismatch. The contract is documented on
179/// issue #1784.
180fn check_harn_version_compat<W: Write>(
181    bundle_version: &str,
182    stderr: &mut W,
183) -> Result<(), HarnpackError> {
184    let current_version = env!("CARGO_PKG_VERSION");
185    if bundle_version == current_version {
186        return Ok(());
187    }
188    let (Some(bundle), Some(current)) = (
189        parse_semver_triplet(bundle_version),
190        parse_semver_triplet(current_version),
191    ) else {
192        let _ = writeln!(
193            stderr,
194            "warning: harnpack harn_version {bundle_version} is not parseable; running anyway"
195        );
196        return Ok(());
197    };
198    if bundle.0 != current.0 || bundle.1 != current.1 {
199        return Err(HarnpackError::new(
200            "harnpack.version_mismatch",
201            format!(
202                "harnpack was built for harn {bundle_version}; \
203                 this runtime is {current_version} (major/minor mismatch refused)"
204            ),
205        ));
206    }
207    let _ = writeln!(
208        stderr,
209        "warning: harnpack was built for harn {bundle_version}; \
210         this runtime is {current_version} (patch mismatch)"
211    );
212    Ok(())
213}
214
215/// Parse the `major.minor.patch` triplet from a version string,
216/// ignoring any pre-release or build metadata. Returns `None` when the
217/// string can't be parsed as `<u32>.<u32>.<u32>` at the front — callers
218/// fall back to a permissive warning so unusual version pins don't
219/// strand a working bundle.
220fn parse_semver_triplet(input: &str) -> Option<(u32, u32, u32)> {
221    let core = input.split_once('-').map(|(head, _)| head).unwrap_or(input);
222    let core = core.split_once('+').map(|(head, _)| head).unwrap_or(core);
223    let mut parts = core.split('.');
224    let major = parts.next()?.parse().ok()?;
225    let minor = parts.next()?.parse().ok()?;
226    let patch = parts.next()?.parse().ok()?;
227    Some((major, minor, patch))
228}
229
230/// Returns true when `cache_dir` already holds a previously-replayed
231/// archive whose `harnpack.json` matches `manifest`. Content addressing
232/// (`bundle_hash` in the directory name) makes a single positive match
233/// sufficient; we still cross-check the manifest payload to defend
234/// against partial writes from a prior crash.
235fn manifest_already_replayed(
236    cache_dir: &Path,
237    manifest: &WorkflowBundle,
238) -> Result<bool, HarnpackError> {
239    let manifest_path = cache_dir.join("harnpack.json");
240    let Ok(bytes) = fs::read(&manifest_path) else {
241        return Ok(false);
242    };
243    let cached: WorkflowBundle = match serde_json::from_slice(&bytes) {
244        Ok(value) => value,
245        Err(_) => return Ok(false),
246    };
247    Ok(&cached == manifest)
248}
249
250/// Unpack the bundle into a fresh staging directory and then rename
251/// into the content-addressed cache slot atomically. The intermediate
252/// directory keeps a crash mid-extract from leaving a half-populated
253/// `<bundle_hash>/` that future runs would mistake for a cache hit.
254fn replay_archive(
255    cache_dir: &Path,
256    manifest: &WorkflowBundle,
257    contents: &[HarnpackEntry],
258) -> Result<(), HarnpackError> {
259    let parent = cache_dir.parent().ok_or_else(|| {
260        HarnpackError::new(
261            "harnpack.replay_failed",
262            format!("pack cache path has no parent: {}", cache_dir.display()),
263        )
264    })?;
265    fs::create_dir_all(parent).map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
266    let staging = tempfile::Builder::new()
267        .prefix(".staging-")
268        .tempdir_in(parent)
269        .map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
270    let staging_path = staging.path().to_path_buf();
271
272    for entry in contents {
273        let dest = join_safe(&staging_path, &entry.path)?;
274        if let Some(parent) = dest.parent() {
275            fs::create_dir_all(parent)
276                .map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
277        }
278        fs::write(&dest, &entry.bytes)
279            .map_err(|err| io_err("harnpack.replay_failed", err, &dest))?;
280    }
281
282    let manifest_bytes = serde_json::to_vec(manifest).map_err(|err| {
283        HarnpackError::new(
284            "harnpack.replay_failed",
285            format!("failed to encode manifest for cache: {err}"),
286        )
287    })?;
288    let manifest_path = staging_path.join("harnpack.json");
289    fs::write(&manifest_path, &manifest_bytes)
290        .map_err(|err| io_err("harnpack.replay_failed", err, &manifest_path))?;
291
292    // `rename` is atomic on the same filesystem. Two concurrent runs
293    // unpacking the same bundle hash will both attempt the rename;
294    // whichever loses sees the destination already present and treats
295    // it as authoritative (content addressing guarantees equivalence).
296    // `TempDir::into_path()` defuses the auto-cleanup so the rename
297    // owns the directory.
298    let staged = staging.keep();
299    match fs::rename(&staged, cache_dir) {
300        Ok(()) => Ok(()),
301        Err(err) if cache_dir.join("harnpack.json").exists() => {
302            let _ = fs::remove_dir_all(&staged);
303            // The other writer's tree is now in place — pretend we won.
304            let _ = err;
305            Ok(())
306        }
307        Err(err) => {
308            let _ = fs::remove_dir_all(&staged);
309            Err(io_err("harnpack.replay_failed", err, cache_dir))
310        }
311    }
312}
313
314fn io_err(code: &'static str, err: io::Error, path: &Path) -> HarnpackError {
315    HarnpackError::new(code, format!("{}: {err}", path.display()))
316}
317
318/// Join an archive-relative path onto `base` while refusing anything
319/// that would escape via `..` or absolute components. `read_harnpack`
320/// already rejects unsafe entries at archive parse time; this is
321/// belt-and-braces defense for paths we synthesize on the host side.
322fn join_safe(base: &Path, rel: &Path) -> Result<PathBuf, HarnpackError> {
323    let mut out = base.to_path_buf();
324    for component in rel.components() {
325        match component {
326            Component::Normal(part) => out.push(part),
327            Component::CurDir => {}
328            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
329                return Err(HarnpackError::new(
330                    "harnpack.unsafe_path",
331                    format!("refusing to unpack unsafe path: {}", rel.display()),
332                ));
333            }
334        }
335    }
336    Ok(out)
337}
338
339fn join_safe_nonempty(base: &Path, rel: &Path) -> Result<PathBuf, HarnpackError> {
340    let out = join_safe(base, rel)?;
341    if out == base {
342        return Err(HarnpackError::new(
343            "harnpack.unsafe_path",
344            "refusing to use empty harnpack entrypoint",
345        ));
346    }
347    Ok(out)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn semver_triplet_parses_release_versions() {
356        assert_eq!(parse_semver_triplet("1.2.3"), Some((1, 2, 3)));
357        assert_eq!(parse_semver_triplet("0.10.42"), Some((0, 10, 42)));
358        assert_eq!(parse_semver_triplet("1.2.3-rc.1"), Some((1, 2, 3)));
359        assert_eq!(parse_semver_triplet("1.2.3+build.4"), Some((1, 2, 3)));
360        assert_eq!(parse_semver_triplet("garbage"), None);
361        assert_eq!(parse_semver_triplet("1.2"), None);
362    }
363
364    #[test]
365    fn sanitize_bundle_hash_replaces_colon() {
366        assert_eq!(sanitize_bundle_hash("blake3:abc"), "blake3_abc");
367        assert_eq!(sanitize_bundle_hash("nohash"), "nohash");
368    }
369
370    #[test]
371    fn check_harn_version_compat_warns_on_patch_mismatch() {
372        let current = env!("CARGO_PKG_VERSION");
373        let (major, minor, patch) = parse_semver_triplet(current).expect("current parses");
374        let other_patch = format!("{major}.{minor}.{}", patch.wrapping_add(1));
375        let mut stderr = String::new();
376        check_harn_version_compat(&other_patch, &mut stderr).expect("patch mismatch warns");
377        assert!(stderr.contains("patch mismatch"), "stderr was {stderr}");
378    }
379
380    #[test]
381    fn check_harn_version_compat_refuses_on_minor_mismatch() {
382        let current = env!("CARGO_PKG_VERSION");
383        let (major, minor, _patch) = parse_semver_triplet(current).expect("current parses");
384        let other_minor = format!("{major}.{}.0", minor.wrapping_add(1));
385        let mut stderr = String::new();
386        let err = check_harn_version_compat(&other_minor, &mut stderr)
387            .expect_err("minor mismatch must refuse");
388        assert_eq!(err.code, "harnpack.version_mismatch");
389    }
390
391    #[test]
392    fn check_harn_version_compat_is_lenient_with_unparseable_bundle_version() {
393        let mut stderr = String::new();
394        check_harn_version_compat("not-a-version", &mut stderr).expect("permissive on parse fail");
395        assert!(stderr.contains("not parseable"));
396    }
397
398    #[test]
399    fn join_safe_refuses_traversal() {
400        let base = PathBuf::from("/tmp/cache");
401        assert!(join_safe(&base, Path::new("../escape")).is_err());
402        assert!(join_safe(&base, Path::new("/abs/path")).is_err());
403        assert_eq!(
404            join_safe(&base, Path::new("sources/hello.harn")).unwrap(),
405            base.join("sources").join("hello.harn"),
406        );
407    }
408
409    #[test]
410    fn prepare_harnpack_rejects_absolute_manifest_entrypoint() {
411        let temp = tempfile::tempdir().expect("tempdir");
412        let external = temp.path().join("outside.harn");
413        fs::write(&external, "fn main() {}\n").expect("external source");
414
415        let mut bundle = WorkflowBundle {
416            entrypoint: external,
417            ..WorkflowBundle::default()
418        };
419        bundle.harn_version = env!("CARGO_PKG_VERSION").to_string();
420        let bytes = harn_vm::orchestration::build_harnpack(
421            &bundle,
422            &[HarnpackEntry::new("sources/inside.harn", b"fn main() {}\n")],
423        )
424        .expect("build pack");
425        let pack_path = temp.path().join("unsafe.harnpack");
426        fs::write(&pack_path, bytes).expect("pack file");
427
428        let mut stderr = String::new();
429        let err = prepare_harnpack(
430            &pack_path,
431            &HarnpackRunOptions {
432                allow_unsigned: true,
433                dry_run_verify: false,
434            },
435            &mut stderr,
436        )
437        .expect_err("absolute entrypoint must be rejected");
438        assert_eq!(err.code, "harnpack.unsafe_path");
439    }
440
441    #[test]
442    fn prepare_harnpack_rejects_traversing_manifest_entrypoint() {
443        let temp = tempfile::tempdir().expect("tempdir");
444        let mut bundle = WorkflowBundle {
445            entrypoint: PathBuf::from("../outside.harn"),
446            ..WorkflowBundle::default()
447        };
448        bundle.harn_version = env!("CARGO_PKG_VERSION").to_string();
449        let bytes = harn_vm::orchestration::build_harnpack(
450            &bundle,
451            &[HarnpackEntry::new("outside.harn", b"fn main() {}\n")],
452        )
453        .expect("build pack");
454        let pack_path = temp.path().join("traversal.harnpack");
455        fs::write(&pack_path, bytes).expect("pack file");
456
457        let mut stderr = String::new();
458        let err = prepare_harnpack(
459            &pack_path,
460            &HarnpackRunOptions {
461                allow_unsigned: true,
462                dry_run_verify: false,
463            },
464            &mut stderr,
465        )
466        .expect_err("traversing entrypoint must be rejected");
467        assert_eq!(err.code, "harnpack.unsafe_path");
468    }
469}