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        // Exclusion first: a path that never reaches the content hash cannot
546        // make the package invalid, whatever kind of file it is. Checking the
547        // symlink before the exclusion rejected packages over a symlinked
548        // `.gitignore` or a symlinked cache-metadata file that the installer
549        // was about to skip anyway.
550        if excluded_package_name(&name) {
551            continue;
552        }
553        if file_type.is_symlink() {
554            return Err(PackageExecutionError::Invalid(format!(
555                "package content contains unsupported symlink: {}",
556                path.display()
557            )));
558        }
559        if file_type.is_dir() {
560            collect_hashable_files(root, &path, out)?;
561        } else if file_type.is_file() {
562            let relative = path.strip_prefix(root).map_err(|error| {
563                PackageExecutionError::Invalid(format!(
564                    "failed to relativize {}: {error}",
565                    path.display()
566                ))
567            })?;
568            out.push(relative.to_path_buf());
569        }
570    }
571    Ok(())
572}
573
574fn read_regular_file(path: &Path) -> Result<Vec<u8>, PackageExecutionError> {
575    let metadata = fs::symlink_metadata(path)
576        .map_err(|error| PackageExecutionError::io("stat", path.to_path_buf(), error))?;
577    if !metadata.file_type().is_file() {
578        return Err(PackageExecutionError::Invalid(format!(
579            "package content is not a regular file: {}",
580            path.display()
581        )));
582    }
583    fs::read(path).map_err(|error| PackageExecutionError::io("read", path.to_path_buf(), error))
584}
585
586fn excluded_package_name(name: &OsStr) -> bool {
587    name == OsStr::new(".git")
588        || name == OsStr::new(".gitignore")
589        || name == OsStr::new(CONTENT_HASH_FILE)
590        || name == OsStr::new(CACHE_METADATA_FILE)
591}
592
593pub fn normalized_package_relative_path(path: &Path) -> String {
594    path.components()
595        .map(|component| component.as_os_str().to_string_lossy())
596        .collect::<Vec<_>>()
597        .join("/")
598}
599
600fn validate_content_hash(hash: &str) -> Result<(), PackageExecutionError> {
601    let Some(hex) = hash.strip_prefix("sha256:") else {
602        return Err(PackageExecutionError::Invalid(format!(
603            "package content hash must use sha256:<64 hex>, got {hash}"
604        )));
605    };
606    if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
607        return Err(PackageExecutionError::Invalid(format!(
608            "package content hash must use sha256:<64 hex>, got {hash}"
609        )));
610    }
611    Ok(())
612}
613
614fn is_safe_package_alias(alias: &str) -> bool {
615    let mut components = Path::new(alias).components();
616    matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
617}
618
619fn encode_hex(bytes: &[u8]) -> String {
620    let mut encoded = String::with_capacity(bytes.len() * 2);
621    for byte in bytes {
622        use fmt::Write as _;
623        let _ = write!(encoded, "{byte:02x}");
624    }
625    encoded
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::package_snapshot::{
632        generation_root, package_current_path, package_publication_lock_path,
633        PackageGenerationManifest, PackageGenerationPointer, GENERATION_LEASE_FILE,
634        GENERATION_LOCK_FILE, GENERATION_MANIFEST_FILE, GENERATION_PACKAGES_DIR,
635    };
636    use std::fs::File;
637
638    fn fixture() -> (tempfile::TempDir, Arc<PackageSnapshot>, PathBuf, String) {
639        let temp = tempfile::tempdir().unwrap();
640        let generation = "generation_a";
641        let generation_root = generation_root(temp.path(), generation);
642        let package_root = generation_root.join(GENERATION_PACKAGES_DIR).join("agents");
643        fs::create_dir_all(&package_root).unwrap();
644        let entry = package_root.join("run.harn");
645        fs::write(&entry, "pub pipeline run() { return 1 }\n").unwrap();
646        fs::write(
647            package_root.join("harn.toml"),
648            "[package]\nname = \"agents\"\n",
649        )
650        .unwrap();
651        fs::create_dir_all(package_root.join("workflows")).unwrap();
652        fs::write(
653            package_root.join("workflows/run.harn"),
654            "pub pipeline run() { return 1 }\n",
655        )
656        .unwrap();
657        fs::write(
658            package_root.join("helper.harn"),
659            "pub fn helper() { return 1 }\n",
660        )
661        .unwrap();
662        let content_hash = compute_package_content_hash(&package_root).unwrap();
663        let dependency_root = generation_root.join(GENERATION_PACKAGES_DIR).join("shared");
664        fs::create_dir_all(&dependency_root).unwrap();
665        fs::write(
666            dependency_root.join("helper.harn"),
667            "pub fn helper() { return 1 }\n",
668        )
669        .unwrap();
670        fs::write(
671            dependency_root.join("harn.toml"),
672            "[package]\nname = \"shared\"\n\n[exports]\napi = \"safe.harn\"\n",
673        )
674        .unwrap();
675        fs::write(
676            dependency_root.join("safe.harn"),
677            "pub fn value() { return 1 }\n",
678        )
679        .unwrap();
680        fs::write(
681            dependency_root.join("payload.harn"),
682            "pub fn value() { return 2 }\n",
683        )
684        .unwrap();
685        let dependency_hash = compute_package_content_hash(&dependency_root).unwrap();
686        let lock = format!(
687            "version = 4\n\n[[package]]\nname = \"agents\"\ncontent_hash = \"{content_hash}\"\n\n[[package]]\nname = \"shared\"\ncontent_hash = \"{dependency_hash}\"\n"
688        );
689        fs::write(generation_root.join(GENERATION_LOCK_FILE), &lock).unwrap();
690        fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
691        let manifest =
692            PackageGenerationManifest::new(generation, package_lock_digest(lock.as_bytes()))
693                .unwrap();
694        fs::write(
695            generation_root.join(GENERATION_MANIFEST_FILE),
696            toml::to_string_pretty(&manifest).unwrap(),
697        )
698        .unwrap();
699        fs::write(
700            package_current_path(temp.path()),
701            toml::to_string_pretty(&PackageGenerationPointer::new(generation).unwrap()).unwrap(),
702        )
703        .unwrap();
704        File::create(package_publication_lock_path(temp.path())).unwrap();
705        let snapshot = Arc::new(PackageSnapshot::acquire(temp.path()).unwrap().unwrap());
706        (temp, snapshot, entry, content_hash)
707    }
708
709    #[test]
710    fn guard_rejects_package_mutation_before_execution() {
711        let (_temp, snapshot, entry, content_hash) = fixture();
712        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
713        let source = guard.verify_entry_source(&entry).unwrap();
714        assert_eq!(source, b"pub pipeline run() { return 1 }\n");
715
716        fs::write(&entry, "pub pipeline run() { return 2 }\n").unwrap();
717        let error = guard.verify_entry(&entry).unwrap_err();
718        assert!(error.to_string().contains("content changed"));
719    }
720
721    #[test]
722    fn guard_retains_generation_lease() {
723        let (_temp, snapshot, entry, content_hash) = fixture();
724        let lease_path = snapshot.generation_root().join(GENERATION_LEASE_FILE);
725        let guard =
726            PackageExecutionGuard::new(Arc::clone(&snapshot), "agents", content_hash).unwrap();
727        drop(snapshot);
728        let lease = File::open(lease_path).unwrap();
729        assert!(lease.try_lock().is_err());
730        guard.verify_entry(&entry).unwrap();
731        drop(guard);
732        lease.try_lock().unwrap();
733    }
734
735    #[test]
736    fn guard_rejects_lock_bytes_not_validated_by_snapshot() {
737        let (_temp, snapshot, _entry, content_hash) = fixture();
738        let mut lock = fs::read(snapshot.lock_path()).unwrap();
739        lock.push(b'\n');
740        fs::write(snapshot.lock_path(), lock).unwrap();
741
742        let error = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap_err();
743
744        assert!(error.to_string().contains("before guard construction"));
745    }
746
747    #[test]
748    fn guard_allows_content_pinned_dependency_entry() {
749        let (_temp, snapshot, _entry, content_hash) = fixture();
750        let dependency = snapshot.packages_root().join("shared/helper.harn");
751        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
752
753        let source = guard.verify_entry_source(&dependency).unwrap();
754
755        assert_eq!(source, b"pub fn helper() { return 1 }\n");
756    }
757
758    #[test]
759    fn guarded_export_resolution_rejects_unverified_manifest_mapping() {
760        let (_temp, snapshot, entry, content_hash) = fixture();
761        let manifest = snapshot.packages_root().join("shared/harn.toml");
762        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
763        fs::write(
764            manifest,
765            "[package]\nname = \"shared\"\n\n[exports]\napi = \"payload.harn\"\n",
766        )
767        .unwrap();
768
769        let error =
770            crate::package_imports::resolve_import_path_with_guard(&entry, "shared/api", &guard)
771                .unwrap_err();
772
773        assert!(error.to_string().contains("content changed"));
774    }
775
776    #[cfg(unix)]
777    #[test]
778    fn guard_rejects_descendant_entry_retargeted_within_package() {
779        let (_temp, snapshot, _entry, content_hash) = fixture();
780        let safe = snapshot.packages_root().join("shared/safe.harn");
781        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
782        fs::remove_file(&safe).unwrap();
783        std::os::unix::fs::symlink("payload.harn", &safe).unwrap();
784
785        let error = guard.verify_entry_source(&safe).unwrap_err();
786
787        assert!(error.to_string().contains("retargeted within package"));
788    }
789
790    #[test]
791    fn guard_normalizes_parent_import_within_package() {
792        let (_temp, snapshot, _entry, content_hash) = fixture();
793        let entry = snapshot
794            .packages_root()
795            .join("agents/workflows/../helper.harn");
796        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
797
798        let source = guard.verify_entry_source(&entry).unwrap();
799
800        assert_eq!(source, b"pub fn helper() { return 1 }\n");
801    }
802
803    #[test]
804    fn guard_rejects_parent_import_escaping_alias_root() {
805        let (_temp, snapshot, _entry, content_hash) = fixture();
806        let entry = snapshot.packages_root().join("agents/run.harn");
807        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
808
809        let error = crate::package_imports::resolve_import_path_with_guard(
810            &entry,
811            "../shared/helper",
812            &guard,
813        )
814        .unwrap_err();
815
816        assert!(error.to_string().contains("escapes package alias"));
817    }
818
819    #[test]
820    fn guard_allows_parent_import_within_package_alias() {
821        let (_temp, snapshot, _entry, content_hash) = fixture();
822        let entry = snapshot.packages_root().join("agents/workflows/run.harn");
823        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
824
825        let path =
826            crate::package_imports::resolve_import_path_with_guard(&entry, "../helper", &guard)
827                .expect("parent traversal remains inside agents")
828                .expect("helper resolves inside agents");
829        assert_eq!(path, entry.parent().unwrap().join("../helper.harn"));
830    }
831
832    #[test]
833    fn guard_rejects_parent_import_after_normalizing_importer_path() {
834        let (_temp, snapshot, _entry, content_hash) = fixture();
835        let entry = snapshot
836            .packages_root()
837            .join("agents/workflows/../helper.harn");
838        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
839
840        let error = crate::package_imports::resolve_import_path_with_guard(
841            &entry,
842            "../shared/helper",
843            &guard,
844        )
845        .unwrap_err();
846
847        assert!(error.to_string().contains("escapes package alias"));
848    }
849
850    #[test]
851    fn guard_leaves_absolute_internal_module_paths_to_entry_verification() {
852        let (_temp, snapshot, entry, content_hash) = fixture();
853        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
854
855        guard
856            .validate_import_path(&entry, entry.to_str().unwrap())
857            .expect("absolute internal module path is checked by verify_entry_source");
858    }
859
860    #[cfg(unix)]
861    #[test]
862    fn guard_rejects_primary_alias_retargeted_to_pinned_dependency() {
863        let (_temp, snapshot, _entry, content_hash) = fixture();
864        let packages = snapshot.packages_root().to_path_buf();
865        let primary = packages.join("agents");
866        let original = packages.join("agents-original");
867        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
868        fs::rename(&primary, &original).unwrap();
869        std::os::unix::fs::symlink(packages.join("shared"), &primary).unwrap();
870
871        let error = guard
872            .verify_entry_source(&primary.join("helper.harn"))
873            .unwrap_err();
874
875        assert!(error.to_string().contains("alias 'agents' was retargeted"));
876    }
877
878    #[cfg(unix)]
879    #[test]
880    fn guard_rejects_dependency_alias_retargeted_to_primary() {
881        let (_temp, snapshot, _entry, content_hash) = fixture();
882        let packages = snapshot.packages_root().to_path_buf();
883        let dependency = packages.join("shared");
884        let original = packages.join("shared-original");
885        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
886        fs::rename(&dependency, &original).unwrap();
887        std::os::unix::fs::symlink(packages.join("agents"), &dependency).unwrap();
888
889        let error = guard
890            .verify_entry_source(&dependency.join("run.harn"))
891            .unwrap_err();
892
893        assert!(error.to_string().contains("alias 'shared' was retargeted"));
894    }
895
896    #[cfg(unix)]
897    #[test]
898    fn content_hash_rejects_descendant_symlink() {
899        let temp = tempfile::tempdir().unwrap();
900        fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
901        std::os::unix::fs::symlink("target.harn", temp.path().join("alias.harn")).unwrap();
902
903        let error = compute_package_content_hash(temp.path()).unwrap_err();
904        assert!(error.to_string().contains("unsupported symlink"));
905    }
906
907    #[cfg(unix)]
908    #[test]
909    fn content_hash_ignores_symlinks_at_excluded_paths() {
910        // The symlink rejection guards package *content*. A path the installer
911        // was going to skip anyway cannot invalidate the package, so a
912        // symlinked `.gitignore` must not fail the hash the way a symlinked
913        // source file does.
914        let temp = tempfile::tempdir().unwrap();
915        fs::write(temp.path().join("target.harn"), "pub fn value() { 1 }\n").unwrap();
916        fs::write(temp.path().join("ignore-target"), "target/\n").unwrap();
917        std::os::unix::fs::symlink("ignore-target", temp.path().join(".gitignore")).unwrap();
918
919        compute_package_content_hash(temp.path())
920            .expect("a symlink at an excluded path must not invalidate the package");
921    }
922
923    #[cfg(unix)]
924    #[test]
925    fn guard_accepts_equivalent_root_alias_without_losing_escape_detection() {
926        let (temp, snapshot, entry, content_hash) = fixture();
927        let alias = temp.path().join("project-alias");
928        std::os::unix::fs::symlink(".", &alias).unwrap();
929        let aliased_entry = alias.join(entry.strip_prefix(temp.path()).unwrap());
930        let guard = PackageExecutionGuard::new(snapshot, "agents", content_hash).unwrap();
931
932        let source = guard.verify_entry_source(&aliased_entry).unwrap();
933
934        assert_eq!(source, b"pub pipeline run() { return 1 }\n");
935        let aliased_packages_root = aliased_entry.parent().unwrap().parent().unwrap();
936        let escape = aliased_packages_root.join("agents/../shared/helper.harn");
937        let error = guard.verify_entry_source(&escape).unwrap_err();
938        assert!(error.to_string().contains("unsafe package-relative path"));
939    }
940}