Skip to main content

harn_modules/
package_execution.rs

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