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