1use 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
20const ZSTD_MAGIC: &[u8; 4] = &[0x28, 0xb5, 0x2f, 0xfd];
23
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
26pub struct HarnpackRunOptions {
27 pub allow_unsigned: bool,
29 pub dry_run_verify: bool,
32}
33
34#[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
78pub 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
96pub 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
169fn sanitize_bundle_hash(hash: &str) -> String {
174 hash.replace(':', "_")
175}
176
177fn 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
215fn 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
230fn 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
250fn 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 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 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
318fn 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}