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