1use std::fmt;
2use std::fs::{self, File, OpenOptions};
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use fs2::FileExt;
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[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 pub(crate) fn count_probes<T>(body: impl FnOnce() -> T) -> (T, usize) {
46 let (value, walks, _) = count_walks_and_acquires(body);
47 (value, walks)
48 }
49
50 pub(crate) fn count_walks_and_acquires<T>(body: impl FnOnce() -> T) -> (T, usize, usize) {
51 ROOT_WALK_CALLS.with(|calls| calls.set(0));
52 ACQUIRE_CALLS.with(|calls| calls.set(0));
53 let value = body();
54 (
55 value,
56 ROOT_WALK_CALLS.with(|calls| calls.get()),
57 ACQUIRE_CALLS.with(|calls| calls.get()),
58 )
59 }
60}
61
62pub const PACKAGE_STATE_DIR: &str = ".harn";
63pub const PACKAGE_CURRENT_FILE: &str = "package-current.toml";
64pub const PACKAGE_GENERATIONS_DIR: &str = "package-generations";
65pub const PACKAGE_PUBLICATION_LOCK_FILE: &str = "package-generation.lock";
66pub const PACKAGE_INSTALL_LOCK_FILE: &str = "package-install.lock";
67pub const GENERATION_MANIFEST_FILE: &str = "generation.toml";
68pub const GENERATION_LOCK_FILE: &str = "harn.lock";
69pub const GENERATION_LEASE_FILE: &str = "lease.lock";
70pub const GENERATION_PACKAGES_DIR: &str = "packages";
71pub const PACKAGE_GENERATION_SCHEMA_VERSION: u32 = 1;
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(deny_unknown_fields)]
75pub struct PackageGenerationPointer {
76 pub schema_version: u32,
77 pub generation: String,
78}
79
80impl PackageGenerationPointer {
81 pub fn new(generation: impl Into<String>) -> Result<Self, PackageSnapshotError> {
82 let generation = generation.into();
83 validate_generation_id(&generation)?;
84 Ok(Self {
85 schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
86 generation,
87 })
88 }
89
90 pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
91 validate_schema_version(self.schema_version, path)?;
92 validate_generation_id(&self.generation)
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(deny_unknown_fields)]
98pub struct PackageGenerationManifest {
99 pub schema_version: u32,
100 pub generation: String,
101 pub lock_digest: String,
102}
103
104impl PackageGenerationManifest {
105 pub fn new(
106 generation: impl Into<String>,
107 lock_digest: impl Into<String>,
108 ) -> Result<Self, PackageSnapshotError> {
109 let generation = generation.into();
110 validate_generation_id(&generation)?;
111 let lock_digest = lock_digest.into();
112 validate_lock_digest(&lock_digest)?;
113 Ok(Self {
114 schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
115 generation,
116 lock_digest,
117 })
118 }
119
120 pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
121 validate_schema_version(self.schema_version, path)?;
122 validate_generation_id(&self.generation)?;
123 validate_lock_digest(&self.lock_digest)
124 }
125}
126
127#[derive(Debug)]
128pub struct PackageSnapshot {
129 project_root: PathBuf,
130 generation: String,
131 generation_root: PathBuf,
132 packages_root: PathBuf,
133 lock_path: PathBuf,
134 lock_digest: String,
135 package_names: Vec<String>,
136 _lease: File,
137}
138
139static PROCESS_PACKAGE_SNAPSHOTS: OnceLock<Mutex<Vec<PackageSnapshot>>> = OnceLock::new();
147
148impl PackageSnapshot {
149 pub(crate) fn retain_for_process(self) {
154 let snapshots = PROCESS_PACKAGE_SNAPSHOTS.get_or_init(|| Mutex::new(Vec::new()));
155 let mut snapshots = snapshots
156 .lock()
157 .unwrap_or_else(std::sync::PoisonError::into_inner);
158 if snapshots.iter().any(|snapshot| {
159 snapshot.project_root == self.project_root && snapshot.generation == self.generation
160 }) {
161 return;
162 }
163 snapshots.push(self);
164 }
165
166 pub fn retained_clone(&self) -> Result<Self, PackageSnapshotError> {
168 Ok(Self {
169 project_root: self.project_root.clone(),
170 generation: self.generation.clone(),
171 generation_root: self.generation_root.clone(),
172 packages_root: self.packages_root.clone(),
173 lock_path: self.lock_path.clone(),
174 lock_digest: self.lock_digest.clone(),
175 package_names: self.package_names.clone(),
176 _lease: self._lease.try_clone().map_err(|error| {
177 PackageSnapshotError::io("clone lease", &self.generation_root, error)
178 })?,
179 })
180 }
181
182 pub fn acquire(project_root: &Path) -> Result<Option<Self>, PackageSnapshotError> {
187 #[cfg(test)]
188 probe_counter::record_acquire();
189 let project_root = project_root
190 .canonicalize()
191 .map_err(|error| PackageSnapshotError::io("canonicalize", project_root, error))?;
192 let state_path = project_root.join(PACKAGE_STATE_DIR);
193 if !state_path.is_dir() {
194 return Ok(None);
195 }
196 let state_dir = canonical_directory_within(&project_root, &state_path)?;
197 let pointer_path = state_dir.join(PACKAGE_CURRENT_FILE);
198 let publication_lock_path = state_dir.join(PACKAGE_PUBLICATION_LOCK_FILE);
199 if !publication_lock_path.exists() && !pointer_path.exists() {
200 return Ok(None);
201 }
202 require_regular_file(&publication_lock_path)?;
203 let publication_lock = open_existing_lock_file(&publication_lock_path)?;
204 FileExt::lock_shared(&publication_lock)
205 .map_err(|error| PackageSnapshotError::io("lock", &publication_lock_path, error))?;
206
207 if !pointer_path.is_file() {
208 return Ok(None);
209 }
210 require_regular_file(&pointer_path)?;
211
212 let pointer = read_toml::<PackageGenerationPointer>(&pointer_path)?;
213 pointer.validate(&pointer_path)?;
214 let generations_dir =
215 canonical_directory_within(&state_dir, &state_dir.join(PACKAGE_GENERATIONS_DIR))?;
216 let generation_root = canonical_directory_within(
217 &generations_dir,
218 &generations_dir.join(&pointer.generation),
219 )?;
220 let lease_path = generation_root.join(GENERATION_LEASE_FILE);
221 require_regular_file(&lease_path)?;
222 let lease = open_existing_lock_file(&lease_path)?;
223 FileExt::lock_shared(&lease)
224 .map_err(|error| PackageSnapshotError::io("lock", &lease_path, error))?;
225
226 FileExt::unlock(&publication_lock)
229 .map_err(|error| PackageSnapshotError::io("unlock", &publication_lock_path, error))?;
230
231 let manifest_path = generation_root.join(GENERATION_MANIFEST_FILE);
232 require_regular_file(&manifest_path)?;
233 let manifest = read_toml::<PackageGenerationManifest>(&manifest_path)?;
234 manifest.validate(&manifest_path)?;
235 if manifest.generation != pointer.generation {
236 return Err(PackageSnapshotError::Invalid(format!(
237 "{} names generation {:?}, expected {:?}",
238 manifest_path.display(),
239 manifest.generation,
240 pointer.generation
241 )));
242 }
243 let packages_root = canonical_directory_within(
244 &generation_root,
245 &generation_root.join(GENERATION_PACKAGES_DIR),
246 )?;
247 let lock_path = generation_root.join(GENERATION_LOCK_FILE);
248 require_regular_file(&lock_path)?;
249 let lock_bytes = fs::read(&lock_path)
250 .map_err(|error| PackageSnapshotError::io("read", &lock_path, error))?;
251 let actual_lock_digest = package_lock_digest(&lock_bytes);
252 if actual_lock_digest != manifest.lock_digest {
253 return Err(PackageSnapshotError::Invalid(format!(
254 "{} digest mismatch: generation manifest records {}, actual {}",
255 lock_path.display(),
256 manifest.lock_digest,
257 actual_lock_digest
258 )));
259 }
260 let package_names = parse_package_names(&lock_path, &lock_bytes)?;
261
262 Ok(Some(Self {
263 project_root,
264 generation: pointer.generation,
265 generation_root,
266 packages_root,
267 lock_path,
268 lock_digest: manifest.lock_digest,
269 package_names,
270 _lease: lease,
271 }))
272 }
273
274 pub fn nearest_project_root(anchor: &Path) -> Option<PathBuf> {
283 #[cfg(test)]
284 probe_counter::record_root_walk();
285 let mut cursor = if anchor.is_dir() {
286 Some(anchor)
287 } else {
288 anchor.parent()
289 };
290 while let Some(dir) = cursor {
291 if dir
292 .join(PACKAGE_STATE_DIR)
293 .join(PACKAGE_CURRENT_FILE)
294 .is_file()
295 {
296 return Some(dir.to_path_buf());
297 }
298 if dir.join(".git").exists() {
299 break;
300 }
301 cursor = dir.parent();
302 }
303 None
304 }
305
306 pub fn acquire_nearest(anchor: &Path) -> Result<Option<Self>, PackageSnapshotError> {
307 match Self::nearest_project_root(anchor) {
308 Some(root) => Self::acquire(&root),
309 None => Ok(None),
310 }
311 }
312
313 pub fn project_root(&self) -> &Path {
314 &self.project_root
315 }
316
317 pub fn generation(&self) -> &str {
318 &self.generation
319 }
320
321 pub fn generation_root(&self) -> &Path {
322 &self.generation_root
323 }
324
325 pub fn packages_root(&self) -> &Path {
326 &self.packages_root
327 }
328
329 pub fn lock_path(&self) -> &Path {
330 &self.lock_path
331 }
332
333 pub fn lock_digest(&self) -> &str {
334 &self.lock_digest
335 }
336
337 pub fn package_names(&self) -> &[String] {
338 &self.package_names
339 }
340}
341
342#[derive(Debug)]
343pub enum PackageSnapshotError {
344 Io {
345 operation: &'static str,
346 path: PathBuf,
347 source: io::Error,
348 },
349 Invalid(String),
350}
351
352impl PackageSnapshotError {
353 fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
354 Self::Io {
355 operation,
356 path: path.to_path_buf(),
357 source,
358 }
359 }
360}
361
362impl fmt::Display for PackageSnapshotError {
363 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
364 match self {
365 Self::Io {
366 operation,
367 path,
368 source,
369 } => write!(
370 formatter,
371 "failed to {operation} {}: {source}",
372 path.display()
373 ),
374 Self::Invalid(message) => formatter.write_str(message),
375 }
376 }
377}
378
379impl std::error::Error for PackageSnapshotError {
380 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
381 match self {
382 Self::Io { source, .. } => Some(source),
383 Self::Invalid(_) => None,
384 }
385 }
386}
387
388pub fn package_state_dir(project_root: &Path) -> PathBuf {
389 project_root.join(PACKAGE_STATE_DIR)
390}
391
392pub fn package_generations_dir(project_root: &Path) -> PathBuf {
393 package_state_dir(project_root).join(PACKAGE_GENERATIONS_DIR)
394}
395
396pub fn package_publication_lock_path(project_root: &Path) -> PathBuf {
397 package_state_dir(project_root).join(PACKAGE_PUBLICATION_LOCK_FILE)
398}
399
400pub fn package_current_path(project_root: &Path) -> PathBuf {
401 package_state_dir(project_root).join(PACKAGE_CURRENT_FILE)
402}
403
404pub fn generation_root(project_root: &Path, generation: &str) -> PathBuf {
405 package_generations_dir(project_root).join(generation)
406}
407
408pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
409 if let Some(parent) = path.parent() {
410 fs::create_dir_all(parent)
411 .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
412 }
413 OpenOptions::new()
414 .read(true)
415 .write(true)
416 .create(true)
417 .truncate(false)
418 .open(path)
419 .map_err(|error| PackageSnapshotError::io("open", path, error))
420}
421
422fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
423 OpenOptions::new()
424 .read(true)
425 .write(true)
426 .open(path)
427 .map_err(|error| PackageSnapshotError::io("open", path, error))
428}
429
430fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
431where
432 T: for<'de> Deserialize<'de>,
433{
434 let source =
435 fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
436 toml::from_str(&source).map_err(|error| {
437 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
438 })
439}
440
441fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
442 if version == PACKAGE_GENERATION_SCHEMA_VERSION {
443 Ok(())
444 } else {
445 Err(PackageSnapshotError::Invalid(format!(
446 "unsupported {} schema version {} (expected {})",
447 path.display(),
448 version,
449 PACKAGE_GENERATION_SCHEMA_VERSION
450 )))
451 }
452}
453
454pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
455 let path = Path::new(generation);
456 let mut components = path.components();
457 let Some(Component::Normal(component)) = components.next() else {
458 return Err(invalid_generation_id(generation));
459 };
460 if components.next().is_some()
461 || component.to_str() != Some(generation)
462 || generation.starts_with('.')
463 || !generation
464 .bytes()
465 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
466 {
467 return Err(invalid_generation_id(generation));
468 }
469 Ok(())
470}
471
472fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
473 PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
474}
475
476fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
477 let Some(hex) = digest.strip_prefix("sha256:") else {
478 return Err(PackageSnapshotError::Invalid(format!(
479 "invalid package lock digest {digest:?}"
480 )));
481 };
482 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
483 return Err(PackageSnapshotError::Invalid(format!(
484 "invalid package lock digest {digest:?}"
485 )));
486 }
487 Ok(())
488}
489
490#[derive(Deserialize)]
491struct PublishedLock {
492 #[serde(default, rename = "package")]
493 packages: Vec<PublishedLockEntry>,
494}
495
496#[derive(Deserialize)]
497struct PublishedLockEntry {
498 name: String,
499}
500
501fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
502 let source = std::str::from_utf8(bytes).map_err(|error| {
503 PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
504 })?;
505 let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
506 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
507 })?;
508 let mut names = std::collections::BTreeSet::new();
509 for entry in lock.packages {
510 if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
511 return Err(PackageSnapshotError::Invalid(format!(
512 "{} contains an invalid or duplicate package name {:?}",
513 path.display(),
514 entry.name
515 )));
516 }
517 }
518 Ok(names.into_iter().collect())
519}
520
521pub fn is_valid_package_name(name: &str) -> bool {
523 !name.is_empty()
524 && name != "."
525 && name != ".."
526 && name
527 .bytes()
528 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
529}
530
531fn encode_hex(bytes: &[u8]) -> String {
532 let mut encoded = String::with_capacity(bytes.len() * 2);
533 for byte in bytes {
534 use std::fmt::Write as _;
535 let _ = write!(encoded, "{byte:02x}");
536 }
537 encoded
538}
539
540pub fn package_lock_digest(bytes: &[u8]) -> String {
541 format!("sha256:{}", encode_hex(&Sha256::digest(bytes)))
542}
543
544fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
545 let canonical = path
546 .canonicalize()
547 .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
548 if canonical == root || canonical.starts_with(root) {
549 Ok(canonical)
550 } else {
551 Err(PackageSnapshotError::Invalid(format!(
552 "package generation directory escapes {}: {}",
553 root.display(),
554 path.display()
555 )))
556 }
557}
558
559fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
560 let metadata = fs::symlink_metadata(path)
561 .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
562 if metadata.file_type().is_file() {
563 return Ok(());
564 }
565 Err(PackageSnapshotError::Invalid(format!(
566 "package generation file is not a regular file: {}",
567 path.display()
568 )))
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574 use std::sync::{Arc, Barrier};
575
576 fn publish_fixture(root: &Path, generation: &str, body: &str) {
577 let generation_root = generation_root(root, generation);
578 fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
579 fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
580 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
581 let digest = package_lock_digest(body.as_bytes());
582 let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
583 fs::write(
584 generation_root.join(GENERATION_MANIFEST_FILE),
585 toml::to_string_pretty(&manifest).unwrap(),
586 )
587 .unwrap();
588 let pointer = PackageGenerationPointer::new(generation).unwrap();
589 fs::create_dir_all(package_state_dir(root)).unwrap();
590 fs::write(
591 package_current_path(root),
592 toml::to_string_pretty(&pointer).unwrap(),
593 )
594 .unwrap();
595 File::create(package_publication_lock_path(root)).unwrap();
596 }
597
598 #[test]
599 fn snapshot_holds_generation_lease_until_drop() {
600 let temp = tempfile::tempdir().unwrap();
601 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
602
603 let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
604 let lease =
605 open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
606 .unwrap();
607 assert!(FileExt::try_lock_exclusive(&lease).is_err());
608
609 drop(snapshot);
610 FileExt::try_lock_exclusive(&lease).unwrap();
611 }
612
613 #[test]
614 fn reader_selects_generation_published_before_publication_unlock() {
615 let temp = tempfile::tempdir().unwrap();
616 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
617 let root = temp.path().to_path_buf();
618 let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
619 FileExt::lock_exclusive(&publication).unwrap();
620
621 let started = Arc::new(Barrier::new(2));
622 let reader_started = Arc::clone(&started);
623 let reader_root = root.clone();
624 let reader = std::thread::spawn(move || {
625 reader_started.wait();
626 PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
627 });
628 started.wait();
629
630 publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
631 FileExt::unlock(&publication).unwrap();
632
633 let snapshot = reader.join().unwrap();
634 assert_eq!(snapshot.generation(), "generation_b");
635 assert_eq!(
636 fs::read_to_string(snapshot.lock_path()).unwrap(),
637 "version = 4\n# lock b\n"
638 );
639 }
640
641 #[test]
642 fn malformed_pointer_cannot_escape_generation_root() {
643 let temp = tempfile::tempdir().unwrap();
644 fs::create_dir_all(package_state_dir(temp.path())).unwrap();
645 fs::write(
646 package_current_path(temp.path()),
647 "schema_version = 1\ngeneration = \"../outside\"\n",
648 )
649 .unwrap();
650 File::create(package_publication_lock_path(temp.path())).unwrap();
651
652 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
653 assert!(
654 error.to_string().contains("invalid package generation id"),
655 "unexpected error: {error}"
656 );
657 }
658
659 #[test]
660 fn lock_package_name_cannot_escape_packages_root() {
661 let temp = tempfile::tempdir().unwrap();
662 publish_fixture(
663 temp.path(),
664 "generation_a",
665 "version = 4\n\n[[package]]\nname = \"../outside\"\n",
666 );
667
668 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
669 assert!(
670 error
671 .to_string()
672 .contains("invalid or duplicate package name"),
673 "unexpected error: {error}"
674 );
675 }
676
677 #[cfg(unix)]
678 #[test]
679 fn symlinked_generation_root_cannot_escape_generations_directory() {
680 let temp = tempfile::tempdir().unwrap();
681 publish_fixture(temp.path(), "generation_a", "version = 4\n");
682 let generation = generation_root(temp.path(), "generation_a");
683 let outside = temp.path().join("outside-generation");
684 fs::rename(&generation, &outside).unwrap();
685 std::os::unix::fs::symlink(&outside, &generation).unwrap();
686
687 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
688 assert!(
689 error.to_string().contains("escapes"),
690 "unexpected error: {error}"
691 );
692 }
693}