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