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 std::fs::File;
632
633 fn fixture() -> (tempfile::TempDir, Arc<PackageSnapshot>, PathBuf, String) {
634 let temp = tempfile::tempdir().unwrap();
635 let generation = "generation_a";
636 let generation_root = generation_root(temp.path(), generation);
637 let package_root = generation_root.join(GENERATION_PACKAGES_DIR).join("agents");
638 fs::create_dir_all(&package_root).unwrap();
639 let entry = package_root.join("run.harn");
640 fs::write(&entry, "pub pipeline run() { return 1 }\n").unwrap();
641 fs::write(
642 package_root.join("harn.toml"),
643 "[package]\nname = \"agents\"\n",
644 )
645 .unwrap();
646 fs::create_dir_all(package_root.join("workflows")).unwrap();
647 fs::write(
648 package_root.join("workflows/run.harn"),
649 "pub pipeline run() { return 1 }\n",
650 )
651 .unwrap();
652 fs::write(
653 package_root.join("helper.harn"),
654 "pub fn helper() { return 1 }\n",
655 )
656 .unwrap();
657 let content_hash = compute_package_content_hash(&package_root).unwrap();
658 let dependency_root = generation_root.join(GENERATION_PACKAGES_DIR).join("shared");
659 fs::create_dir_all(&dependency_root).unwrap();
660 fs::write(
661 dependency_root.join("helper.harn"),
662 "pub fn helper() { return 1 }\n",
663 )
664 .unwrap();
665 fs::write(
666 dependency_root.join("harn.toml"),
667 "[package]\nname = \"shared\"\n\n[exports]\napi = \"safe.harn\"\n",
668 )
669 .unwrap();
670 fs::write(
671 dependency_root.join("safe.harn"),
672 "pub fn value() { return 1 }\n",
673 )
674 .unwrap();
675 fs::write(
676 dependency_root.join("payload.harn"),
677 "pub fn value() { return 2 }\n",
678 )
679 .unwrap();
680 let dependency_hash = compute_package_content_hash(&dependency_root).unwrap();
681 let lock = format!(
682 "version = 4\n\n[[package]]\nname = \"agents\"\ncontent_hash = \"{content_hash}\"\n\n[[package]]\nname = \"shared\"\ncontent_hash = \"{dependency_hash}\"\n"
683 );
684 fs::write(generation_root.join(GENERATION_LOCK_FILE), &lock).unwrap();
685 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
686 let manifest =
687 PackageGenerationManifest::new(generation, package_lock_digest(lock.as_bytes()))
688 .unwrap();
689 fs::write(
690 generation_root.join(GENERATION_MANIFEST_FILE),
691 toml::to_string_pretty(&manifest).unwrap(),
692 )
693 .unwrap();
694 fs::write(
695 package_current_path(temp.path()),
696 toml::to_string_pretty(&PackageGenerationPointer::new(generation).unwrap()).unwrap(),
697 )
698 .unwrap();
699 File::create(package_publication_lock_path(temp.path())).unwrap();
700 let snapshot = Arc::new(PackageSnapshot::acquire(temp.path()).unwrap().unwrap());
701 (temp, snapshot, entry, content_hash)
702 }
703
704 #[test]
705 fn guard_rejects_package_mutation_before_execution() {
706 let (_temp, snapshot, entry, content_hash) = fixture();
707 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
708 let source = guard.verify_entry_source(&entry).unwrap();
709 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
710
711 fs::write(&entry, "pub pipeline run() { return 2 }\n").unwrap();
712 let error = guard.verify_entry(&entry).unwrap_err();
713 assert!(error.to_string().contains("content changed"));
714 }
715
716 #[test]
717 fn guard_retains_generation_lease() {
718 let (_temp, snapshot, entry, content_hash) = fixture();
719 let lease_path = snapshot.generation_root().join(GENERATION_LEASE_FILE);
720 let guard =
721 PackageExecutionGuard::new(Arc::clone(&snapshot), "agents", content_hash).unwrap();
722 drop(snapshot);
723 let lease = File::open(lease_path).unwrap();
724 assert!(lease.try_lock().is_err());
725 guard.verify_entry(&entry).unwrap();
726 drop(guard);
727 lease.try_lock().unwrap();
728 }
729
730 #[test]
731 fn guard_rejects_lock_bytes_not_validated_by_snapshot() {
732 let (_temp, snapshot, _entry, content_hash) = fixture();
733 let mut lock = fs::read(snapshot.lock_path()).unwrap();
734 lock.push(b'\n');
735 fs::write(snapshot.lock_path(), lock).unwrap();
736
737 let error = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap_err();
738
739 assert!(error.to_string().contains("before guard construction"));
740 }
741
742 #[test]
743 fn guard_allows_content_pinned_dependency_entry() {
744 let (_temp, snapshot, _entry, content_hash) = fixture();
745 let dependency = snapshot.packages_root().join("shared/helper.harn");
746 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
747
748 let source = guard.verify_entry_source(&dependency).unwrap();
749
750 assert_eq!(source, b"pub fn helper() { return 1 }\n");
751 }
752
753 #[test]
754 fn guarded_export_resolution_rejects_unverified_manifest_mapping() {
755 let (_temp, snapshot, entry, content_hash) = fixture();
756 let manifest = snapshot.packages_root().join("shared/harn.toml");
757 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
758 fs::write(
759 manifest,
760 "[package]\nname = \"shared\"\n\n[exports]\napi = \"payload.harn\"\n",
761 )
762 .unwrap();
763
764 let error =
765 crate::package_imports::resolve_import_path_with_guard(&entry, "shared/api", &guard)
766 .unwrap_err();
767
768 assert!(error.to_string().contains("content changed"));
769 }
770
771 #[cfg(unix)]
772 #[test]
773 fn guard_rejects_descendant_entry_retargeted_within_package() {
774 let (_temp, snapshot, _entry, content_hash) = fixture();
775 let safe = snapshot.packages_root().join("shared/safe.harn");
776 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
777 fs::remove_file(&safe).unwrap();
778 std::os::unix::fs::symlink("payload.harn", &safe).unwrap();
779
780 let error = guard.verify_entry_source(&safe).unwrap_err();
781
782 assert!(error.to_string().contains("retargeted within package"));
783 }
784
785 #[test]
786 fn guard_normalizes_parent_import_within_package() {
787 let (_temp, snapshot, _entry, content_hash) = fixture();
788 let entry = snapshot
789 .packages_root()
790 .join("agents/workflows/../helper.harn");
791 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
792
793 let source = guard.verify_entry_source(&entry).unwrap();
794
795 assert_eq!(source, b"pub fn helper() { return 1 }\n");
796 }
797
798 #[test]
799 fn guard_rejects_parent_import_escaping_alias_root() {
800 let (_temp, snapshot, _entry, content_hash) = fixture();
801 let entry = snapshot.packages_root().join("agents/run.harn");
802 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
803
804 let error = crate::package_imports::resolve_import_path_with_guard(
805 &entry,
806 "../shared/helper",
807 &guard,
808 )
809 .unwrap_err();
810
811 assert!(error.to_string().contains("escapes package alias"));
812 }
813
814 #[test]
815 fn guard_allows_parent_import_within_package_alias() {
816 let (_temp, snapshot, _entry, content_hash) = fixture();
817 let entry = snapshot.packages_root().join("agents/workflows/run.harn");
818 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
819
820 let path =
821 crate::package_imports::resolve_import_path_with_guard(&entry, "../helper", &guard)
822 .expect("parent traversal remains inside agents")
823 .expect("helper resolves inside agents");
824 assert_eq!(path, entry.parent().unwrap().join("../helper.harn"));
825 }
826
827 #[test]
828 fn guard_rejects_parent_import_after_normalizing_importer_path() {
829 let (_temp, snapshot, _entry, content_hash) = fixture();
830 let entry = snapshot
831 .packages_root()
832 .join("agents/workflows/../helper.harn");
833 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
834
835 let error = crate::package_imports::resolve_import_path_with_guard(
836 &entry,
837 "../shared/helper",
838 &guard,
839 )
840 .unwrap_err();
841
842 assert!(error.to_string().contains("escapes package alias"));
843 }
844
845 #[test]
846 fn guard_leaves_absolute_internal_module_paths_to_entry_verification() {
847 let (_temp, snapshot, entry, content_hash) = fixture();
848 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
849
850 guard
851 .validate_import_path(&entry, entry.to_str().unwrap())
852 .expect("absolute internal module path is checked by verify_entry_source");
853 }
854
855 #[cfg(unix)]
856 #[test]
857 fn guard_rejects_primary_alias_retargeted_to_pinned_dependency() {
858 let (_temp, snapshot, _entry, content_hash) = fixture();
859 let packages = snapshot.packages_root().to_path_buf();
860 let primary = packages.join("agents");
861 let original = packages.join("agents-original");
862 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
863 fs::rename(&primary, &original).unwrap();
864 std::os::unix::fs::symlink(packages.join("shared"), &primary).unwrap();
865
866 let error = guard
867 .verify_entry_source(&primary.join("helper.harn"))
868 .unwrap_err();
869
870 assert!(error.to_string().contains("alias 'agents' was retargeted"));
871 }
872
873 #[cfg(unix)]
874 #[test]
875 fn guard_rejects_dependency_alias_retargeted_to_primary() {
876 let (_temp, snapshot, _entry, content_hash) = fixture();
877 let packages = snapshot.packages_root().to_path_buf();
878 let dependency = packages.join("shared");
879 let original = packages.join("shared-original");
880 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
881 fs::rename(&dependency, &original).unwrap();
882 std::os::unix::fs::symlink(packages.join("agents"), &dependency).unwrap();
883
884 let error = guard
885 .verify_entry_source(&dependency.join("run.harn"))
886 .unwrap_err();
887
888 assert!(error.to_string().contains("alias 'shared' was retargeted"));
889 }
890
891 #[cfg(unix)]
892 #[test]
893 fn content_hash_rejects_descendant_symlink() {
894 let temp = tempfile::tempdir().unwrap();
895 fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
896 std::os::unix::fs::symlink("target.harn", temp.path().join("alias.harn")).unwrap();
897
898 let error = compute_package_content_hash(temp.path()).unwrap_err();
899 assert!(error.to_string().contains("unsupported symlink"));
900 }
901
902 #[cfg(unix)]
903 #[test]
904 fn guard_accepts_equivalent_root_alias_without_losing_escape_detection() {
905 let (temp, snapshot, entry, content_hash) = fixture();
906 let alias = temp.path().join("project-alias");
907 std::os::unix::fs::symlink(".", &alias).unwrap();
908 let aliased_entry = alias.join(entry.strip_prefix(temp.path()).unwrap());
909 let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
910
911 let source = guard.verify_entry_source(&aliased_entry).unwrap();
912
913 assert_eq!(source, b"pub pipeline run() { return 1 }\n");
914 let aliased_packages_root = aliased_entry.parent().unwrap().parent().unwrap();
915 let escape = aliased_packages_root.join("agents/../shared/helper.harn");
916 let error = guard.verify_entry_source(&escape).unwrap_err();
917 assert!(error.to_string().contains("unsafe package-relative path"));
918 }
919}