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