use std::fmt::Write;
use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use harn_vm::bytecode_cache;
use harn_vm::orchestration::{
read_harnpack, verify_workflow_bundle_signature, workflow_bundle_hash, HarnpackEntry,
WorkflowBundle, WorkflowBundleError,
};
const ZSTD_MAGIC: &[u8; 4] = &[0x28, 0xb5, 0x2f, 0xfd];
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HarnpackRunOptions {
pub allow_unsigned: bool,
pub dry_run_verify: bool,
}
#[derive(Debug)]
pub struct PreparedHarnpack {
pub bundle_hash: String,
pub signature_verified: bool,
pub key_id: Option<String>,
pub cache_hit: bool,
pub cache_dir: PathBuf,
pub entrypoint_path: PathBuf,
pub manifest: WorkflowBundle,
}
#[derive(Debug)]
pub struct HarnpackError {
pub code: &'static str,
pub message: String,
}
impl HarnpackError {
fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
impl std::fmt::Display for HarnpackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for HarnpackError {}
impl From<WorkflowBundleError> for HarnpackError {
fn from(error: WorkflowBundleError) -> Self {
Self::new("harnpack.archive", error.message)
}
}
pub fn looks_like_harnpack(path: &Path) -> bool {
if path.extension().and_then(|ext| ext.to_str()) == Some("harnpack") {
return true;
}
match fs::File::open(path) {
Ok(mut file) => {
use std::io::Read;
let mut buf = [0u8; 4];
file.read_exact(&mut buf).is_ok() && &buf == ZSTD_MAGIC
}
Err(_) => false,
}
}
pub fn prepare_harnpack<W: Write>(
path: &Path,
options: &HarnpackRunOptions,
stderr: &mut W,
) -> Result<PreparedHarnpack, HarnpackError> {
let bytes = fs::read(path).map_err(|err| {
HarnpackError::new(
"harnpack.read_failed",
format!("failed to read {}: {err}", path.display()),
)
})?;
let archive = read_harnpack(&bytes)?;
let manifest = archive.manifest;
let contents = archive.contents;
let (signature_verified, key_id) = match manifest.signature.as_ref() {
Some(signature) => {
verify_workflow_bundle_signature(&manifest, &contents)?;
(true, signature.key_id.clone())
}
None => {
if !options.allow_unsigned {
return Err(HarnpackError::new(
"harnpack.unsigned",
format!(
"refusing to run unsigned bundle {} \
(re-run with --allow-unsigned to override)",
path.display()
),
));
}
(false, None)
}
};
check_harn_version_compat(&manifest.harn_version, stderr)?;
let bundle_hash = workflow_bundle_hash(&manifest, &contents)?;
let cache_dir = bytecode_cache::packs_cache_dir().join(sanitize_bundle_hash(&bundle_hash));
let cache_hit = manifest_already_replayed(&cache_dir, &manifest)?;
if !cache_hit {
replay_archive(&cache_dir, &manifest, &contents)?;
}
let entrypoint_path = cache_dir.join("sources").join(&manifest.entrypoint);
if !entrypoint_path.exists() {
return Err(HarnpackError::new(
"harnpack.missing_entrypoint",
format!(
"manifest entrypoint {} not present in unpacked bundle at {}",
manifest.entrypoint.display(),
entrypoint_path.display()
),
));
}
Ok(PreparedHarnpack {
bundle_hash,
signature_verified,
key_id,
cache_hit,
cache_dir,
entrypoint_path,
manifest,
})
}
fn sanitize_bundle_hash(hash: &str) -> String {
hash.replace(':', "_")
}
fn check_harn_version_compat<W: Write>(
bundle_version: &str,
stderr: &mut W,
) -> Result<(), HarnpackError> {
let current_version = env!("CARGO_PKG_VERSION");
if bundle_version == current_version {
return Ok(());
}
let (Some(bundle), Some(current)) = (
parse_semver_triplet(bundle_version),
parse_semver_triplet(current_version),
) else {
let _ = writeln!(
stderr,
"warning: harnpack harn_version {bundle_version} is not parseable; running anyway"
);
return Ok(());
};
if bundle.0 != current.0 || bundle.1 != current.1 {
return Err(HarnpackError::new(
"harnpack.version_mismatch",
format!(
"harnpack was built for harn {bundle_version}; \
this runtime is {current_version} (major/minor mismatch refused)"
),
));
}
let _ = writeln!(
stderr,
"warning: harnpack was built for harn {bundle_version}; \
this runtime is {current_version} (patch mismatch)"
);
Ok(())
}
fn parse_semver_triplet(input: &str) -> Option<(u32, u32, u32)> {
let core = input.split_once('-').map(|(head, _)| head).unwrap_or(input);
let core = core.split_once('+').map(|(head, _)| head).unwrap_or(core);
let mut parts = core.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}
fn manifest_already_replayed(
cache_dir: &Path,
manifest: &WorkflowBundle,
) -> Result<bool, HarnpackError> {
let manifest_path = cache_dir.join("harnpack.json");
let Ok(bytes) = fs::read(&manifest_path) else {
return Ok(false);
};
let cached: WorkflowBundle = match serde_json::from_slice(&bytes) {
Ok(value) => value,
Err(_) => return Ok(false),
};
Ok(&cached == manifest)
}
fn replay_archive(
cache_dir: &Path,
manifest: &WorkflowBundle,
contents: &[HarnpackEntry],
) -> Result<(), HarnpackError> {
let parent = cache_dir.parent().ok_or_else(|| {
HarnpackError::new(
"harnpack.replay_failed",
format!("pack cache path has no parent: {}", cache_dir.display()),
)
})?;
fs::create_dir_all(parent).map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
let staging = tempfile::Builder::new()
.prefix(".staging-")
.tempdir_in(parent)
.map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
let staging_path = staging.path().to_path_buf();
for entry in contents {
let dest = join_safe(&staging_path, &entry.path)?;
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.map_err(|err| io_err("harnpack.replay_failed", err, parent))?;
}
fs::write(&dest, &entry.bytes)
.map_err(|err| io_err("harnpack.replay_failed", err, &dest))?;
}
let manifest_bytes = serde_json::to_vec(manifest).map_err(|err| {
HarnpackError::new(
"harnpack.replay_failed",
format!("failed to encode manifest for cache: {err}"),
)
})?;
let manifest_path = staging_path.join("harnpack.json");
fs::write(&manifest_path, &manifest_bytes)
.map_err(|err| io_err("harnpack.replay_failed", err, &manifest_path))?;
let staged = staging.keep();
match fs::rename(&staged, cache_dir) {
Ok(()) => Ok(()),
Err(err) if cache_dir.join("harnpack.json").exists() => {
let _ = fs::remove_dir_all(&staged);
let _ = err;
Ok(())
}
Err(err) => {
let _ = fs::remove_dir_all(&staged);
Err(io_err("harnpack.replay_failed", err, cache_dir))
}
}
}
fn io_err(code: &'static str, err: io::Error, path: &Path) -> HarnpackError {
HarnpackError::new(code, format!("{}: {err}", path.display()))
}
fn join_safe(base: &Path, rel: &Path) -> Result<PathBuf, HarnpackError> {
let mut out = base.to_path_buf();
for component in rel.components() {
match component {
Component::Normal(part) => out.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(HarnpackError::new(
"harnpack.unsafe_path",
format!("refusing to unpack unsafe path: {}", rel.display()),
));
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn semver_triplet_parses_release_versions() {
assert_eq!(parse_semver_triplet("1.2.3"), Some((1, 2, 3)));
assert_eq!(parse_semver_triplet("0.10.42"), Some((0, 10, 42)));
assert_eq!(parse_semver_triplet("1.2.3-rc.1"), Some((1, 2, 3)));
assert_eq!(parse_semver_triplet("1.2.3+build.4"), Some((1, 2, 3)));
assert_eq!(parse_semver_triplet("garbage"), None);
assert_eq!(parse_semver_triplet("1.2"), None);
}
#[test]
fn sanitize_bundle_hash_replaces_colon() {
assert_eq!(sanitize_bundle_hash("blake3:abc"), "blake3_abc");
assert_eq!(sanitize_bundle_hash("nohash"), "nohash");
}
#[test]
fn check_harn_version_compat_warns_on_patch_mismatch() {
let current = env!("CARGO_PKG_VERSION");
let (major, minor, patch) = parse_semver_triplet(current).expect("current parses");
let other_patch = format!("{major}.{minor}.{}", patch.wrapping_add(1));
let mut stderr = String::new();
check_harn_version_compat(&other_patch, &mut stderr).expect("patch mismatch warns");
assert!(stderr.contains("patch mismatch"), "stderr was {stderr}");
}
#[test]
fn check_harn_version_compat_refuses_on_minor_mismatch() {
let current = env!("CARGO_PKG_VERSION");
let (major, minor, _patch) = parse_semver_triplet(current).expect("current parses");
let other_minor = format!("{major}.{}.0", minor.wrapping_add(1));
let mut stderr = String::new();
let err = check_harn_version_compat(&other_minor, &mut stderr)
.expect_err("minor mismatch must refuse");
assert_eq!(err.code, "harnpack.version_mismatch");
}
#[test]
fn check_harn_version_compat_is_lenient_with_unparseable_bundle_version() {
let mut stderr = String::new();
check_harn_version_compat("not-a-version", &mut stderr).expect("permissive on parse fail");
assert!(stderr.contains("not parseable"));
}
#[test]
fn join_safe_refuses_traversal() {
let base = PathBuf::from("/tmp/cache");
assert!(join_safe(&base, Path::new("../escape")).is_err());
assert!(join_safe(&base, Path::new("/abs/path")).is_err());
assert_eq!(
join_safe(&base, Path::new("sources/hello.harn")).unwrap(),
base.join("sources").join("hello.harn"),
);
}
}