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)]
477pub enum PackageExecutionError {
478 Io {
479 operation: &'static str,
480 path: PathBuf,
481 source: std::io::Error,
482 },
483 Invalid(String),
484}
485
486impl PackageExecutionError {
487 fn io(operation: &'static str, path: PathBuf, source: std::io::Error) -> Self {
488 Self::Io {
489 operation,
490 path,
491 source,
492 }
493 }
494}
495
496impl fmt::Display for PackageExecutionError {
497 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
498 match self {
499 Self::Io {
500 operation,
501 path,
502 source,
503 } => write!(
504 formatter,
505 "failed to {operation} {} while verifying package execution: {source}",
506 path.display()
507 ),
508 Self::Invalid(message) => formatter.write_str(message),
509 }
510 }
511}
512
513impl std::error::Error for PackageExecutionError {}
514
515fn is_safe_package_alias(alias: &str) -> bool {
516 let mut components = Path::new(alias).components();
517 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::package_snapshot::{
524 generation_root, package_current_path, package_publication_lock_path,
525 PackageGenerationManifest, PackageGenerationPointer, GENERATION_LEASE_FILE,
526 GENERATION_LOCK_FILE, GENERATION_MANIFEST_FILE, GENERATION_PACKAGES_DIR,
527 };
528 use std::fs::File;
529
530 fn fixture() -> (tempfile::TempDir, Arc<PackageSnapshot>, PathBuf, String) {
531 let temp = tempfile::tempdir().unwrap();
532 let generation = "generation_a";
533 let generation_root = generation_root(temp.path(), generation);
534 let package_root = generation_root.join(GENERATION_PACKAGES_DIR).join("agents");
535 fs::create_dir_all(&package_root).unwrap();
536 let entry = package_root.join("run.harn");
537 fs::write(&entry, "pub pipeline run() { return 1 }\n").unwrap();
538 fs::write(
539 package_root.join("harn.toml"),
540 "[package]\nname = \"agents\"\n",
541 )
542 .unwrap();
543 fs::create_dir_all(package_root.join("workflows")).unwrap();
544 fs::write(
545 package_root.join("workflows/run.harn"),
546 "pub pipeline run() { return 1 }\n",
547 )
548 .unwrap();
549 fs::write(
550 package_root.join("helper.harn"),
551 "pub fn helper() { return 1 }\n",
552 )
553 .unwrap();
554 let content_hash = compute_package_content_hash(&package_root).unwrap();
555 let dependency_root = generation_root.join(GENERATION_PACKAGES_DIR).join("shared");
556 fs::create_dir_all(&dependency_root).unwrap();
557 fs::write(
558 dependency_root.join("helper.harn"),
559 "pub fn helper() { return 1 }\n",
560 )
561 .unwrap();
562 fs::write(
563 dependency_root.join("harn.toml"),
564 "[package]\nname = \"shared\"\n\n[exports]\napi = \"safe.harn\"\n",
565 )
566 .unwrap();
567 fs::write(
568 dependency_root.join("safe.harn"),
569 "pub fn value() { return 1 }\n",
570 )
571 .unwrap();
572 fs::write(
573 dependency_root.join("payload.harn"),
574 "pub fn value() { return 2 }\n",
575 )
576 .unwrap();
577 let dependency_hash = compute_package_content_hash(&dependency_root).unwrap();
578 let lock = format!(
579 "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"
580 );
581 fs::write(generation_root.join(GENERATION_LOCK_FILE), &lock).unwrap();
582 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
583 let manifest =
584 PackageGenerationManifest::new(generation, package_lock_digest(lock.as_bytes()))
585 .unwrap();
586 fs::write(
587 generation_root.join(GENERATION_MANIFEST_FILE),
588 toml::to_string_pretty(&manifest).unwrap(),
589 )
590 .unwrap();
591 fs::write(
592 package_current_path(temp.path()),
593 toml::to_string_pretty(&PackageGenerationPointer::new(generation).unwrap()).unwrap(),
594 )
595 .unwrap();
596 File::create(package_publication_lock_path(temp.path())).unwrap();
597 let snapshot = Arc::new(PackageSnapshot::acquire(temp.path()).unwrap().unwrap());
598 (temp, snapshot, entry, content_hash)
599 }
600
601 #[test]
602 fn canonical_content_hash_projection_is_platform_independent() {
603 let lf = tempfile::tempdir().unwrap();
604 let crlf = tempfile::tempdir().unwrap();
605 for root in [lf.path(), crlf.path()] {
606 fs::create_dir_all(root.join("src")).unwrap();
607 fs::write(root.join("asset.bin"), [0, b'\r', b'\n', 0xff]).unwrap();
608 }
609 fs::write(lf.path().join("harn.toml"), "[package]\nname = \"demo\"\n").unwrap();
610 fs::write(
611 crlf.path().join("harn.toml"),
612 "[package]\r\nname = \"demo\"\r\n",
613 )
614 .unwrap();
615 fs::write(
616 lf.path().join("src/lib.harn"),
617 "pub fn value() -> number { 1 }\n",
618 )
619 .unwrap();
620 fs::write(
621 crlf.path().join("src/lib.harn"),
622 "pub fn value() -> number { 1 }\r\n",
623 )
624 .unwrap();
625
626 #[cfg(unix)]
627 {
628 use std::os::unix::fs::PermissionsExt;
629 fs::set_permissions(
630 lf.path().join("src/lib.harn"),
631 fs::Permissions::from_mode(0o644),
632 )
633 .unwrap();
634 fs::set_permissions(
635 crlf.path().join("src/lib.harn"),
636 fs::Permissions::from_mode(0o755),
637 )
638 .unwrap();
639 }
640
641 let expected = "sha256-v2:899d8ece60275669d4894707420835dbac7530e4179a5e45163ba46add129435";
642 assert_eq!(compute_package_content_hash(lf.path()).unwrap(), expected);
643 assert_eq!(compute_package_content_hash(crlf.path()).unwrap(), expected);
644 }
645
646 #[test]
647 fn canonical_content_hash_normalizes_unicode_paths() {
648 let composed = tempfile::tempdir().unwrap();
649 let decomposed = tempfile::tempdir().unwrap();
650 fs::write(
651 composed.path().join("\u{e9}.harn"),
652 "pub fn value() { 1 }\n",
653 )
654 .unwrap();
655 fs::write(
656 decomposed.path().join("e\u{301}.harn"),
657 "pub fn value() { 1 }\n",
658 )
659 .unwrap();
660
661 assert_eq!(
662 compute_package_content_hash(composed.path()).unwrap(),
663 compute_package_content_hash(decomposed.path()).unwrap()
664 );
665 }
666
667 #[test]
668 fn archive_and_canonical_content_hash_algorithms_are_explicit() {
669 let temp = tempfile::tempdir().unwrap();
670 fs::write(temp.path().join("AGENTS.md"), "# Guidance\n").unwrap();
671 fs::write(temp.path().join("CLAUDE.md"), "# Guidance\n").unwrap();
672 fs::write(temp.path().join("lib.harn"), "pub fn value() { 1 }\n").unwrap();
673
674 let archive = compute_archive_content_hash(temp.path()).unwrap();
675 let canonical = compute_package_content_hash(temp.path()).unwrap();
676
677 assert_eq!(
678 verify_package_content_hash(temp.path(), &archive).unwrap(),
679 archive
680 );
681 assert_eq!(
682 verify_package_content_hash(temp.path(), &canonical).unwrap(),
683 canonical
684 );
685 assert!(!is_canonical_package_content_hash(&archive));
686 assert!(is_canonical_package_content_hash(&canonical));
687 }
688
689 #[test]
690 fn guard_rejects_package_mutation_before_execution() {
691 let (_temp, snapshot, entry, content_hash) = fixture();
692 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
693 let source = guard.verify_entry_source(&entry).unwrap();
694 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
695
696 fs::write(&entry, "pub pipeline run() { return 2 }\n").unwrap();
697 let error = guard.verify_entry(&entry).unwrap_err();
698 assert!(error.to_string().contains("content changed"));
699 }
700
701 #[test]
702 fn guard_retains_generation_lease() {
703 let (_temp, snapshot, entry, content_hash) = fixture();
704 let lease_path = snapshot.generation_root().join(GENERATION_LEASE_FILE);
705 let guard =
706 PackageExecutionGuard::new(Arc::clone(&snapshot), "agents", content_hash).unwrap();
707 drop(snapshot);
708 let lease = File::open(lease_path).unwrap();
709 assert!(lease.try_lock().is_err());
710 guard.verify_entry(&entry).unwrap();
711 drop(guard);
712 lease.try_lock().unwrap();
713 }
714
715 #[test]
716 fn guard_rejects_lock_bytes_not_validated_by_snapshot() {
717 let (_temp, snapshot, _entry, content_hash) = fixture();
718 let mut lock = fs::read(snapshot.lock_path()).unwrap();
719 lock.push(b'\n');
720 fs::write(snapshot.lock_path(), lock).unwrap();
721
722 let error = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap_err();
723
724 assert!(error.to_string().contains("before guard construction"));
725 }
726
727 #[test]
728 fn guard_allows_content_pinned_dependency_entry() {
729 let (_temp, snapshot, _entry, content_hash) = fixture();
730 let dependency = snapshot.packages_root().join("shared/helper.harn");
731 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
732
733 let source = guard.verify_entry_source(&dependency).unwrap();
734
735 assert_eq!(source, b"pub fn helper() { return 1 }\n");
736 }
737
738 #[test]
739 fn guarded_export_resolution_rejects_unverified_manifest_mapping() {
740 let (_temp, snapshot, entry, content_hash) = fixture();
741 let manifest = snapshot.packages_root().join("shared/harn.toml");
742 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
743 fs::write(
744 manifest,
745 "[package]\nname = \"shared\"\n\n[exports]\napi = \"payload.harn\"\n",
746 )
747 .unwrap();
748
749 let error =
750 crate::package_imports::resolve_import_path_with_guard(&entry, "shared/api", &guard)
751 .unwrap_err();
752
753 assert!(error.to_string().contains("content changed"));
754 }
755
756 #[cfg(unix)]
757 #[test]
758 fn guard_rejects_descendant_entry_retargeted_within_package() {
759 let (_temp, snapshot, _entry, content_hash) = fixture();
760 let safe = snapshot.packages_root().join("shared/safe.harn");
761 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
762 fs::remove_file(&safe).unwrap();
763 std::os::unix::fs::symlink("payload.harn", &safe).unwrap();
764
765 let error = guard.verify_entry_source(&safe).unwrap_err();
766
767 assert!(error.to_string().contains("retargeted within package"));
768 }
769
770 #[test]
771 fn guard_normalizes_parent_import_within_package() {
772 let (_temp, snapshot, _entry, content_hash) = fixture();
773 let entry = snapshot
774 .packages_root()
775 .join("agents/workflows/../helper.harn");
776 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
777
778 let source = guard.verify_entry_source(&entry).unwrap();
779
780 assert_eq!(source, b"pub fn helper() { return 1 }\n");
781 }
782
783 #[test]
784 fn guard_rejects_parent_import_escaping_alias_root() {
785 let (_temp, snapshot, _entry, content_hash) = fixture();
786 let entry = snapshot.packages_root().join("agents/run.harn");
787 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
788
789 let error = crate::package_imports::resolve_import_path_with_guard(
790 &entry,
791 "../shared/helper",
792 &guard,
793 )
794 .unwrap_err();
795
796 assert!(error.to_string().contains("escapes package alias"));
797 }
798
799 #[test]
800 fn guard_allows_parent_import_within_package_alias() {
801 let (_temp, snapshot, _entry, content_hash) = fixture();
802 let entry = snapshot.packages_root().join("agents/workflows/run.harn");
803 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
804
805 let path =
806 crate::package_imports::resolve_import_path_with_guard(&entry, "../helper", &guard)
807 .expect("parent traversal remains inside agents")
808 .expect("helper resolves inside agents");
809 assert_eq!(path, entry.parent().unwrap().join("../helper.harn"));
810 }
811
812 #[test]
813 fn guard_rejects_parent_import_after_normalizing_importer_path() {
814 let (_temp, snapshot, _entry, content_hash) = fixture();
815 let entry = snapshot
816 .packages_root()
817 .join("agents/workflows/../helper.harn");
818 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
819
820 let error = crate::package_imports::resolve_import_path_with_guard(
821 &entry,
822 "../shared/helper",
823 &guard,
824 )
825 .unwrap_err();
826
827 assert!(error.to_string().contains("escapes package alias"));
828 }
829
830 #[test]
831 fn guard_leaves_absolute_internal_module_paths_to_entry_verification() {
832 let (_temp, snapshot, entry, content_hash) = fixture();
833 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
834
835 guard
836 .validate_import_path(&entry, entry.to_str().unwrap())
837 .expect("absolute internal module path is checked by verify_entry_source");
838 }
839
840 #[cfg(unix)]
841 #[test]
842 fn guard_rejects_primary_alias_retargeted_to_pinned_dependency() {
843 let (_temp, snapshot, _entry, content_hash) = fixture();
844 let packages = snapshot.packages_root().to_path_buf();
845 let primary = packages.join("agents");
846 let original = packages.join("agents-original");
847 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
848 fs::rename(&primary, &original).unwrap();
849 std::os::unix::fs::symlink(packages.join("shared"), &primary).unwrap();
850
851 let error = guard
852 .verify_entry_source(&primary.join("helper.harn"))
853 .unwrap_err();
854
855 assert!(error.to_string().contains("alias 'agents' was retargeted"));
856 }
857
858 #[cfg(unix)]
859 #[test]
860 fn guard_rejects_dependency_alias_retargeted_to_primary() {
861 let (_temp, snapshot, _entry, content_hash) = fixture();
862 let packages = snapshot.packages_root().to_path_buf();
863 let dependency = packages.join("shared");
864 let original = packages.join("shared-original");
865 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
866 fs::rename(&dependency, &original).unwrap();
867 std::os::unix::fs::symlink(packages.join("agents"), &dependency).unwrap();
868
869 let error = guard
870 .verify_entry_source(&dependency.join("run.harn"))
871 .unwrap_err();
872
873 assert!(error.to_string().contains("alias 'shared' was retargeted"));
874 }
875
876 #[cfg(unix)]
877 #[test]
878 fn content_hash_rejects_descendant_symlink() {
879 let temp = tempfile::tempdir().unwrap();
880 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
881 std::os::unix::fs::symlink("target.harn", temp.path().join("alias.harn")).unwrap();
882
883 let error = compute_package_content_hash(temp.path()).unwrap_err();
884 assert!(error.to_string().contains("unsupported symlink"));
885 }
886
887 #[cfg(unix)]
888 #[test]
889 fn content_hash_ignores_symlinks_at_excluded_paths() {
890 let temp = tempfile::tempdir().unwrap();
895 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
896 fs::write(temp.path().join("ignore-target"), "target/\n").unwrap();
897 std::os::unix::fs::symlink("ignore-target", temp.path().join(".gitignore")).unwrap();
898
899 compute_package_content_hash(temp.path())
900 .expect("a symlink at an excluded path must not invalidate the package");
901 }
902
903 #[cfg(unix)]
904 #[test]
905 fn content_hash_ignores_claude_guidance_projection() {
906 let temp = tempfile::tempdir().unwrap();
907 fs::write(temp.path().join("AGENTS.md"), "# Package guidance\n").unwrap();
908 fs::write(temp.path().join("lib.harn"), "pub fn value() { 1 }\n").unwrap();
909 std::os::unix::fs::symlink("AGENTS.md", temp.path().join("CLAUDE.md")).unwrap();
910
911 compute_package_content_hash(temp.path())
912 .expect("the Claude guidance projection is not executable package content");
913 }
914
915 #[cfg(unix)]
916 #[test]
917 fn guard_accepts_equivalent_root_alias_without_losing_escape_detection() {
918 let (temp, snapshot, entry, content_hash) = fixture();
919 let alias = temp.path().join("project-alias");
920 std::os::unix::fs::symlink(".", &alias).unwrap();
921 let aliased_entry = alias.join(entry.strip_prefix(temp.path()).unwrap());
922 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
923
924 let source = guard.verify_entry_source(&aliased_entry).unwrap();
925
926 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
927 let aliased_packages_root = aliased_entry.parent().unwrap().parent().unwrap();
928 let escape = aliased_packages_root.join("agents/../shared/helper.harn");
929 let error = guard.verify_entry_source(&escape).unwrap_err();
930 assert!(error.to_string().contains("unsafe package-relative path"));
931 }
932}