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};
5
6use fs2::FileExt;
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
138impl PackageSnapshot {
139    /// Duplicate this snapshot while retaining the same generation lease.
140    pub fn retained_clone(&self) -> Result<Self, PackageSnapshotError> {
141        Ok(Self {
142            project_root: self.project_root.clone(),
143            generation: self.generation.clone(),
144            generation_root: self.generation_root.clone(),
145            packages_root: self.packages_root.clone(),
146            lock_path: self.lock_path.clone(),
147            lock_digest: self.lock_digest.clone(),
148            package_names: self.package_names.clone(),
149            _lease: self._lease.try_clone().map_err(|error| {
150                PackageSnapshotError::io("clone lease", &self.generation_root, error)
151            })?,
152        })
153    }
154
155    /// Acquire the currently published package generation for `project_root`.
156    ///
157    /// The publication lock closes the pointer-to-lease race: GC cannot remove
158    /// the selected generation until this reader holds its shared lease.
159    pub fn acquire(project_root: &Path) -> Result<Option<Self>, PackageSnapshotError> {
160        #[cfg(test)]
161        probe_counter::record_acquire();
162        let project_root = project_root
163            .canonicalize()
164            .map_err(|error| PackageSnapshotError::io("canonicalize", project_root, error))?;
165        let state_path = project_root.join(PACKAGE_STATE_DIR);
166        if !state_path.is_dir() {
167            return Ok(None);
168        }
169        let state_dir = canonical_directory_within(&project_root, &state_path)?;
170        let pointer_path = state_dir.join(PACKAGE_CURRENT_FILE);
171        let publication_lock_path = state_dir.join(PACKAGE_PUBLICATION_LOCK_FILE);
172        if !publication_lock_path.exists() && !pointer_path.exists() {
173            return Ok(None);
174        }
175        require_regular_file(&publication_lock_path)?;
176        let publication_lock = open_existing_lock_file(&publication_lock_path)?;
177        FileExt::lock_shared(&publication_lock)
178            .map_err(|error| PackageSnapshotError::io("lock", &publication_lock_path, error))?;
179
180        if !pointer_path.is_file() {
181            return Ok(None);
182        }
183        require_regular_file(&pointer_path)?;
184
185        let pointer = read_toml::<PackageGenerationPointer>(&pointer_path)?;
186        pointer.validate(&pointer_path)?;
187        let generations_dir =
188            canonical_directory_within(&state_dir, &state_dir.join(PACKAGE_GENERATIONS_DIR))?;
189        let generation_root = canonical_directory_within(
190            &generations_dir,
191            &generations_dir.join(&pointer.generation),
192        )?;
193        let lease_path = generation_root.join(GENERATION_LEASE_FILE);
194        require_regular_file(&lease_path)?;
195        let lease = open_existing_lock_file(&lease_path)?;
196        FileExt::lock_shared(&lease)
197            .map_err(|error| PackageSnapshotError::io("lock", &lease_path, error))?;
198
199        // The generation lease now protects every immutable artifact below the
200        // selected root, so GC no longer needs to be excluded.
201        FileExt::unlock(&publication_lock)
202            .map_err(|error| PackageSnapshotError::io("unlock", &publication_lock_path, error))?;
203
204        let manifest_path = generation_root.join(GENERATION_MANIFEST_FILE);
205        require_regular_file(&manifest_path)?;
206        let manifest = read_toml::<PackageGenerationManifest>(&manifest_path)?;
207        manifest.validate(&manifest_path)?;
208        if manifest.generation != pointer.generation {
209            return Err(PackageSnapshotError::Invalid(format!(
210                "{} names generation {:?}, expected {:?}",
211                manifest_path.display(),
212                manifest.generation,
213                pointer.generation
214            )));
215        }
216        let packages_root = canonical_directory_within(
217            &generation_root,
218            &generation_root.join(GENERATION_PACKAGES_DIR),
219        )?;
220        let lock_path = generation_root.join(GENERATION_LOCK_FILE);
221        require_regular_file(&lock_path)?;
222        let lock_bytes = fs::read(&lock_path)
223            .map_err(|error| PackageSnapshotError::io("read", &lock_path, error))?;
224        let actual_lock_digest = package_lock_digest(&lock_bytes);
225        if actual_lock_digest != manifest.lock_digest {
226            return Err(PackageSnapshotError::Invalid(format!(
227                "{} digest mismatch: generation manifest records {}, actual {}",
228                lock_path.display(),
229                manifest.lock_digest,
230                actual_lock_digest
231            )));
232        }
233        let package_names = parse_package_names(&lock_path, &lock_bytes)?;
234
235        Ok(Some(Self {
236            project_root,
237            generation: pointer.generation,
238            generation_root,
239            packages_root,
240            lock_path,
241            lock_digest: manifest.lock_digest,
242            package_names,
243            _lease: lease,
244        }))
245    }
246
247    /// The nearest ancestor of `anchor` that publishes a package generation.
248    ///
249    /// Split out from `acquire_nearest` so a caller resolving many files can
250    /// find each one's root — a cheap stat-walk — and then acquire only once
251    /// per DISTINCT root. Acquiring is the expensive half (canonicalize, two
252    /// shared flocks, two TOML parses, and a re-read plus SHA256 of the
253    /// lockfile), so a caller that acquires per file and dedupes afterwards
254    /// pays it once per file and throws all but one result away.
255    pub fn nearest_project_root(anchor: &Path) -> Option<PathBuf> {
256        #[cfg(test)]
257        probe_counter::record_root_walk();
258        let mut cursor = if anchor.is_dir() {
259            Some(anchor)
260        } else {
261            anchor.parent()
262        };
263        while let Some(dir) = cursor {
264            if dir
265                .join(PACKAGE_STATE_DIR)
266                .join(PACKAGE_CURRENT_FILE)
267                .is_file()
268            {
269                return Some(dir.to_path_buf());
270            }
271            if dir.join(".git").exists() {
272                break;
273            }
274            cursor = dir.parent();
275        }
276        None
277    }
278
279    pub fn acquire_nearest(anchor: &Path) -> Result<Option<Self>, PackageSnapshotError> {
280        match Self::nearest_project_root(anchor) {
281            Some(root) => Self::acquire(&root),
282            None => Ok(None),
283        }
284    }
285
286    pub fn project_root(&self) -> &Path {
287        &self.project_root
288    }
289
290    pub fn generation(&self) -> &str {
291        &self.generation
292    }
293
294    pub fn generation_root(&self) -> &Path {
295        &self.generation_root
296    }
297
298    pub fn packages_root(&self) -> &Path {
299        &self.packages_root
300    }
301
302    pub fn lock_path(&self) -> &Path {
303        &self.lock_path
304    }
305
306    pub fn lock_digest(&self) -> &str {
307        &self.lock_digest
308    }
309
310    pub fn package_names(&self) -> &[String] {
311        &self.package_names
312    }
313}
314
315#[derive(Debug)]
316pub enum PackageSnapshotError {
317    Io {
318        operation: &'static str,
319        path: PathBuf,
320        source: io::Error,
321    },
322    Invalid(String),
323}
324
325impl PackageSnapshotError {
326    fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
327        Self::Io {
328            operation,
329            path: path.to_path_buf(),
330            source,
331        }
332    }
333}
334
335impl fmt::Display for PackageSnapshotError {
336    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
337        match self {
338            Self::Io {
339                operation,
340                path,
341                source,
342            } => write!(
343                formatter,
344                "failed to {operation} {}: {source}",
345                path.display()
346            ),
347            Self::Invalid(message) => formatter.write_str(message),
348        }
349    }
350}
351
352impl std::error::Error for PackageSnapshotError {
353    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
354        match self {
355            Self::Io { source, .. } => Some(source),
356            Self::Invalid(_) => None,
357        }
358    }
359}
360
361pub fn package_state_dir(project_root: &Path) -> PathBuf {
362    project_root.join(PACKAGE_STATE_DIR)
363}
364
365pub fn package_generations_dir(project_root: &Path) -> PathBuf {
366    package_state_dir(project_root).join(PACKAGE_GENERATIONS_DIR)
367}
368
369pub fn package_publication_lock_path(project_root: &Path) -> PathBuf {
370    package_state_dir(project_root).join(PACKAGE_PUBLICATION_LOCK_FILE)
371}
372
373pub fn package_current_path(project_root: &Path) -> PathBuf {
374    package_state_dir(project_root).join(PACKAGE_CURRENT_FILE)
375}
376
377pub fn generation_root(project_root: &Path, generation: &str) -> PathBuf {
378    package_generations_dir(project_root).join(generation)
379}
380
381pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
382    if let Some(parent) = path.parent() {
383        fs::create_dir_all(parent)
384            .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
385    }
386    OpenOptions::new()
387        .read(true)
388        .write(true)
389        .create(true)
390        .truncate(false)
391        .open(path)
392        .map_err(|error| PackageSnapshotError::io("open", path, error))
393}
394
395fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
396    OpenOptions::new()
397        .read(true)
398        .write(true)
399        .open(path)
400        .map_err(|error| PackageSnapshotError::io("open", path, error))
401}
402
403fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
404where
405    T: for<'de> Deserialize<'de>,
406{
407    let source =
408        fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
409    toml::from_str(&source).map_err(|error| {
410        PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
411    })
412}
413
414fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
415    if version == PACKAGE_GENERATION_SCHEMA_VERSION {
416        Ok(())
417    } else {
418        Err(PackageSnapshotError::Invalid(format!(
419            "unsupported {} schema version {} (expected {})",
420            path.display(),
421            version,
422            PACKAGE_GENERATION_SCHEMA_VERSION
423        )))
424    }
425}
426
427pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
428    let path = Path::new(generation);
429    let mut components = path.components();
430    let Some(Component::Normal(component)) = components.next() else {
431        return Err(invalid_generation_id(generation));
432    };
433    if components.next().is_some()
434        || component.to_str() != Some(generation)
435        || generation.starts_with('.')
436        || !generation
437            .bytes()
438            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
439    {
440        return Err(invalid_generation_id(generation));
441    }
442    Ok(())
443}
444
445fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
446    PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
447}
448
449fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
450    let Some(hex) = digest.strip_prefix("sha256:") else {
451        return Err(PackageSnapshotError::Invalid(format!(
452            "invalid package lock digest {digest:?}"
453        )));
454    };
455    if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
456        return Err(PackageSnapshotError::Invalid(format!(
457            "invalid package lock digest {digest:?}"
458        )));
459    }
460    Ok(())
461}
462
463#[derive(Deserialize)]
464struct PublishedLock {
465    #[serde(default, rename = "package")]
466    packages: Vec<PublishedLockEntry>,
467}
468
469#[derive(Deserialize)]
470struct PublishedLockEntry {
471    name: String,
472}
473
474fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
475    let source = std::str::from_utf8(bytes).map_err(|error| {
476        PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
477    })?;
478    let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
479        PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
480    })?;
481    let mut names = std::collections::BTreeSet::new();
482    for entry in lock.packages {
483        if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
484            return Err(PackageSnapshotError::Invalid(format!(
485                "{} contains an invalid or duplicate package name {:?}",
486                path.display(),
487                entry.name
488            )));
489        }
490    }
491    Ok(names.into_iter().collect())
492}
493
494/// Return whether `name` is a safe single-component package import alias.
495pub fn is_valid_package_name(name: &str) -> bool {
496    !name.is_empty()
497        && name != "."
498        && name != ".."
499        && name
500            .bytes()
501            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
502}
503
504fn encode_hex(bytes: &[u8]) -> String {
505    let mut encoded = String::with_capacity(bytes.len() * 2);
506    for byte in bytes {
507        use std::fmt::Write as _;
508        let _ = write!(encoded, "{byte:02x}");
509    }
510    encoded
511}
512
513pub fn package_lock_digest(bytes: &[u8]) -> String {
514    format!("sha256:{}", encode_hex(&Sha256::digest(bytes)))
515}
516
517fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
518    let canonical = path
519        .canonicalize()
520        .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
521    if canonical == root || canonical.starts_with(root) {
522        Ok(canonical)
523    } else {
524        Err(PackageSnapshotError::Invalid(format!(
525            "package generation directory escapes {}: {}",
526            root.display(),
527            path.display()
528        )))
529    }
530}
531
532fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
533    let metadata = fs::symlink_metadata(path)
534        .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
535    if metadata.file_type().is_file() {
536        return Ok(());
537    }
538    Err(PackageSnapshotError::Invalid(format!(
539        "package generation file is not a regular file: {}",
540        path.display()
541    )))
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use std::sync::{Arc, Barrier};
548
549    fn publish_fixture(root: &Path, generation: &str, body: &str) {
550        let generation_root = generation_root(root, generation);
551        fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
552        fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
553        fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
554        let digest = package_lock_digest(body.as_bytes());
555        let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
556        fs::write(
557            generation_root.join(GENERATION_MANIFEST_FILE),
558            toml::to_string_pretty(&manifest).unwrap(),
559        )
560        .unwrap();
561        let pointer = PackageGenerationPointer::new(generation).unwrap();
562        fs::create_dir_all(package_state_dir(root)).unwrap();
563        fs::write(
564            package_current_path(root),
565            toml::to_string_pretty(&pointer).unwrap(),
566        )
567        .unwrap();
568        File::create(package_publication_lock_path(root)).unwrap();
569    }
570
571    #[test]
572    fn snapshot_holds_generation_lease_until_drop() {
573        let temp = tempfile::tempdir().unwrap();
574        publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
575
576        let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
577        let lease =
578            open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
579                .unwrap();
580        assert!(FileExt::try_lock_exclusive(&lease).is_err());
581
582        drop(snapshot);
583        FileExt::try_lock_exclusive(&lease).unwrap();
584    }
585
586    #[test]
587    fn reader_selects_generation_published_before_publication_unlock() {
588        let temp = tempfile::tempdir().unwrap();
589        publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
590        let root = temp.path().to_path_buf();
591        let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
592        FileExt::lock_exclusive(&publication).unwrap();
593
594        let started = Arc::new(Barrier::new(2));
595        let reader_started = Arc::clone(&started);
596        let reader_root = root.clone();
597        let reader = std::thread::spawn(move || {
598            reader_started.wait();
599            PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
600        });
601        started.wait();
602
603        publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
604        FileExt::unlock(&publication).unwrap();
605
606        let snapshot = reader.join().unwrap();
607        assert_eq!(snapshot.generation(), "generation_b");
608        assert_eq!(
609            fs::read_to_string(snapshot.lock_path()).unwrap(),
610            "version = 4\n# lock b\n"
611        );
612    }
613
614    #[test]
615    fn malformed_pointer_cannot_escape_generation_root() {
616        let temp = tempfile::tempdir().unwrap();
617        fs::create_dir_all(package_state_dir(temp.path())).unwrap();
618        fs::write(
619            package_current_path(temp.path()),
620            "schema_version = 1\ngeneration = \"../outside\"\n",
621        )
622        .unwrap();
623        File::create(package_publication_lock_path(temp.path())).unwrap();
624
625        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
626        assert!(
627            error.to_string().contains("invalid package generation id"),
628            "unexpected error: {error}"
629        );
630    }
631
632    #[test]
633    fn lock_package_name_cannot_escape_packages_root() {
634        let temp = tempfile::tempdir().unwrap();
635        publish_fixture(
636            temp.path(),
637            "generation_a",
638            "version = 4\n\n[[package]]\nname = \"../outside\"\n",
639        );
640
641        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
642        assert!(
643            error
644                .to_string()
645                .contains("invalid or duplicate package name"),
646            "unexpected error: {error}"
647        );
648    }
649
650    #[cfg(unix)]
651    #[test]
652    fn symlinked_generation_root_cannot_escape_generations_directory() {
653        let temp = tempfile::tempdir().unwrap();
654        publish_fixture(temp.path(), "generation_a", "version = 4\n");
655        let generation = generation_root(temp.path(), "generation_a");
656        let outside = temp.path().join("outside-generation");
657        fs::rename(&generation, &outside).unwrap();
658        std::os::unix::fs::symlink(&outside, &generation).unwrap();
659
660        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
661        assert!(
662            error.to_string().contains("escapes"),
663            "unexpected error: {error}"
664        );
665    }
666}