1use 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
22const ZSTD_MAGIC: &[u8; 4] = &[0x28, 0xb5, 0x2f, 0xfd];
25
26#[derive(Clone, Debug, Default, PartialEq, Eq)]
28pub struct HarnpackRunOptions {
29 pub allow_unsigned: bool,
31 pub dry_run_verify: bool,
34}
35
36#[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
84pub 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
102pub 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 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
293fn sanitize_bundle_hash(hash: &str) -> String {
298 hash.replace(':', "_")
299}
300
301fn 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
339fn 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
354fn 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
374fn 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 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 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
446fn 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
501fn 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
554fn 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(¤t) {
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(¤t) {
587 Ok(()) => {}
588 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
589 require_real_directory(¤t)?;
590 }
591 Err(error) => {
592 return Err(io_err("harnpack.replay_failed", error, ¤t));
593 }
594 }
595 }
596 Err(error) => return Err(io_err("harnpack.replay_failed", error, ¤t)),
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
618fn 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}