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