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