Skip to main content

harn_modules/
package_execution.rs

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            // Path dependencies may be generation-owned symlinks. Pin each
150            // canonical target so retargeting is rejected before execution.
151            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    /// Reject a relative import that would escape the importing package alias.
181    ///
182    /// This takes the raw import string rather than a `Path`: Windows normalizes
183    /// parent components while constructing a `PathBuf`, so a later entry guard
184    /// cannot distinguish `agents/../shared` from `shared`.
185    pub(crate) fn validate_import_path(
186        &self,
187        current_file: &Path,
188        import_path: &str,
189    ) -> Result<(), PackageExecutionError> {
190        // Internal module loading can pass an already-resolved absolute path;
191        // `verify_entry_source` remains the authority for that representation.
192        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    /// Return the entry bytes from the same package walk whose digest matched
272    /// the pinned content hash. Callers must compile these bytes rather than
273    /// reopening the path after verification.
274    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    // `Path::strip_prefix` compares normalized components on Windows. Walk the
417    // components ourselves so the caller can still reject `alias/../escape`.
418    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        // The symlink rejection guards package *content*. A path the installer
915        // was going to skip anyway cannot invalidate the package, so a
916        // symlinked `.gitignore` must not fail the hash the way a symlinked
917        // source file does.
918        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}