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(CONTENT_HASH_FILE)
590 || name == OsStr::new(CACHE_METADATA_FILE)
591}
592
593pub fn normalized_package_relative_path(path: &Path) -> String {
594 path.components()
595 .map(|component| component.as_os_str().to_string_lossy())
596 .collect::<Vec<_>>()
597 .join("/")
598}
599
600fn validate_content_hash(hash: &str) -> Result<(), PackageExecutionError> {
601 let Some(hex) = hash.strip_prefix("sha256:") else {
602 return Err(PackageExecutionError::Invalid(format!(
603 "package content hash must use sha256:<64 hex>, got {hash}"
604 )));
605 };
606 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
607 return Err(PackageExecutionError::Invalid(format!(
608 "package content hash must use sha256:<64 hex>, got {hash}"
609 )));
610 }
611 Ok(())
612}
613
614fn is_safe_package_alias(alias: &str) -> bool {
615 let mut components = Path::new(alias).components();
616 matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
617}
618
619fn encode_hex(bytes: &[u8]) -> String {
620 let mut encoded = String::with_capacity(bytes.len() * 2);
621 for byte in bytes {
622 use fmt::Write as _;
623 let _ = write!(encoded, "{byte:02x}");
624 }
625 encoded
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::package_snapshot::{
632 generation_root, package_current_path, package_publication_lock_path,
633 PackageGenerationManifest, PackageGenerationPointer, GENERATION_LEASE_FILE,
634 GENERATION_LOCK_FILE, GENERATION_MANIFEST_FILE, GENERATION_PACKAGES_DIR,
635 };
636 use std::fs::File;
637
638 fn fixture() -> (tempfile::TempDir, Arc<PackageSnapshot>, PathBuf, String) {
639 let temp = tempfile::tempdir().unwrap();
640 let generation = "generation_a";
641 let generation_root = generation_root(temp.path(), generation);
642 let package_root = generation_root.join(GENERATION_PACKAGES_DIR).join("agents");
643 fs::create_dir_all(&package_root).unwrap();
644 let entry = package_root.join("run.harn");
645 fs::write(&entry, "pub pipeline run() { return 1 }\n").unwrap();
646 fs::write(
647 package_root.join("harn.toml"),
648 "[package]\nname = \"agents\"\n",
649 )
650 .unwrap();
651 fs::create_dir_all(package_root.join("workflows")).unwrap();
652 fs::write(
653 package_root.join("workflows/run.harn"),
654 "pub pipeline run() { return 1 }\n",
655 )
656 .unwrap();
657 fs::write(
658 package_root.join("helper.harn"),
659 "pub fn helper() { return 1 }\n",
660 )
661 .unwrap();
662 let content_hash = compute_package_content_hash(&package_root).unwrap();
663 let dependency_root = generation_root.join(GENERATION_PACKAGES_DIR).join("shared");
664 fs::create_dir_all(&dependency_root).unwrap();
665 fs::write(
666 dependency_root.join("helper.harn"),
667 "pub fn helper() { return 1 }\n",
668 )
669 .unwrap();
670 fs::write(
671 dependency_root.join("harn.toml"),
672 "[package]\nname = \"shared\"\n\n[exports]\napi = \"safe.harn\"\n",
673 )
674 .unwrap();
675 fs::write(
676 dependency_root.join("safe.harn"),
677 "pub fn value() { return 1 }\n",
678 )
679 .unwrap();
680 fs::write(
681 dependency_root.join("payload.harn"),
682 "pub fn value() { return 2 }\n",
683 )
684 .unwrap();
685 let dependency_hash = compute_package_content_hash(&dependency_root).unwrap();
686 let lock = format!(
687 "version = 4\n\n[[package]]\nname = \"agents\"\ncontent_hash = \"{content_hash}\"\n\n[[package]]\nname = \"shared\"\ncontent_hash = \"{dependency_hash}\"\n"
688 );
689 fs::write(generation_root.join(GENERATION_LOCK_FILE), &lock).unwrap();
690 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
691 let manifest =
692 PackageGenerationManifest::new(generation, package_lock_digest(lock.as_bytes()))
693 .unwrap();
694 fs::write(
695 generation_root.join(GENERATION_MANIFEST_FILE),
696 toml::to_string_pretty(&manifest).unwrap(),
697 )
698 .unwrap();
699 fs::write(
700 package_current_path(temp.path()),
701 toml::to_string_pretty(&PackageGenerationPointer::new(generation).unwrap()).unwrap(),
702 )
703 .unwrap();
704 File::create(package_publication_lock_path(temp.path())).unwrap();
705 let snapshot = Arc::new(PackageSnapshot::acquire(temp.path()).unwrap().unwrap());
706 (temp, snapshot, entry, content_hash)
707 }
708
709 #[test]
710 fn guard_rejects_package_mutation_before_execution() {
711 let (_temp, snapshot, entry, content_hash) = fixture();
712 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
713 let source = guard.verify_entry_source(&entry).unwrap();
714 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
715
716 fs::write(&entry, "pub pipeline run() { return 2 }\n").unwrap();
717 let error = guard.verify_entry(&entry).unwrap_err();
718 assert!(error.to_string().contains("content changed"));
719 }
720
721 #[test]
722 fn guard_retains_generation_lease() {
723 let (_temp, snapshot, entry, content_hash) = fixture();
724 let lease_path = snapshot.generation_root().join(GENERATION_LEASE_FILE);
725 let guard =
726 PackageExecutionGuard::new(Arc::clone(&snapshot), "agents", content_hash).unwrap();
727 drop(snapshot);
728 let lease = File::open(lease_path).unwrap();
729 assert!(lease.try_lock().is_err());
730 guard.verify_entry(&entry).unwrap();
731 drop(guard);
732 lease.try_lock().unwrap();
733 }
734
735 #[test]
736 fn guard_rejects_lock_bytes_not_validated_by_snapshot() {
737 let (_temp, snapshot, _entry, content_hash) = fixture();
738 let mut lock = fs::read(snapshot.lock_path()).unwrap();
739 lock.push(b'\n');
740 fs::write(snapshot.lock_path(), lock).unwrap();
741
742 let error = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap_err();
743
744 assert!(error.to_string().contains("before guard construction"));
745 }
746
747 #[test]
748 fn guard_allows_content_pinned_dependency_entry() {
749 let (_temp, snapshot, _entry, content_hash) = fixture();
750 let dependency = snapshot.packages_root().join("shared/helper.harn");
751 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
752
753 let source = guard.verify_entry_source(&dependency).unwrap();
754
755 assert_eq!(source, b"pub fn helper() { return 1 }\n");
756 }
757
758 #[test]
759 fn guarded_export_resolution_rejects_unverified_manifest_mapping() {
760 let (_temp, snapshot, entry, content_hash) = fixture();
761 let manifest = snapshot.packages_root().join("shared/harn.toml");
762 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
763 fs::write(
764 manifest,
765 "[package]\nname = \"shared\"\n\n[exports]\napi = \"payload.harn\"\n",
766 )
767 .unwrap();
768
769 let error =
770 crate::package_imports::resolve_import_path_with_guard(&entry, "shared/api", &guard)
771 .unwrap_err();
772
773 assert!(error.to_string().contains("content changed"));
774 }
775
776 #[cfg(unix)]
777 #[test]
778 fn guard_rejects_descendant_entry_retargeted_within_package() {
779 let (_temp, snapshot, _entry, content_hash) = fixture();
780 let safe = snapshot.packages_root().join("shared/safe.harn");
781 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
782 fs::remove_file(&safe).unwrap();
783 std::os::unix::fs::symlink("payload.harn", &safe).unwrap();
784
785 let error = guard.verify_entry_source(&safe).unwrap_err();
786
787 assert!(error.to_string().contains("retargeted within package"));
788 }
789
790 #[test]
791 fn guard_normalizes_parent_import_within_package() {
792 let (_temp, snapshot, _entry, content_hash) = fixture();
793 let entry = snapshot
794 .packages_root()
795 .join("agents/workflows/../helper.harn");
796 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
797
798 let source = guard.verify_entry_source(&entry).unwrap();
799
800 assert_eq!(source, b"pub fn helper() { return 1 }\n");
801 }
802
803 #[test]
804 fn guard_rejects_parent_import_escaping_alias_root() {
805 let (_temp, snapshot, _entry, content_hash) = fixture();
806 let entry = snapshot.packages_root().join("agents/run.harn");
807 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
808
809 let error = crate::package_imports::resolve_import_path_with_guard(
810 &entry,
811 "../shared/helper",
812 &guard,
813 )
814 .unwrap_err();
815
816 assert!(error.to_string().contains("escapes package alias"));
817 }
818
819 #[test]
820 fn guard_allows_parent_import_within_package_alias() {
821 let (_temp, snapshot, _entry, content_hash) = fixture();
822 let entry = snapshot.packages_root().join("agents/workflows/run.harn");
823 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
824
825 let path =
826 crate::package_imports::resolve_import_path_with_guard(&entry, "../helper", &guard)
827 .expect("parent traversal remains inside agents")
828 .expect("helper resolves inside agents");
829 assert_eq!(path, entry.parent().unwrap().join("../helper.harn"));
830 }
831
832 #[test]
833 fn guard_rejects_parent_import_after_normalizing_importer_path() {
834 let (_temp, snapshot, _entry, content_hash) = fixture();
835 let entry = snapshot
836 .packages_root()
837 .join("agents/workflows/../helper.harn");
838 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
839
840 let error = crate::package_imports::resolve_import_path_with_guard(
841 &entry,
842 "../shared/helper",
843 &guard,
844 )
845 .unwrap_err();
846
847 assert!(error.to_string().contains("escapes package alias"));
848 }
849
850 #[test]
851 fn guard_leaves_absolute_internal_module_paths_to_entry_verification() {
852 let (_temp, snapshot, entry, content_hash) = fixture();
853 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
854
855 guard
856 .validate_import_path(&entry, entry.to_str().unwrap())
857 .expect("absolute internal module path is checked by verify_entry_source");
858 }
859
860 #[cfg(unix)]
861 #[test]
862 fn guard_rejects_primary_alias_retargeted_to_pinned_dependency() {
863 let (_temp, snapshot, _entry, content_hash) = fixture();
864 let packages = snapshot.packages_root().to_path_buf();
865 let primary = packages.join("agents");
866 let original = packages.join("agents-original");
867 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
868 fs::rename(&primary, &original).unwrap();
869 std::os::unix::fs::symlink(packages.join("shared"), &primary).unwrap();
870
871 let error = guard
872 .verify_entry_source(&primary.join("helper.harn"))
873 .unwrap_err();
874
875 assert!(error.to_string().contains("alias 'agents' was retargeted"));
876 }
877
878 #[cfg(unix)]
879 #[test]
880 fn guard_rejects_dependency_alias_retargeted_to_primary() {
881 let (_temp, snapshot, _entry, content_hash) = fixture();
882 let packages = snapshot.packages_root().to_path_buf();
883 let dependency = packages.join("shared");
884 let original = packages.join("shared-original");
885 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
886 fs::rename(&dependency, &original).unwrap();
887 std::os::unix::fs::symlink(packages.join("agents"), &dependency).unwrap();
888
889 let error = guard
890 .verify_entry_source(&dependency.join("run.harn"))
891 .unwrap_err();
892
893 assert!(error.to_string().contains("alias 'shared' was retargeted"));
894 }
895
896 #[cfg(unix)]
897 #[test]
898 fn content_hash_rejects_descendant_symlink() {
899 let temp = tempfile::tempdir().unwrap();
900 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
901 std::os::unix::fs::symlink("target.harn", temp.path().join("alias.harn")).unwrap();
902
903 let error = compute_package_content_hash(temp.path()).unwrap_err();
904 assert!(error.to_string().contains("unsupported symlink"));
905 }
906
907 #[cfg(unix)]
908 #[test]
909 fn content_hash_ignores_symlinks_at_excluded_paths() {
910 let temp = tempfile::tempdir().unwrap();
915 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
916 fs::write(temp.path().join("ignore-target"), "target/\n").unwrap();
917 std::os::unix::fs::symlink("ignore-target", temp.path().join(".gitignore")).unwrap();
918
919 compute_package_content_hash(temp.path())
920 .expect("a symlink at an excluded path must not invalidate the package");
921 }
922
923 #[cfg(unix)]
924 #[test]
925 fn guard_accepts_equivalent_root_alias_without_losing_escape_detection() {
926 let (temp, snapshot, entry, content_hash) = fixture();
927 let alias = temp.path().join("project-alias");
928 std::os::unix::fs::symlink(".", &alias).unwrap();
929 let aliased_entry = alias.join(entry.strip_prefix(temp.path()).unwrap());
930 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
931
932 let source = guard.verify_entry_source(&aliased_entry).unwrap();
933
934 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
935 let aliased_packages_root = aliased_entry.parent().unwrap().parent().unwrap();
936 let escape = aliased_packages_root.join("agents/../shared/helper.harn");
937 let error = guard.verify_entry_source(&escape).unwrap_err();
938 assert!(error.to_string().contains("unsafe package-relative path"));
939 }
940}