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