1use std::collections::BTreeMap;
2use std::fmt;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use serde::Deserialize;
8
9use crate::package_snapshot::{package_lock_digest, PackageSnapshot};
10
11mod content_hash;
12pub use content_hash::{
13 compute_archive_content_hash, compute_package_content_hash, is_canonical_package_content_hash,
14 normalized_package_relative_path, verify_package_content_hash, CANONICAL_CONTENT_HASH_PREFIX,
15};
16use content_hash::{
17 compute_package_content_hash_capturing, excluded_package_name, validate_content_hash,
18};
19
20pub const CONTENT_HASH_FILE: &str = ".harn-content-hash";
21pub const CACHE_METADATA_FILE: &str = ".harn-package-cache.toml";
22
23pub struct PackageExecutionGuard {
24 snapshot: Arc<PackageSnapshot>,
25 package_alias: String,
26 expected_lock_digest: String,
27 package_pins: BTreeMap<String, PackageExecutionPin>,
28}
29
30struct PackageExecutionPin {
31 root: PathBuf,
32 content_hash: String,
33}
34
35#[derive(Deserialize)]
36struct ExecutionLock {
37 #[serde(default, rename = "package")]
38 packages: Vec<ExecutionLockPackage>,
39}
40
41#[derive(Deserialize)]
42struct ExecutionLockPackage {
43 name: String,
44 #[serde(default)]
45 source: String,
46 content_hash: Option<String>,
47}
48
49impl PackageExecutionGuard {
50 pub fn new(
51 snapshot: Arc<PackageSnapshot>,
52 package_alias: impl Into<String>,
53 expected_content_hash: impl Into<String>,
54 ) -> Result<Self, PackageExecutionError> {
55 let expected_lock_digest = snapshot.lock_digest().to_string();
56 Self::new_with_lock_digest(
57 snapshot,
58 package_alias,
59 expected_content_hash,
60 expected_lock_digest,
61 )
62 }
63
64 pub fn new_with_lock_digest(
65 snapshot: Arc<PackageSnapshot>,
66 package_alias: impl Into<String>,
67 expected_content_hash: impl Into<String>,
68 expected_lock_digest: impl Into<String>,
69 ) -> Result<Self, PackageExecutionError> {
70 let package_alias = package_alias.into();
71 if !is_safe_package_alias(&package_alias)
72 || !snapshot
73 .package_names()
74 .iter()
75 .any(|name| name == &package_alias)
76 {
77 return Err(PackageExecutionError::Invalid(format!(
78 "package alias '{package_alias}' is not present in generation {}",
79 snapshot.generation()
80 )));
81 }
82 let expected_content_hash = expected_content_hash.into();
83 validate_content_hash(&expected_content_hash)?;
84 let expected_lock_digest = expected_lock_digest.into();
85 validate_content_hash(&expected_lock_digest)?;
86 if snapshot.lock_digest() != expected_lock_digest {
87 return Err(PackageExecutionError::Invalid(format!(
88 "package generation {} lock digest changed since activation: expected {}, got {}",
89 snapshot.generation(),
90 expected_lock_digest,
91 snapshot.lock_digest()
92 )));
93 }
94 let lock_bytes = fs::read(snapshot.lock_path()).map_err(|error| {
95 PackageExecutionError::io("read", snapshot.lock_path().to_path_buf(), error)
96 })?;
97 let actual_lock_digest = package_lock_digest(&lock_bytes);
98 if actual_lock_digest != expected_lock_digest {
99 return Err(PackageExecutionError::Invalid(format!(
100 "package generation {} lock digest changed before guard construction: expected {}, got {}",
101 snapshot.generation(),
102 expected_lock_digest,
103 actual_lock_digest
104 )));
105 }
106 let lock: ExecutionLock =
107 toml::from_str(std::str::from_utf8(&lock_bytes).map_err(|error| {
108 PackageExecutionError::Invalid(format!(
109 "package generation lock is not valid UTF-8: {error}"
110 ))
111 })?)
112 .map_err(|error| {
113 PackageExecutionError::Invalid(format!(
114 "failed to parse package generation lock: {error}"
115 ))
116 })?;
117 let mut package_pins = BTreeMap::new();
118 for package in lock.packages {
119 if !is_safe_package_alias(&package.name) {
120 return Err(PackageExecutionError::Invalid(format!(
121 "package generation contains unsafe alias '{}'",
122 package.name
123 )));
124 }
125 let content_hash = package
126 .content_hash
127 .or_else(|| (package.name == package_alias).then(|| expected_content_hash.clone()));
128 let Some(content_hash) = content_hash else {
129 continue;
130 };
131 validate_content_hash(&content_hash)?;
132 if package.source.starts_with("git+")
133 && !is_canonical_package_content_hash(&content_hash)
134 {
135 return Err(PackageExecutionError::Invalid(format!(
136 "git package '{}' uses an unversioned content hash; run `harn install` to migrate harn.lock",
137 package.name
138 )));
139 }
140 let root = snapshot.packages_root().join(&package.name);
141 if !root.is_dir() {
142 return Err(PackageExecutionError::Invalid(format!(
143 "locked package '{}' is missing from generation {}",
144 package.name,
145 snapshot.generation()
146 )));
147 }
148 let root = root
151 .canonicalize()
152 .map_err(|error| PackageExecutionError::io("canonicalize", root.clone(), error))?;
153 package_pins.insert(package.name, PackageExecutionPin { root, content_hash });
154 }
155 let primary = package_pins.get(&package_alias).ok_or_else(|| {
156 PackageExecutionError::Invalid(format!(
157 "package '{package_alias}' has no content hash in generation {}",
158 snapshot.generation()
159 ))
160 })?;
161 if primary.content_hash != expected_content_hash {
162 return Err(PackageExecutionError::Invalid(format!(
163 "package '{package_alias}' activation hash {} does not match generation hash {}",
164 expected_content_hash, primary.content_hash
165 )));
166 }
167 Ok(Self {
168 snapshot,
169 package_alias,
170 expected_lock_digest,
171 package_pins,
172 })
173 }
174
175 pub fn verify_entry(&self, entry: &Path) -> Result<(), PackageExecutionError> {
176 self.verify_entry_source(entry).map(|_| ())
177 }
178
179 pub(crate) fn validate_import_path(
185 &self,
186 current_file: &Path,
187 import_path: &str,
188 ) -> Result<(), PackageExecutionError> {
189 if Path::new(import_path).is_absolute() {
192 return Ok(());
193 }
194 if import_path.contains('\\') {
195 return Err(PackageExecutionError::Invalid(format!(
196 "package import '{import_path}' from {} must be a slash-separated relative path",
197 current_file.display()
198 )));
199 }
200 let relative = lexical_package_relative_path(
201 current_file,
202 self.snapshot.packages_root(),
203 self.snapshot.generation(),
204 )?;
205 let mut components = relative.components();
206 let package_alias = match components.next() {
207 Some(Component::Normal(alias)) => alias.to_str().ok_or_else(|| {
208 PackageExecutionError::Invalid(format!(
209 "importing file {} has a non-UTF-8 package alias",
210 current_file.display()
211 ))
212 })?,
213 _ => {
214 return Err(PackageExecutionError::Invalid(format!(
215 "importing file {} has no package alias in generation {}",
216 current_file.display(),
217 self.snapshot.generation()
218 )));
219 }
220 };
221 let components = components.collect::<Vec<_>>();
222 let Some((file_name, parent_components)) = components.split_last() else {
223 return Err(PackageExecutionError::Invalid(format!(
224 "importing path {} does not name a file inside package '{package_alias}'",
225 current_file.display()
226 )));
227 };
228 if !matches!(file_name, Component::Normal(_)) {
229 return Err(PackageExecutionError::Invalid(format!(
230 "importing path {} does not name a file inside package '{package_alias}'",
231 current_file.display()
232 )));
233 }
234 let mut depth = 0usize;
235 for component in parent_components {
236 match component {
237 Component::Normal(_) => depth += 1,
238 Component::CurDir => {}
239 Component::ParentDir if depth == 0 => {
240 return Err(PackageExecutionError::Invalid(format!(
241 "importing path {} escapes package alias '{package_alias}'",
242 current_file.display()
243 )));
244 }
245 Component::ParentDir => depth -= 1,
246 Component::RootDir | Component::Prefix(_) => {
247 return Err(PackageExecutionError::Invalid(format!(
248 "importing path {} has an unsafe package-relative path",
249 current_file.display()
250 )));
251 }
252 }
253 }
254 for component in import_path.split('/') {
255 match component {
256 "" | "." => {}
257 ".." if depth == 0 => {
258 return Err(PackageExecutionError::Invalid(format!(
259 "package import '{import_path}' from {} escapes package alias '{package_alias}'",
260 current_file.display()
261 )));
262 }
263 ".." => depth -= 1,
264 _ => depth += 1,
265 }
266 }
267 Ok(())
268 }
269
270 pub fn verify_entry_source(&self, entry: &Path) -> Result<Vec<u8>, PackageExecutionError> {
274 let canonical_entry = entry.canonicalize().map_err(|error| {
275 PackageExecutionError::io("canonicalize", entry.to_path_buf(), error)
276 })?;
277 if !canonical_entry.is_file() {
278 return Err(PackageExecutionError::Invalid(format!(
279 "entry {} is not a regular file in generation {}",
280 entry.display(),
281 self.snapshot.generation()
282 )));
283 }
284 let relative_to_generation = lexical_package_relative_path(
285 entry,
286 self.snapshot.packages_root(),
287 self.snapshot.generation(),
288 )?;
289 let mut components = relative_to_generation.components();
290 let package_alias = match components.next() {
291 Some(Component::Normal(alias)) => alias.to_str().ok_or_else(|| {
292 PackageExecutionError::Invalid(format!(
293 "entry {} has a non-UTF-8 package alias",
294 entry.display()
295 ))
296 })?,
297 _ => {
298 return Err(PackageExecutionError::Invalid(format!(
299 "entry {} has no package alias in generation {}",
300 entry.display(),
301 self.snapshot.generation()
302 )));
303 }
304 };
305 let mut requested_relative = PathBuf::new();
306 for component in components {
307 match component {
308 Component::Normal(part) => requested_relative.push(part),
309 Component::CurDir => {}
310 Component::ParentDir if requested_relative.pop() => {}
311 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
312 return Err(PackageExecutionError::Invalid(format!(
313 "entry {} has an unsafe package-relative path",
314 entry.display()
315 )));
316 }
317 }
318 }
319 if requested_relative.as_os_str().is_empty() {
320 return Err(PackageExecutionError::Invalid(format!(
321 "entry {} does not name a file inside package '{package_alias}'",
322 entry.display()
323 )));
324 }
325 let pin = self.package_pins.get(package_alias).ok_or_else(|| {
326 PackageExecutionError::Invalid(format!(
327 "package alias '{package_alias}' is not content-pinned for activated package '{}'",
328 self.package_alias
329 ))
330 })?;
331 if !canonical_entry.starts_with(&pin.root) {
332 return Err(PackageExecutionError::Invalid(format!(
333 "package alias '{package_alias}' was retargeted outside its pinned root {}",
334 pin.root.display()
335 )));
336 }
337 let relative = canonical_entry.strip_prefix(&pin.root).map_err(|error| {
338 PackageExecutionError::Invalid(format!(
339 "failed to relativize package entry {}: {error}",
340 canonical_entry.display()
341 ))
342 })?;
343 if relative != requested_relative {
344 return Err(PackageExecutionError::Invalid(format!(
345 "entry {} was retargeted within package '{package_alias}' from {} to {}",
346 entry.display(),
347 requested_relative.display(),
348 relative.display()
349 )));
350 }
351 if relative
352 .components()
353 .any(|component| excluded_package_name(component.as_os_str()))
354 {
355 return Err(PackageExecutionError::Invalid(format!(
356 "entry {} is excluded from package '{}' content identity",
357 entry.display(),
358 package_alias
359 )));
360 }
361 let lock_bytes = fs::read(self.snapshot.lock_path()).map_err(|error| {
362 PackageExecutionError::io("read", self.snapshot.lock_path().to_path_buf(), error)
363 })?;
364 let actual_lock_digest = package_lock_digest(&lock_bytes);
365 if actual_lock_digest != self.expected_lock_digest {
366 return Err(PackageExecutionError::Invalid(format!(
367 "package generation {} lock digest changed: expected {}, got {}",
368 self.snapshot.generation(),
369 self.expected_lock_digest,
370 actual_lock_digest
371 )));
372 }
373 let (actual_content_hash, source) =
374 compute_package_content_hash_capturing(&pin.root, Some(relative), &pin.content_hash)?;
375 if actual_content_hash != pin.content_hash {
376 return Err(PackageExecutionError::Invalid(format!(
377 "package '{}' content changed in generation {}: expected {}, got {}",
378 package_alias,
379 self.snapshot.generation(),
380 pin.content_hash,
381 actual_content_hash
382 )));
383 }
384 source.ok_or_else(|| {
385 PackageExecutionError::Invalid(format!(
386 "entry {} disappeared while verifying package '{}'",
387 entry.display(),
388 self.package_alias
389 ))
390 })
391 }
392
393 pub fn snapshot(&self) -> &PackageSnapshot {
394 &self.snapshot
395 }
396
397 pub fn package_alias(&self) -> &str {
398 &self.package_alias
399 }
400}
401
402fn lexical_package_relative_path(
403 entry: &Path,
404 canonical_packages_root: &Path,
405 generation: &str,
406) -> Result<PathBuf, PackageExecutionError> {
407 let outside_generation = || {
408 PackageExecutionError::Invalid(format!(
409 "entry {} is outside package generation {} rooted at '{}'",
410 entry.display(),
411 generation,
412 canonical_packages_root.display()
413 ))
414 };
415 if let Some(relative) = lexical_relative_suffix(entry, canonical_packages_root) {
418 return Ok(relative);
419 }
420 let mut input_packages_root = None;
421 for ancestor in entry.ancestors() {
422 if ancestor
423 .canonicalize()
424 .is_ok_and(|canonical| canonical == canonical_packages_root)
425 {
426 input_packages_root = Some(ancestor);
427 }
428 }
429 let input_packages_root = input_packages_root.ok_or_else(&outside_generation)?;
430 lexical_relative_suffix(entry, input_packages_root).ok_or_else(outside_generation)
431}
432
433fn lexical_relative_suffix(entry: &Path, root: &Path) -> Option<PathBuf> {
434 let mut entry_components = entry.components();
435 for root_component in root.components() {
436 if entry_components.next() != Some(root_component) {
437 return None;
438 }
439 }
440 let mut relative = PathBuf::new();
441 relative.extend(entry_components);
442 Some(relative)
443}
444
445impl fmt::Debug for PackageExecutionGuard {
446 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
447 formatter
448 .debug_struct("PackageExecutionGuard")
449 .field("project_root", &self.snapshot.project_root())
450 .field("generation", &self.snapshot.generation())
451 .field("package_alias", &self.package_alias)
452 .field("expected_lock_digest", &self.expected_lock_digest)
453 .field("pinned_package_count", &self.package_pins.len())
454 .finish()
455 }
456}
457
458impl PartialEq for PackageExecutionGuard {
459 fn eq(&self, other: &Self) -> bool {
460 self.snapshot.project_root() == other.snapshot.project_root()
461 && self.snapshot.generation() == other.snapshot.generation()
462 && self.snapshot.lock_digest() == other.snapshot.lock_digest()
463 && self.package_alias == other.package_alias
464 && self.expected_lock_digest == other.expected_lock_digest
465 && self.package_pins.len() == other.package_pins.len()
466 && self.package_pins.iter().all(|(name, pin)| {
467 other.package_pins.get(name).is_some_and(|other| {
468 pin.root == other.root && pin.content_hash == other.content_hash
469 })
470 })
471 }
472}
473
474impl Eq for PackageExecutionGuard {}
475
476#[derive(Debug)]
477#[non_exhaustive]
478pub enum PackageExecutionError {
479 Io {
480 operation: &'static str,
481 path: PathBuf,
482 source: std::io::Error,
483 },
484 Invalid(String),
485}
486
487impl PackageExecutionError {
488 fn io(operation: &'static str, path: PathBuf, source: std::io::Error) -> Self {
489 Self::Io {
490 operation,
491 path,
492 source,
493 }
494 }
495}
496
497impl fmt::Display for PackageExecutionError {
498 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
499 match self {
500 Self::Io {
501 operation,
502 path,
503 source,
504 } => write!(
505 formatter,
506 "failed to {operation} {} while verifying package execution: {source}",
507 path.display()
508 ),
509 Self::Invalid(message) => formatter.write_str(message),
510 }
511 }
512}
513
514impl std::error::Error for PackageExecutionError {}
515
516fn is_safe_package_alias(alias: &str) -> bool {
517 let mut components = Path::new(alias).components();
518 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524 use crate::package_snapshot::{
525 generation_root, package_current_path, package_publication_lock_path,
526 PackageGenerationManifest, PackageGenerationPointer, GENERATION_LEASE_FILE,
527 GENERATION_LOCK_FILE, GENERATION_MANIFEST_FILE, GENERATION_PACKAGES_DIR,
528 };
529 use std::fs::File;
530
531 fn fixture() -> (tempfile::TempDir, Arc<PackageSnapshot>, PathBuf, String) {
532 let temp = tempfile::tempdir().unwrap();
533 let generation = "generation_a";
534 let generation_root = generation_root(temp.path(), generation);
535 let package_root = generation_root.join(GENERATION_PACKAGES_DIR).join("agents");
536 fs::create_dir_all(&package_root).unwrap();
537 let entry = package_root.join("run.harn");
538 fs::write(&entry, "pub pipeline run() { return 1 }\n").unwrap();
539 fs::write(
540 package_root.join("harn.toml"),
541 "[package]\nname = \"agents\"\n",
542 )
543 .unwrap();
544 fs::create_dir_all(package_root.join("workflows")).unwrap();
545 fs::write(
546 package_root.join("workflows/run.harn"),
547 "pub pipeline run() { return 1 }\n",
548 )
549 .unwrap();
550 fs::write(
551 package_root.join("helper.harn"),
552 "pub fn helper() { return 1 }\n",
553 )
554 .unwrap();
555 let content_hash = compute_package_content_hash(&package_root).unwrap();
556 let dependency_root = generation_root.join(GENERATION_PACKAGES_DIR).join("shared");
557 fs::create_dir_all(&dependency_root).unwrap();
558 fs::write(
559 dependency_root.join("helper.harn"),
560 "pub fn helper() { return 1 }\n",
561 )
562 .unwrap();
563 fs::write(
564 dependency_root.join("harn.toml"),
565 "[package]\nname = \"shared\"\n\n[exports]\napi = \"safe.harn\"\n",
566 )
567 .unwrap();
568 fs::write(
569 dependency_root.join("safe.harn"),
570 "pub fn value() { return 1 }\n",
571 )
572 .unwrap();
573 fs::write(
574 dependency_root.join("payload.harn"),
575 "pub fn value() { return 2 }\n",
576 )
577 .unwrap();
578 let dependency_hash = compute_package_content_hash(&dependency_root).unwrap();
579 let lock = format!(
580 "version = 5\n\n[[package]]\nname = \"agents\"\nsource = \"git+https://example.test/agents\"\ncontent_hash = \"{content_hash}\"\n\n[[package]]\nname = \"shared\"\nsource = \"git+https://example.test/shared\"\ncontent_hash = \"{dependency_hash}\"\n"
581 );
582 fs::write(generation_root.join(GENERATION_LOCK_FILE), &lock).unwrap();
583 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
584 let manifest =
585 PackageGenerationManifest::new(generation, package_lock_digest(lock.as_bytes()))
586 .unwrap();
587 fs::write(
588 generation_root.join(GENERATION_MANIFEST_FILE),
589 toml::to_string_pretty(&manifest).unwrap(),
590 )
591 .unwrap();
592 fs::write(
593 package_current_path(temp.path()),
594 toml::to_string_pretty(&PackageGenerationPointer::new(generation).unwrap()).unwrap(),
595 )
596 .unwrap();
597 File::create(package_publication_lock_path(temp.path())).unwrap();
598 let snapshot = Arc::new(PackageSnapshot::acquire(temp.path()).unwrap().unwrap());
599 (temp, snapshot, entry, content_hash)
600 }
601
602 #[test]
603 fn canonical_content_hash_projection_is_platform_independent() {
604 let lf = tempfile::tempdir().unwrap();
605 let crlf = tempfile::tempdir().unwrap();
606 for root in [lf.path(), crlf.path()] {
607 fs::create_dir_all(root.join("src")).unwrap();
608 fs::write(root.join("asset.bin"), [0, b'\r', b'\n', 0xff]).unwrap();
609 }
610 fs::write(lf.path().join("harn.toml"), "[package]\nname = \"demo\"\n").unwrap();
611 fs::write(
612 crlf.path().join("harn.toml"),
613 "[package]\r\nname = \"demo\"\r\n",
614 )
615 .unwrap();
616 fs::write(
617 lf.path().join("src/lib.harn"),
618 "pub fn value() -> number { 1 }\n",
619 )
620 .unwrap();
621 fs::write(
622 crlf.path().join("src/lib.harn"),
623 "pub fn value() -> number { 1 }\r\n",
624 )
625 .unwrap();
626
627 #[cfg(unix)]
628 {
629 use std::os::unix::fs::PermissionsExt;
630 fs::set_permissions(
631 lf.path().join("src/lib.harn"),
632 fs::Permissions::from_mode(0o644),
633 )
634 .unwrap();
635 fs::set_permissions(
636 crlf.path().join("src/lib.harn"),
637 fs::Permissions::from_mode(0o755),
638 )
639 .unwrap();
640 }
641
642 let expected = "sha256-v2:899d8ece60275669d4894707420835dbac7530e4179a5e45163ba46add129435";
643 assert_eq!(compute_package_content_hash(lf.path()).unwrap(), expected);
644 assert_eq!(compute_package_content_hash(crlf.path()).unwrap(), expected);
645 }
646
647 #[test]
648 fn canonical_content_hash_normalizes_unicode_paths() {
649 let composed = tempfile::tempdir().unwrap();
650 let decomposed = tempfile::tempdir().unwrap();
651 fs::write(
652 composed.path().join("\u{e9}.harn"),
653 "pub fn value() { 1 }\n",
654 )
655 .unwrap();
656 fs::write(
657 decomposed.path().join("e\u{301}.harn"),
658 "pub fn value() { 1 }\n",
659 )
660 .unwrap();
661
662 assert_eq!(
663 compute_package_content_hash(composed.path()).unwrap(),
664 compute_package_content_hash(decomposed.path()).unwrap()
665 );
666 }
667
668 #[test]
669 fn archive_and_canonical_content_hash_algorithms_are_explicit() {
670 let temp = tempfile::tempdir().unwrap();
671 fs::write(temp.path().join("AGENTS.md"), "# Guidance\n").unwrap();
672 fs::write(temp.path().join("CLAUDE.md"), "# Guidance\n").unwrap();
673 fs::write(temp.path().join("lib.harn"), "pub fn value() { 1 }\n").unwrap();
674
675 let archive = compute_archive_content_hash(temp.path()).unwrap();
676 let canonical = compute_package_content_hash(temp.path()).unwrap();
677
678 assert_eq!(
679 verify_package_content_hash(temp.path(), &archive).unwrap(),
680 archive
681 );
682 assert_eq!(
683 verify_package_content_hash(temp.path(), &canonical).unwrap(),
684 canonical
685 );
686 assert!(!is_canonical_package_content_hash(&archive));
687 assert!(is_canonical_package_content_hash(&canonical));
688 }
689
690 #[test]
691 fn guard_rejects_package_mutation_before_execution() {
692 let (_temp, snapshot, entry, content_hash) = fixture();
693 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
694 let source = guard.verify_entry_source(&entry).unwrap();
695 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
696
697 fs::write(&entry, "pub pipeline run() { return 2 }\n").unwrap();
698 let error = guard.verify_entry(&entry).unwrap_err();
699 assert!(error.to_string().contains("content changed"));
700 }
701
702 #[test]
703 fn guard_retains_generation_lease() {
704 let (_temp, snapshot, entry, content_hash) = fixture();
705 let lease_path = snapshot.generation_root().join(GENERATION_LEASE_FILE);
706 let guard =
707 PackageExecutionGuard::new(Arc::clone(&snapshot), "agents", content_hash).unwrap();
708 drop(snapshot);
709 let lease = File::open(lease_path).unwrap();
710 assert!(lease.try_lock().is_err());
711 guard.verify_entry(&entry).unwrap();
712 drop(guard);
713 lease.try_lock().unwrap();
714 }
715
716 #[test]
717 fn guard_rejects_lock_bytes_not_validated_by_snapshot() {
718 let (_temp, snapshot, _entry, content_hash) = fixture();
719 let mut lock = fs::read(snapshot.lock_path()).unwrap();
720 lock.push(b'\n');
721 fs::write(snapshot.lock_path(), lock).unwrap();
722
723 let error = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap_err();
724
725 assert!(error.to_string().contains("before guard construction"));
726 }
727
728 #[test]
729 fn guard_allows_content_pinned_dependency_entry() {
730 let (_temp, snapshot, _entry, content_hash) = fixture();
731 let dependency = snapshot.packages_root().join("shared/helper.harn");
732 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
733
734 let source = guard.verify_entry_source(&dependency).unwrap();
735
736 assert_eq!(source, b"pub fn helper() { return 1 }\n");
737 }
738
739 #[test]
740 fn guarded_export_resolution_rejects_unverified_manifest_mapping() {
741 let (_temp, snapshot, entry, content_hash) = fixture();
742 let manifest = snapshot.packages_root().join("shared/harn.toml");
743 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
744 fs::write(
745 manifest,
746 "[package]\nname = \"shared\"\n\n[exports]\napi = \"payload.harn\"\n",
747 )
748 .unwrap();
749
750 let error =
751 crate::package_imports::resolve_import_path_with_guard(&entry, "shared/api", &guard)
752 .unwrap_err();
753
754 assert!(error.to_string().contains("content changed"));
755 }
756
757 #[cfg(unix)]
758 #[test]
759 fn guard_rejects_descendant_entry_retargeted_within_package() {
760 let (_temp, snapshot, _entry, content_hash) = fixture();
761 let safe = snapshot.packages_root().join("shared/safe.harn");
762 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
763 fs::remove_file(&safe).unwrap();
764 std::os::unix::fs::symlink("payload.harn", &safe).unwrap();
765
766 let error = guard.verify_entry_source(&safe).unwrap_err();
767
768 assert!(error.to_string().contains("retargeted within package"));
769 }
770
771 #[test]
772 fn guard_normalizes_parent_import_within_package() {
773 let (_temp, snapshot, _entry, content_hash) = fixture();
774 let entry = snapshot
775 .packages_root()
776 .join("agents/workflows/../helper.harn");
777 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
778
779 let source = guard.verify_entry_source(&entry).unwrap();
780
781 assert_eq!(source, b"pub fn helper() { return 1 }\n");
782 }
783
784 #[test]
785 fn guard_rejects_parent_import_escaping_alias_root() {
786 let (_temp, snapshot, _entry, content_hash) = fixture();
787 let entry = snapshot.packages_root().join("agents/run.harn");
788 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
789
790 let error = crate::package_imports::resolve_import_path_with_guard(
791 &entry,
792 "../shared/helper",
793 &guard,
794 )
795 .unwrap_err();
796
797 assert!(error.to_string().contains("escapes package alias"));
798 }
799
800 #[test]
801 fn guard_allows_parent_import_within_package_alias() {
802 let (_temp, snapshot, _entry, content_hash) = fixture();
803 let entry = snapshot.packages_root().join("agents/workflows/run.harn");
804 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
805
806 let path =
807 crate::package_imports::resolve_import_path_with_guard(&entry, "../helper", &guard)
808 .expect("parent traversal remains inside agents")
809 .expect("helper resolves inside agents");
810 assert_eq!(path, entry.parent().unwrap().join("../helper.harn"));
811 }
812
813 #[test]
814 fn guard_rejects_parent_import_after_normalizing_importer_path() {
815 let (_temp, snapshot, _entry, content_hash) = fixture();
816 let entry = snapshot
817 .packages_root()
818 .join("agents/workflows/../helper.harn");
819 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
820
821 let error = crate::package_imports::resolve_import_path_with_guard(
822 &entry,
823 "../shared/helper",
824 &guard,
825 )
826 .unwrap_err();
827
828 assert!(error.to_string().contains("escapes package alias"));
829 }
830
831 #[test]
832 fn guard_leaves_absolute_internal_module_paths_to_entry_verification() {
833 let (_temp, snapshot, entry, content_hash) = fixture();
834 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
835
836 guard
837 .validate_import_path(&entry, entry.to_str().unwrap())
838 .expect("absolute internal module path is checked by verify_entry_source");
839 }
840
841 #[cfg(unix)]
842 #[test]
843 fn guard_rejects_primary_alias_retargeted_to_pinned_dependency() {
844 let (_temp, snapshot, _entry, content_hash) = fixture();
845 let packages = snapshot.packages_root().to_path_buf();
846 let primary = packages.join("agents");
847 let original = packages.join("agents-original");
848 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
849 fs::rename(&primary, &original).unwrap();
850 std::os::unix::fs::symlink(packages.join("shared"), &primary).unwrap();
851
852 let error = guard
853 .verify_entry_source(&primary.join("helper.harn"))
854 .unwrap_err();
855
856 assert!(error.to_string().contains("alias 'agents' was retargeted"));
857 }
858
859 #[cfg(unix)]
860 #[test]
861 fn guard_rejects_dependency_alias_retargeted_to_primary() {
862 let (_temp, snapshot, _entry, content_hash) = fixture();
863 let packages = snapshot.packages_root().to_path_buf();
864 let dependency = packages.join("shared");
865 let original = packages.join("shared-original");
866 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
867 fs::rename(&dependency, &original).unwrap();
868 std::os::unix::fs::symlink(packages.join("agents"), &dependency).unwrap();
869
870 let error = guard
871 .verify_entry_source(&dependency.join("run.harn"))
872 .unwrap_err();
873
874 assert!(error.to_string().contains("alias 'shared' was retargeted"));
875 }
876
877 #[cfg(unix)]
878 #[test]
879 fn content_hash_rejects_descendant_symlink() {
880 let temp = tempfile::tempdir().unwrap();
881 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
882 std::os::unix::fs::symlink("target.harn", temp.path().join("alias.harn")).unwrap();
883
884 let error = compute_package_content_hash(temp.path()).unwrap_err();
885 assert!(error.to_string().contains("unsupported symlink"));
886 }
887
888 #[cfg(unix)]
889 #[test]
890 fn content_hash_ignores_symlinks_at_excluded_paths() {
891 let temp = tempfile::tempdir().unwrap();
896 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
897 fs::write(temp.path().join("ignore-target"), "target/\n").unwrap();
898 std::os::unix::fs::symlink("ignore-target", temp.path().join(".gitignore")).unwrap();
899
900 compute_package_content_hash(temp.path())
901 .expect("a symlink at an excluded path must not invalidate the package");
902 }
903
904 #[cfg(unix)]
905 #[test]
906 fn content_hash_ignores_claude_guidance_projection() {
907 let temp = tempfile::tempdir().unwrap();
908 fs::write(temp.path().join("AGENTS.md"), "# Package guidance\n").unwrap();
909 fs::write(temp.path().join("lib.harn"), "pub fn value() { 1 }\n").unwrap();
910 std::os::unix::fs::symlink("AGENTS.md", temp.path().join("CLAUDE.md")).unwrap();
911
912 compute_package_content_hash(temp.path())
913 .expect("the Claude guidance projection is not executable package content");
914 }
915
916 #[cfg(unix)]
917 #[test]
918 fn guard_accepts_equivalent_root_alias_without_losing_escape_detection() {
919 let (temp, snapshot, entry, content_hash) = fixture();
920 let alias = temp.path().join("project-alias");
921 std::os::unix::fs::symlink(".", &alias).unwrap();
922 let aliased_entry = alias.join(entry.strip_prefix(temp.path()).unwrap());
923 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
924
925 let source = guard.verify_entry_source(&aliased_entry).unwrap();
926
927 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
928 let aliased_packages_root = aliased_entry.parent().unwrap().parent().unwrap();
929 let escape = aliased_packages_root.join("agents/../shared/helper.harn");
930 let error = guard.verify_entry_source(&escape).unwrap_err();
931 assert!(error.to_string().contains("unsafe package-relative path"));
932 }
933}