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
10pub const PACKAGE_STATE_DIR: &str = ".harn";
11pub const PACKAGE_CURRENT_FILE: &str = "package-current.toml";
12pub const PACKAGE_GENERATIONS_DIR: &str = "package-generations";
13pub const PACKAGE_PUBLICATION_LOCK_FILE: &str = "package-generation.lock";
14pub const PACKAGE_INSTALL_LOCK_FILE: &str = "package-install.lock";
15pub const GENERATION_MANIFEST_FILE: &str = "generation.toml";
16pub const GENERATION_LOCK_FILE: &str = "harn.lock";
17pub const GENERATION_LEASE_FILE: &str = "lease.lock";
18pub const GENERATION_PACKAGES_DIR: &str = "packages";
19pub const PACKAGE_GENERATION_SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct PackageGenerationPointer {
24    pub schema_version: u32,
25    pub generation: String,
26}
27
28impl PackageGenerationPointer {
29    pub fn new(generation: impl Into<String>) -> Result<Self, PackageSnapshotError> {
30        let generation = generation.into();
31        validate_generation_id(&generation)?;
32        Ok(Self {
33            schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
34            generation,
35        })
36    }
37
38    pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
39        validate_schema_version(self.schema_version, path)?;
40        validate_generation_id(&self.generation)
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct PackageGenerationManifest {
47    pub schema_version: u32,
48    pub generation: String,
49    pub lock_digest: String,
50}
51
52impl PackageGenerationManifest {
53    pub fn new(
54        generation: impl Into<String>,
55        lock_digest: impl Into<String>,
56    ) -> Result<Self, PackageSnapshotError> {
57        let generation = generation.into();
58        validate_generation_id(&generation)?;
59        let lock_digest = lock_digest.into();
60        validate_lock_digest(&lock_digest)?;
61        Ok(Self {
62            schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
63            generation,
64            lock_digest,
65        })
66    }
67
68    pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
69        validate_schema_version(self.schema_version, path)?;
70        validate_generation_id(&self.generation)?;
71        validate_lock_digest(&self.lock_digest)
72    }
73}
74
75#[derive(Debug)]
76pub struct PackageSnapshot {
77    project_root: PathBuf,
78    generation: String,
79    generation_root: PathBuf,
80    packages_root: PathBuf,
81    lock_path: PathBuf,
82    lock_digest: String,
83    package_names: Vec<String>,
84    _lease: File,
85}
86
87impl PackageSnapshot {
88    /// Acquire the currently published package generation for `project_root`.
89    ///
90    /// The publication lock closes the pointer-to-lease race: GC cannot remove
91    /// the selected generation until this reader holds its shared lease.
92    pub fn acquire(project_root: &Path) -> Result<Option<Self>, PackageSnapshotError> {
93        let project_root = project_root
94            .canonicalize()
95            .map_err(|error| PackageSnapshotError::io("canonicalize", project_root, error))?;
96        let state_path = project_root.join(PACKAGE_STATE_DIR);
97        if !state_path.is_dir() {
98            return Ok(None);
99        }
100        let state_dir = canonical_directory_within(&project_root, &state_path)?;
101        let pointer_path = state_dir.join(PACKAGE_CURRENT_FILE);
102        let publication_lock_path = state_dir.join(PACKAGE_PUBLICATION_LOCK_FILE);
103        if !publication_lock_path.exists() && !pointer_path.exists() {
104            return Ok(None);
105        }
106        require_regular_file(&publication_lock_path)?;
107        let publication_lock = open_existing_lock_file(&publication_lock_path)?;
108        FileExt::lock_shared(&publication_lock)
109            .map_err(|error| PackageSnapshotError::io("lock", &publication_lock_path, error))?;
110
111        if !pointer_path.is_file() {
112            return Ok(None);
113        }
114        require_regular_file(&pointer_path)?;
115
116        let pointer = read_toml::<PackageGenerationPointer>(&pointer_path)?;
117        pointer.validate(&pointer_path)?;
118        let generations_dir =
119            canonical_directory_within(&state_dir, &state_dir.join(PACKAGE_GENERATIONS_DIR))?;
120        let generation_root = canonical_directory_within(
121            &generations_dir,
122            &generations_dir.join(&pointer.generation),
123        )?;
124        let lease_path = generation_root.join(GENERATION_LEASE_FILE);
125        require_regular_file(&lease_path)?;
126        let lease = open_existing_lock_file(&lease_path)?;
127        FileExt::lock_shared(&lease)
128            .map_err(|error| PackageSnapshotError::io("lock", &lease_path, error))?;
129
130        // The generation lease now protects every immutable artifact below the
131        // selected root, so GC no longer needs to be excluded.
132        FileExt::unlock(&publication_lock)
133            .map_err(|error| PackageSnapshotError::io("unlock", &publication_lock_path, error))?;
134
135        let manifest_path = generation_root.join(GENERATION_MANIFEST_FILE);
136        require_regular_file(&manifest_path)?;
137        let manifest = read_toml::<PackageGenerationManifest>(&manifest_path)?;
138        manifest.validate(&manifest_path)?;
139        if manifest.generation != pointer.generation {
140            return Err(PackageSnapshotError::Invalid(format!(
141                "{} names generation {:?}, expected {:?}",
142                manifest_path.display(),
143                manifest.generation,
144                pointer.generation
145            )));
146        }
147        let packages_root = canonical_directory_within(
148            &generation_root,
149            &generation_root.join(GENERATION_PACKAGES_DIR),
150        )?;
151        let lock_path = generation_root.join(GENERATION_LOCK_FILE);
152        require_regular_file(&lock_path)?;
153        let lock_bytes = fs::read(&lock_path)
154            .map_err(|error| PackageSnapshotError::io("read", &lock_path, error))?;
155        let actual_lock_digest = package_lock_digest(&lock_bytes);
156        if actual_lock_digest != manifest.lock_digest {
157            return Err(PackageSnapshotError::Invalid(format!(
158                "{} digest mismatch: generation manifest records {}, actual {}",
159                lock_path.display(),
160                manifest.lock_digest,
161                actual_lock_digest
162            )));
163        }
164        let package_names = parse_package_names(&lock_path, &lock_bytes)?;
165
166        Ok(Some(Self {
167            project_root,
168            generation: pointer.generation,
169            generation_root,
170            packages_root,
171            lock_path,
172            lock_digest: manifest.lock_digest,
173            package_names,
174            _lease: lease,
175        }))
176    }
177
178    pub fn acquire_nearest(anchor: &Path) -> Result<Option<Self>, PackageSnapshotError> {
179        let mut cursor = if anchor.is_dir() {
180            Some(anchor)
181        } else {
182            anchor.parent()
183        };
184        while let Some(dir) = cursor {
185            if dir
186                .join(PACKAGE_STATE_DIR)
187                .join(PACKAGE_CURRENT_FILE)
188                .is_file()
189            {
190                return Self::acquire(dir);
191            }
192            if dir.join(".git").exists() {
193                break;
194            }
195            cursor = dir.parent();
196        }
197        Ok(None)
198    }
199
200    pub fn project_root(&self) -> &Path {
201        &self.project_root
202    }
203
204    pub fn generation(&self) -> &str {
205        &self.generation
206    }
207
208    pub fn generation_root(&self) -> &Path {
209        &self.generation_root
210    }
211
212    pub fn packages_root(&self) -> &Path {
213        &self.packages_root
214    }
215
216    pub fn lock_path(&self) -> &Path {
217        &self.lock_path
218    }
219
220    pub fn lock_digest(&self) -> &str {
221        &self.lock_digest
222    }
223
224    pub fn package_names(&self) -> &[String] {
225        &self.package_names
226    }
227}
228
229#[derive(Debug)]
230pub enum PackageSnapshotError {
231    Io {
232        operation: &'static str,
233        path: PathBuf,
234        source: io::Error,
235    },
236    Invalid(String),
237}
238
239impl PackageSnapshotError {
240    fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
241        Self::Io {
242            operation,
243            path: path.to_path_buf(),
244            source,
245        }
246    }
247}
248
249impl fmt::Display for PackageSnapshotError {
250    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251        match self {
252            Self::Io {
253                operation,
254                path,
255                source,
256            } => write!(
257                formatter,
258                "failed to {operation} {}: {source}",
259                path.display()
260            ),
261            Self::Invalid(message) => formatter.write_str(message),
262        }
263    }
264}
265
266impl std::error::Error for PackageSnapshotError {
267    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
268        match self {
269            Self::Io { source, .. } => Some(source),
270            Self::Invalid(_) => None,
271        }
272    }
273}
274
275pub fn package_state_dir(project_root: &Path) -> PathBuf {
276    project_root.join(PACKAGE_STATE_DIR)
277}
278
279pub fn package_generations_dir(project_root: &Path) -> PathBuf {
280    package_state_dir(project_root).join(PACKAGE_GENERATIONS_DIR)
281}
282
283pub fn package_publication_lock_path(project_root: &Path) -> PathBuf {
284    package_state_dir(project_root).join(PACKAGE_PUBLICATION_LOCK_FILE)
285}
286
287pub fn package_current_path(project_root: &Path) -> PathBuf {
288    package_state_dir(project_root).join(PACKAGE_CURRENT_FILE)
289}
290
291pub fn generation_root(project_root: &Path, generation: &str) -> PathBuf {
292    package_generations_dir(project_root).join(generation)
293}
294
295pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
296    if let Some(parent) = path.parent() {
297        fs::create_dir_all(parent)
298            .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
299    }
300    OpenOptions::new()
301        .read(true)
302        .write(true)
303        .create(true)
304        .truncate(false)
305        .open(path)
306        .map_err(|error| PackageSnapshotError::io("open", path, error))
307}
308
309fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
310    OpenOptions::new()
311        .read(true)
312        .write(true)
313        .open(path)
314        .map_err(|error| PackageSnapshotError::io("open", path, error))
315}
316
317fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
318where
319    T: for<'de> Deserialize<'de>,
320{
321    let source =
322        fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
323    toml::from_str(&source).map_err(|error| {
324        PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
325    })
326}
327
328fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
329    if version == PACKAGE_GENERATION_SCHEMA_VERSION {
330        Ok(())
331    } else {
332        Err(PackageSnapshotError::Invalid(format!(
333            "unsupported {} schema version {} (expected {})",
334            path.display(),
335            version,
336            PACKAGE_GENERATION_SCHEMA_VERSION
337        )))
338    }
339}
340
341pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
342    let path = Path::new(generation);
343    let mut components = path.components();
344    let Some(Component::Normal(component)) = components.next() else {
345        return Err(invalid_generation_id(generation));
346    };
347    if components.next().is_some()
348        || component.to_str() != Some(generation)
349        || generation.starts_with('.')
350        || !generation
351            .bytes()
352            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
353    {
354        return Err(invalid_generation_id(generation));
355    }
356    Ok(())
357}
358
359fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
360    PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
361}
362
363fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
364    let Some(hex) = digest.strip_prefix("sha256:") else {
365        return Err(PackageSnapshotError::Invalid(format!(
366            "invalid package lock digest {digest:?}"
367        )));
368    };
369    if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
370        return Err(PackageSnapshotError::Invalid(format!(
371            "invalid package lock digest {digest:?}"
372        )));
373    }
374    Ok(())
375}
376
377#[derive(Deserialize)]
378struct PublishedLock {
379    #[serde(default, rename = "package")]
380    packages: Vec<PublishedLockEntry>,
381}
382
383#[derive(Deserialize)]
384struct PublishedLockEntry {
385    name: String,
386}
387
388fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
389    let source = std::str::from_utf8(bytes).map_err(|error| {
390        PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
391    })?;
392    let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
393        PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
394    })?;
395    let mut names = std::collections::BTreeSet::new();
396    for entry in lock.packages {
397        if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
398            return Err(PackageSnapshotError::Invalid(format!(
399                "{} contains an invalid or duplicate package name {:?}",
400                path.display(),
401                entry.name
402            )));
403        }
404    }
405    Ok(names.into_iter().collect())
406}
407
408/// Return whether `name` is a safe single-component package import alias.
409pub fn is_valid_package_name(name: &str) -> bool {
410    !name.is_empty()
411        && name != "."
412        && name != ".."
413        && name
414            .bytes()
415            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
416}
417
418fn encode_hex(bytes: &[u8]) -> String {
419    let mut encoded = String::with_capacity(bytes.len() * 2);
420    for byte in bytes {
421        use std::fmt::Write as _;
422        let _ = write!(encoded, "{byte:02x}");
423    }
424    encoded
425}
426
427pub fn package_lock_digest(bytes: &[u8]) -> String {
428    format!("sha256:{}", encode_hex(&Sha256::digest(bytes)))
429}
430
431fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
432    let canonical = path
433        .canonicalize()
434        .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
435    if canonical == root || canonical.starts_with(root) {
436        Ok(canonical)
437    } else {
438        Err(PackageSnapshotError::Invalid(format!(
439            "package generation directory escapes {}: {}",
440            root.display(),
441            path.display()
442        )))
443    }
444}
445
446fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
447    let metadata = fs::symlink_metadata(path)
448        .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
449    if metadata.file_type().is_file() {
450        return Ok(());
451    }
452    Err(PackageSnapshotError::Invalid(format!(
453        "package generation file is not a regular file: {}",
454        path.display()
455    )))
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461    use std::sync::{Arc, Barrier};
462
463    fn publish_fixture(root: &Path, generation: &str, body: &str) {
464        let generation_root = generation_root(root, generation);
465        fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
466        fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
467        fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
468        let digest = package_lock_digest(body.as_bytes());
469        let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
470        fs::write(
471            generation_root.join(GENERATION_MANIFEST_FILE),
472            toml::to_string_pretty(&manifest).unwrap(),
473        )
474        .unwrap();
475        let pointer = PackageGenerationPointer::new(generation).unwrap();
476        fs::create_dir_all(package_state_dir(root)).unwrap();
477        fs::write(
478            package_current_path(root),
479            toml::to_string_pretty(&pointer).unwrap(),
480        )
481        .unwrap();
482        File::create(package_publication_lock_path(root)).unwrap();
483    }
484
485    #[test]
486    fn snapshot_holds_generation_lease_until_drop() {
487        let temp = tempfile::tempdir().unwrap();
488        publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
489
490        let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
491        let lease =
492            open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
493                .unwrap();
494        assert!(FileExt::try_lock_exclusive(&lease).is_err());
495
496        drop(snapshot);
497        FileExt::try_lock_exclusive(&lease).unwrap();
498    }
499
500    #[test]
501    fn reader_selects_generation_published_before_publication_unlock() {
502        let temp = tempfile::tempdir().unwrap();
503        publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
504        let root = temp.path().to_path_buf();
505        let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
506        FileExt::lock_exclusive(&publication).unwrap();
507
508        let started = Arc::new(Barrier::new(2));
509        let reader_started = Arc::clone(&started);
510        let reader_root = root.clone();
511        let reader = std::thread::spawn(move || {
512            reader_started.wait();
513            PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
514        });
515        started.wait();
516
517        publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
518        FileExt::unlock(&publication).unwrap();
519
520        let snapshot = reader.join().unwrap();
521        assert_eq!(snapshot.generation(), "generation_b");
522        assert_eq!(
523            fs::read_to_string(snapshot.lock_path()).unwrap(),
524            "version = 4\n# lock b\n"
525        );
526    }
527
528    #[test]
529    fn malformed_pointer_cannot_escape_generation_root() {
530        let temp = tempfile::tempdir().unwrap();
531        fs::create_dir_all(package_state_dir(temp.path())).unwrap();
532        fs::write(
533            package_current_path(temp.path()),
534            "schema_version = 1\ngeneration = \"../outside\"\n",
535        )
536        .unwrap();
537        File::create(package_publication_lock_path(temp.path())).unwrap();
538
539        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
540        assert!(
541            error.to_string().contains("invalid package generation id"),
542            "unexpected error: {error}"
543        );
544    }
545
546    #[test]
547    fn lock_package_name_cannot_escape_packages_root() {
548        let temp = tempfile::tempdir().unwrap();
549        publish_fixture(
550            temp.path(),
551            "generation_a",
552            "version = 4\n\n[[package]]\nname = \"../outside\"\n",
553        );
554
555        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
556        assert!(
557            error
558                .to_string()
559                .contains("invalid or duplicate package name"),
560            "unexpected error: {error}"
561        );
562    }
563
564    #[cfg(unix)]
565    #[test]
566    fn symlinked_generation_root_cannot_escape_generations_directory() {
567        let temp = tempfile::tempdir().unwrap();
568        publish_fixture(temp.path(), "generation_a", "version = 4\n");
569        let generation = generation_root(temp.path(), "generation_a");
570        let outside = temp.path().join("outside-generation");
571        fs::rename(&generation, &outside).unwrap();
572        std::os::unix::fs::symlink(&outside, &generation).unwrap();
573
574        let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
575        assert!(
576            error.to_string().contains("escapes"),
577            "unexpected error: {error}"
578        );
579    }
580}