Skip to main content

harn_modules/
package_snapshot.rs

1use std::fmt;
2use std::fs::{self, File, OpenOptions};
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9
10/// Counts the filesystem work package resolution performs, so tests can assert
11/// that work which is not needed does not happen.
12///
13/// Counters rather than behavioural assertions because there is nothing else to
14/// observe: acquiring a snapshot and discarding it changes no result, only wall
15/// time. That is exactly how a 5x regression shipped and stayed hidden for three
16/// releases (harn#4815) — the property under test is "this work does not
17/// happen", and only a probe can see that.
18///
19/// The two halves are counted separately because they cost very differently: a
20/// root walk is a handful of stats, while an acquire canonicalizes, takes two
21/// shared flocks, parses two TOML files, and re-reads plus SHA256s the lockfile.
22/// A caller resolving N files under one root should walk N times and acquire
23/// ONCE; a single total could not tell that apart from N acquires.
24#[cfg(test)]
25pub(crate) mod probe_counter {
26    use std::cell::Cell;
27
28    thread_local! {
29        static ACQUIRE_CALLS: Cell<usize> = const { Cell::new(0) };
30        static ROOT_WALK_CALLS: Cell<usize> = const { Cell::new(0) };
31    }
32
33    pub(crate) fn record_acquire() {
34        ACQUIRE_CALLS.with(|calls| calls.set(calls.get() + 1));
35    }
36
37    pub(crate) fn record_root_walk() {
38        ROOT_WALK_CALLS.with(|calls| calls.set(calls.get() + 1));
39    }
40
41    /// Run `body`, returning its value and how many times it probed the
42    /// filesystem for a package at all. Thread-local, so concurrent tests
43    /// cannot bleed into each other.
44    pub(crate) fn count_probes<T>(body: impl FnOnce() -> T) -> (T, usize) {
45        let (value, walks, _) = count_walks_and_acquires(body);
46        (value, walks)
47    }
48
49    pub(crate) fn count_walks_and_acquires<T>(body: impl FnOnce() -> T) -> (T, usize, usize) {
50        ROOT_WALK_CALLS.with(|calls| calls.set(0));
51        ACQUIRE_CALLS.with(|calls| calls.set(0));
52        let value = body();
53        (
54            value,
55            ROOT_WALK_CALLS.with(|calls| calls.get()),
56            ACQUIRE_CALLS.with(|calls| calls.get()),
57        )
58    }
59}
60
61pub const PACKAGE_STATE_DIR: &str = ".harn";
62pub const PACKAGE_CURRENT_FILE: &str = "package-current.toml";
63pub const PACKAGE_GENERATIONS_DIR: &str = "package-generations";
64pub const PACKAGE_PUBLICATION_LOCK_FILE: &str = "package-generation.lock";
65pub const PACKAGE_INSTALL_LOCK_FILE: &str = "package-install.lock";
66pub const GENERATION_MANIFEST_FILE: &str = "generation.toml";
67pub const GENERATION_LOCK_FILE: &str = "harn.lock";
68pub const GENERATION_LEASE_FILE: &str = "lease.lock";
69pub const GENERATION_PACKAGES_DIR: &str = "packages";
70pub const PACKAGE_GENERATION_SCHEMA_VERSION: u32 = 1;
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(deny_unknown_fields)]
74pub struct PackageGenerationPointer {
75    pub schema_version: u32,
76    pub generation: String,
77}
78
79impl PackageGenerationPointer {
80    pub fn new(generation: impl Into<String>) -> Result<Self, PackageSnapshotError> {
81        let generation = generation.into();
82        validate_generation_id(&generation)?;
83        Ok(Self {
84            schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
85            generation,
86        })
87    }
88
89    pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
90        validate_schema_version(self.schema_version, path)?;
91        validate_generation_id(&self.generation)
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct PackageGenerationManifest {
98    pub schema_version: u32,
99    pub generation: String,
100    pub lock_digest: String,
101}
102
103impl PackageGenerationManifest {
104    pub fn new(
105        generation: impl Into<String>,
106        lock_digest: impl Into<String>,
107    ) -> Result<Self, PackageSnapshotError> {
108        let generation = generation.into();
109        validate_generation_id(&generation)?;
110        let lock_digest = lock_digest.into();
111        validate_lock_digest(&lock_digest)?;
112        Ok(Self {
113            schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
114            generation,
115            lock_digest,
116        })
117    }
118
119    pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
120        validate_schema_version(self.schema_version, path)?;
121        validate_generation_id(&self.generation)?;
122        validate_lock_digest(&self.lock_digest)
123    }
124}
125
126#[derive(Debug)]
127pub struct PackageSnapshot {
128    project_root: PathBuf,
129    generation: String,
130    generation_root: PathBuf,
131    packages_root: PathBuf,
132    lock_path: PathBuf,
133    lock_digest: String,
134    package_names: Vec<String>,
135    _lease: File,
136}
137
138/// Generation leases retained by lazy path resolution.
139///
140/// A bare `PathBuf` cannot carry the lease that makes a path beneath a package
141/// generation valid. Lazy resolution therefore promotes a selected snapshot
142/// to this process-lifetime registry before returning such a path. Harn CLI
143/// processes are command-scoped, so this is also the command lifetime; long-
144/// lived processes retain every generation whose paths they may have cached.
145static PROCESS_PACKAGE_SNAPSHOTS: OnceLock<Mutex<Vec<PackageSnapshot>>> = OnceLock::new();
146
147impl PackageSnapshot {
148    /// Retain this generation for the rest of the process.
149    ///
150    /// Repeated resolution against the same project generation reuses the
151    /// existing lease instead of consuming another file descriptor.
152    pub(crate) fn retain_for_process(self) {
153        let snapshots = PROCESS_PACKAGE_SNAPSHOTS.get_or_init(|| Mutex::new(Vec::new()));
154        let mut snapshots = snapshots
155            .lock()
156            .unwrap_or_else(std::sync::PoisonError::into_inner);
157        if snapshots.iter().any(|snapshot| {
158            snapshot.project_root == self.project_root && snapshot.generation == self.generation
159        }) {
160            return;
161        }
162        snapshots.push(self);
163    }
164
165    /// Duplicate this snapshot while retaining the same generation lease.
166    pub fn retained_clone(&self) -> Result<Self, PackageSnapshotError> {
167        Ok(Self {
168            project_root: self.project_root.clone(),
169            generation: self.generation.clone(),
170            generation_root: self.generation_root.clone(),
171            packages_root: self.packages_root.clone(),
172            lock_path: self.lock_path.clone(),
173            lock_digest: self.lock_digest.clone(),
174            package_names: self.package_names.clone(),
175            _lease: self._lease.try_clone().map_err(|error| {
176                PackageSnapshotError::io("clone lease", &self.generation_root, error)
177            })?,
178        })
179    }
180
181    /// Acquire the currently published package generation for `project_root`.
182    ///
183    /// The publication lock closes the pointer-to-lease race: GC cannot remove
184    /// the selected generation until this reader holds its shared lease.
185    pub fn acquire(project_root: &Path) -> Result<Option<Self>, PackageSnapshotError> {
186        #[cfg(test)]
187        probe_counter::record_acquire();
188        let project_root = project_root
189            .canonicalize()
190            .map_err(|error| PackageSnapshotError::io("canonicalize", project_root, error))?;
191        let state_path = project_root.join(PACKAGE_STATE_DIR);
192        if !state_path.is_dir() {
193            return Ok(None);
194        }
195        let state_dir = canonical_directory_within(&project_root, &state_path)?;
196        let pointer_path = state_dir.join(PACKAGE_CURRENT_FILE);
197        let publication_lock_path = state_dir.join(PACKAGE_PUBLICATION_LOCK_FILE);
198        if !publication_lock_path.exists() && !pointer_path.exists() {
199            return Ok(None);
200        }
201        require_regular_file(&publication_lock_path)?;
202        let publication_lock = open_existing_lock_file(&publication_lock_path)?;
203        publication_lock
204            .lock_shared()
205            .map_err(|error| PackageSnapshotError::io("lock", &publication_lock_path, error))?;
206
207        if !pointer_path.is_file() {
208            return Ok(None);
209        }
210        require_regular_file(&pointer_path)?;
211
212        let pointer = read_toml::<PackageGenerationPointer>(&pointer_path)?;
213        pointer.validate(&pointer_path)?;
214        let generations_dir =
215            canonical_directory_within(&state_dir, &state_dir.join(PACKAGE_GENERATIONS_DIR))?;
216        let generation_root = canonical_directory_within(
217            &generations_dir,
218            &generations_dir.join(&pointer.generation),
219        )?;
220        let lease_path = generation_root.join(GENERATION_LEASE_FILE);
221        require_regular_file(&lease_path)?;
222        let lease = open_existing_lock_file(&lease_path)?;
223        lease
224            .lock_shared()
225            .map_err(|error| PackageSnapshotError::io("lock", &lease_path, error))?;
226
227        // The generation lease now protects every immutable artifact below the
228        // selected root, so GC no longer needs to be excluded.
229        publication_lock
230            .unlock()
231            .map_err(|error| PackageSnapshotError::io("unlock", &publication_lock_path, error))?;
232
233        let manifest_path = generation_root.join(GENERATION_MANIFEST_FILE);
234        require_regular_file(&manifest_path)?;
235        let manifest = read_toml::<PackageGenerationManifest>(&manifest_path)?;
236        manifest.validate(&manifest_path)?;
237        if manifest.generation != pointer.generation {
238            return Err(PackageSnapshotError::Invalid(format!(
239                "{} names generation {:?}, expected {:?}",
240                manifest_path.display(),
241                manifest.generation,
242                pointer.generation
243            )));
244        }
245        let packages_root = canonical_directory_within(
246            &generation_root,
247            &generation_root.join(GENERATION_PACKAGES_DIR),
248        )?;
249        let lock_path = generation_root.join(GENERATION_LOCK_FILE);
250        require_regular_file(&lock_path)?;
251        let lock_bytes = fs::read(&lock_path)
252            .map_err(|error| PackageSnapshotError::io("read", &lock_path, error))?;
253        let actual_lock_digest = package_lock_digest(&lock_bytes);
254        if actual_lock_digest != manifest.lock_digest {
255            return Err(PackageSnapshotError::Invalid(format!(
256                "{} digest mismatch: generation manifest records {}, actual {}",
257                lock_path.display(),
258                manifest.lock_digest,
259                actual_lock_digest
260            )));
261        }
262        let package_names = parse_package_names(&lock_path, &lock_bytes)?;
263
264        Ok(Some(Self {
265            project_root,
266            generation: pointer.generation,
267            generation_root,
268            packages_root,
269            lock_path,
270            lock_digest: manifest.lock_digest,
271            package_names,
272            _lease: lease,
273        }))
274    }
275
276    /// The nearest ancestor of `anchor` that publishes a package generation.
277    ///
278    /// Split out from `acquire_nearest` so a caller resolving many files can
279    /// find each one's root — a cheap stat-walk — and then acquire only once
280    /// per DISTINCT root. Acquiring is the expensive half (canonicalize, two
281    /// shared flocks, two TOML parses, and a re-read plus SHA256 of the
282    /// lockfile), so a caller that acquires per file and dedupes afterwards
283    /// pays it once per file and throws all but one result away.
284    pub fn nearest_project_root(anchor: &Path) -> Option<PathBuf> {
285        #[cfg(test)]
286        probe_counter::record_root_walk();
287        // Same upward walk as every other Harn frontend, parameterized on the
288        // package-state sentinel (`.harn/package-current.toml`) instead of
289        // `harn.toml`.
290        let sentinel = Path::new(PACKAGE_STATE_DIR).join(PACKAGE_CURRENT_FILE);
291        crate::manifest_walk::find_nearest_ancestor(anchor, sentinel).map(|found| found.dir)
292    }
293
294    pub fn acquire_nearest(anchor: &Path) -> Result<Option<Self>, PackageSnapshotError> {
295        match Self::nearest_project_root(anchor) {
296            Some(root) => Self::acquire(&root),
297            None => Ok(None),
298        }
299    }
300
301    pub fn project_root(&self) -> &Path {
302        &self.project_root
303    }
304
305    pub fn generation(&self) -> &str {
306        &self.generation
307    }
308
309    pub fn generation_root(&self) -> &Path {
310        &self.generation_root
311    }
312
313    pub fn packages_root(&self) -> &Path {
314        &self.packages_root
315    }
316
317    pub fn lock_path(&self) -> &Path {
318        &self.lock_path
319    }
320
321    pub fn lock_digest(&self) -> &str {
322        &self.lock_digest
323    }
324
325    pub fn package_names(&self) -> &[String] {
326        &self.package_names
327    }
328}
329
330#[derive(Debug)]
331pub enum PackageSnapshotError {
332    Io {
333        operation: &'static str,
334        path: PathBuf,
335        source: io::Error,
336    },
337    Invalid(String),
338}
339
340impl PackageSnapshotError {
341    fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
342        Self::Io {
343            operation,
344            path: path.to_path_buf(),
345            source,
346        }
347    }
348}
349
350impl fmt::Display for PackageSnapshotError {
351    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
352        match self {
353            Self::Io {
354                operation,
355                path,
356                source,
357            } => write!(
358                formatter,
359                "failed to {operation} {}: {source}",
360                path.display()
361            ),
362            Self::Invalid(message) => formatter.write_str(message),
363        }
364    }
365}
366
367impl std::error::Error for PackageSnapshotError {
368    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
369        match self {
370            Self::Io { source, .. } => Some(source),
371            Self::Invalid(_) => None,
372        }
373    }
374}
375
376pub fn package_state_dir(project_root: &Path) -> PathBuf {
377    project_root.join(PACKAGE_STATE_DIR)
378}
379
380pub fn package_generations_dir(project_root: &Path) -> PathBuf {
381    package_state_dir(project_root).join(PACKAGE_GENERATIONS_DIR)
382}
383
384pub fn package_publication_lock_path(project_root: &Path) -> PathBuf {
385    package_state_dir(project_root).join(PACKAGE_PUBLICATION_LOCK_FILE)
386}
387
388pub fn package_current_path(project_root: &Path) -> PathBuf {
389    package_state_dir(project_root).join(PACKAGE_CURRENT_FILE)
390}
391
392pub fn generation_root(project_root: &Path, generation: &str) -> PathBuf {
393    package_generations_dir(project_root).join(generation)
394}
395
396pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
397    if let Some(parent) = path.parent() {
398        fs::create_dir_all(parent)
399            .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
400    }
401    OpenOptions::new()
402        .read(true)
403        .write(true)
404        .create(true)
405        .truncate(false)
406        .open(path)
407        .map_err(|error| PackageSnapshotError::io("open", path, error))
408}
409
410fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
411    OpenOptions::new()
412        .read(true)
413        .write(true)
414        .open(path)
415        .map_err(|error| PackageSnapshotError::io("open", path, error))
416}
417
418fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
419where
420    T: for<'de> Deserialize<'de>,
421{
422    let source =
423        fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
424    toml::from_str(&source).map_err(|error| {
425        PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
426    })
427}
428
429fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
430    if version == PACKAGE_GENERATION_SCHEMA_VERSION {
431        Ok(())
432    } else {
433        Err(PackageSnapshotError::Invalid(format!(
434            "unsupported {} schema version {} (expected {})",
435            path.display(),
436            version,
437            PACKAGE_GENERATION_SCHEMA_VERSION
438        )))
439    }
440}
441
442pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
443    let path = Path::new(generation);
444    let mut components = path.components();
445    let Some(Component::Normal(component)) = components.next() else {
446        return Err(invalid_generation_id(generation));
447    };
448    if components.next().is_some()
449        || component.to_str() != Some(generation)
450        || generation.starts_with('.')
451        || !generation
452            .bytes()
453            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
454    {
455        return Err(invalid_generation_id(generation));
456    }
457    Ok(())
458}
459
460fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
461    PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
462}
463
464fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
465    let Some(hex) = digest.strip_prefix("sha256:") else {
466        return Err(PackageSnapshotError::Invalid(format!(
467            "invalid package lock digest {digest:?}"
468        )));
469    };
470    if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
471        return Err(PackageSnapshotError::Invalid(format!(
472            "invalid package lock digest {digest:?}"
473        )));
474    }
475    Ok(())
476}
477
478#[derive(Deserialize)]
479struct PublishedLock {
480    #[serde(default, rename = "package")]
481    packages: Vec<PublishedLockEntry>,
482}
483
484#[derive(Deserialize)]
485struct PublishedLockEntry {
486    name: String,
487}
488
489fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
490    let source = std::str::from_utf8(bytes).map_err(|error| {
491        PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
492    })?;
493    let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
494        PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
495    })?;
496    let mut names = std::collections::BTreeSet::new();
497    for entry in lock.packages {
498        if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
499            return Err(PackageSnapshotError::Invalid(format!(
500                "{} contains an invalid or duplicate package name {:?}",
501                path.display(),
502                entry.name
503            )));
504        }
505    }
506    Ok(names.into_iter().collect())
507}
508
509/// Return whether `name` is a safe single-component package import alias.
510pub fn is_valid_package_name(name: &str) -> bool {
511    !name.is_empty()
512        && name != "."
513        && name != ".."
514        && name
515            .bytes()
516            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
517}
518
519fn encode_hex(bytes: &[u8]) -> String {
520    let mut encoded = String::with_capacity(bytes.len() * 2);
521    for byte in bytes {
522        use std::fmt::Write as _;
523        let _ = write!(encoded, "{byte:02x}");
524    }
525    encoded
526}
527
528pub fn package_lock_digest(bytes: &[u8]) -> String {
529    format!("sha256:{}", encode_hex(&Sha256::digest(bytes)))
530}
531
532fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
533    let canonical = path
534        .canonicalize()
535        .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
536    if canonical == root || canonical.starts_with(root) {
537        Ok(canonical)
538    } else {
539        Err(PackageSnapshotError::Invalid(format!(
540            "package generation directory escapes {}: {}",
541            root.display(),
542            path.display()
543        )))
544    }
545}
546
547fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
548    let metadata = fs::symlink_metadata(path)
549        .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
550    if metadata.file_type().is_file() {
551        return Ok(());
552    }
553    Err(PackageSnapshotError::Invalid(format!(
554        "package generation file is not a regular file: {}",
555        path.display()
556    )))
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562    use std::sync::{Arc, Barrier};
563
564    fn publish_fixture(root: &Path, generation: &str, body: &str) {
565        let generation_root = generation_root(root, generation);
566        fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
567        fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
568        fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
569        let digest = package_lock_digest(body.as_bytes());
570        let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
571        fs::write(
572            generation_root.join(GENERATION_MANIFEST_FILE),
573            toml::to_string_pretty(&manifest).unwrap(),
574        )
575        .unwrap();
576        let pointer = PackageGenerationPointer::new(generation).unwrap();
577        fs::create_dir_all(package_state_dir(root)).unwrap();
578        fs::write(
579            package_current_path(root),
580            toml::to_string_pretty(&pointer).unwrap(),
581        )
582        .unwrap();
583        File::create(package_publication_lock_path(root)).unwrap();
584    }
585
586    #[test]
587    fn snapshot_holds_generation_lease_until_drop() {
588        let temp = tempfile::tempdir().unwrap();
589        publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
590
591        let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
592        let lease =
593            open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
594                .unwrap();
595        assert!(lease.try_lock().is_err());
596
597        drop(snapshot);
598        lease.try_lock().unwrap();
599    }
600
601    #[test]
602    fn reader_selects_generation_published_before_publication_unlock() {
603        let temp = tempfile::tempdir().unwrap();
604        publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
605        let root = temp.path().to_path_buf();
606        let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
607        publication.lock().unwrap();
608
609        let started = Arc::new(Barrier::new(2));
610        let reader_started = Arc::clone(&started);
611        let reader_root = root.clone();
612        let reader = std::thread::spawn(move || {
613            reader_started.wait();
614            PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
615        });
616        started.wait();
617
618        publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
619        publication.unlock().unwrap();
620
621        let snapshot = reader.join().unwrap();
622        assert_eq!(snapshot.generation(), "generation_b");
623        assert_eq!(
624            fs::read_to_string(snapshot.lock_path()).unwrap(),
625            "version = 4\n# lock b\n"
626        );
627    }
628
629    #[test]
630    fn malformed_pointer_cannot_escape_generation_root() {
631        let temp = tempfile::tempdir().unwrap();
632        fs::create_dir_all(package_state_dir(temp.path())).unwrap();
633        fs::write(
634            package_current_path(temp.path()),
635            "schema_version = 1\ngeneration = \"../outside\"\n",
636        )
637        .unwrap();
638        File::create(package_publication_lock_path(temp.path())).unwrap();
639
640        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
641        assert!(
642            error.to_string().contains("invalid package generation id"),
643            "unexpected error: {error}"
644        );
645    }
646
647    #[test]
648    fn lock_package_name_cannot_escape_packages_root() {
649        let temp = tempfile::tempdir().unwrap();
650        publish_fixture(
651            temp.path(),
652            "generation_a",
653            "version = 4\n\n[[package]]\nname = \"../outside\"\n",
654        );
655
656        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
657        assert!(
658            error
659                .to_string()
660                .contains("invalid or duplicate package name"),
661            "unexpected error: {error}"
662        );
663    }
664
665    #[cfg(unix)]
666    #[test]
667    fn symlinked_generation_root_cannot_escape_generations_directory() {
668        let temp = tempfile::tempdir().unwrap();
669        publish_fixture(temp.path(), "generation_a", "version = 4\n");
670        let generation = generation_root(temp.path(), "generation_a");
671        let outside = temp.path().join("outside-generation");
672        fs::rename(&generation, &outside).unwrap();
673        std::os::unix::fs::symlink(&outside, &generation).unwrap();
674
675        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
676        assert!(
677            error.to_string().contains("escapes"),
678            "unexpected error: {error}"
679        );
680    }
681}