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#[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
73const 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
145static PROCESS_PACKAGE_SNAPSHOTS: OnceLock<Mutex<Vec<PackageSnapshot>>> = OnceLock::new();
153
154impl PackageSnapshot {
155 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 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 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 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 pub fn nearest_project_root(anchor: &Path) -> Option<PathBuf> {
302 #[cfg(test)]
303 probe_counter::record_root_walk();
304 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
414pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
415 if let Some(parent) = path.parent() {
416 fs::create_dir_all(parent)
417 .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
418 }
419 OpenOptions::new()
420 .read(true)
421 .write(true)
422 .create(true)
423 .truncate(false)
424 .open(path)
425 .map_err(|error| PackageSnapshotError::io("open", path, error))
426}
427
428fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
429 OpenOptions::new()
430 .read(true)
431 .write(true)
432 .open(path)
433 .map_err(|error| PackageSnapshotError::io("open", path, error))
434}
435
436fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
437where
438 T: for<'de> Deserialize<'de>,
439{
440 let source =
441 fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
442 toml::from_str(&source).map_err(|error| {
443 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
444 })
445}
446
447fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
448 if version == PACKAGE_GENERATION_SCHEMA_VERSION {
449 Ok(())
450 } else {
451 Err(PackageSnapshotError::Invalid(format!(
452 "unsupported {} schema version {} (expected {})",
453 path.display(),
454 version,
455 PACKAGE_GENERATION_SCHEMA_VERSION
456 )))
457 }
458}
459
460pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
461 let path = Path::new(generation);
462 let mut components = path.components();
463 let Some(Component::Normal(component)) = components.next() else {
464 return Err(invalid_generation_id(generation));
465 };
466 if components.next().is_some()
467 || component.to_str() != Some(generation)
468 || generation.starts_with('.')
469 || !generation
470 .bytes()
471 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
472 {
473 return Err(invalid_generation_id(generation));
474 }
475 Ok(())
476}
477
478fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
479 PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
480}
481
482fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
483 let Some(hex) = digest.strip_prefix("sha256:") else {
484 return Err(PackageSnapshotError::Invalid(format!(
485 "invalid package lock digest {digest:?}"
486 )));
487 };
488 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
489 return Err(PackageSnapshotError::Invalid(format!(
490 "invalid package lock digest {digest:?}"
491 )));
492 }
493 Ok(())
494}
495
496#[derive(Deserialize)]
497struct PublishedLock {
498 #[serde(default, rename = "package")]
499 packages: Vec<PublishedLockEntry>,
500}
501
502#[derive(Deserialize)]
503struct PublishedLockEntry {
504 name: String,
505}
506
507fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
508 let source = std::str::from_utf8(bytes).map_err(|error| {
509 PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
510 })?;
511 let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
512 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
513 })?;
514 let mut names = std::collections::BTreeSet::new();
515 for entry in lock.packages {
516 if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
517 return Err(PackageSnapshotError::Invalid(format!(
518 "{} contains an invalid or duplicate package name {:?}",
519 path.display(),
520 entry.name
521 )));
522 }
523 }
524 Ok(names.into_iter().collect())
525}
526
527pub fn is_valid_package_name(name: &str) -> bool {
529 !name.is_empty()
530 && name != "."
531 && name != ".."
532 && name
533 .bytes()
534 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
535}
536
537fn encode_hex(bytes: &[u8]) -> String {
538 let mut encoded = String::with_capacity(bytes.len() * 2);
539 for byte in bytes {
540 use std::fmt::Write as _;
541 let _ = write!(encoded, "{byte:02x}");
542 }
543 encoded
544}
545
546pub fn package_lock_digest(bytes: &[u8]) -> String {
547 format!("sha256:{}", encode_hex(&Sha256::digest(bytes)))
548}
549
550fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
551 let canonical = path
552 .canonicalize()
553 .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
554 if canonical == root || canonical.starts_with(root) {
555 Ok(canonical)
556 } else {
557 Err(PackageSnapshotError::Invalid(format!(
558 "package generation directory escapes {}: {}",
559 root.display(),
560 path.display()
561 )))
562 }
563}
564
565fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
566 let metadata = fs::symlink_metadata(path)
567 .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
568 if metadata.file_type().is_file() {
569 return Ok(());
570 }
571 Err(PackageSnapshotError::Invalid(format!(
572 "package generation file is not a regular file: {}",
573 path.display()
574 )))
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use std::sync::{Arc, Barrier};
581
582 fn publish_fixture(root: &Path, generation: &str, body: &str) {
583 let generation_root = generation_root(root, generation);
584 fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
585 fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
586 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
587 let digest = package_lock_digest(body.as_bytes());
588 let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
589 fs::write(
590 generation_root.join(GENERATION_MANIFEST_FILE),
591 toml::to_string_pretty(&manifest).unwrap(),
592 )
593 .unwrap();
594 let pointer = PackageGenerationPointer::new(generation).unwrap();
595 fs::create_dir_all(package_state_dir(root)).unwrap();
596 fs::write(
597 package_current_path(root),
598 toml::to_string_pretty(&pointer).unwrap(),
599 )
600 .unwrap();
601 File::create(package_publication_lock_path(root)).unwrap();
602 }
603
604 #[test]
605 fn snapshot_holds_generation_lease_until_drop() {
606 let temp = tempfile::tempdir().unwrap();
607 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
608
609 let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
610 let lease =
611 open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
612 .unwrap();
613 assert!(lease.try_lock().is_err());
614
615 drop(snapshot);
616 lease.try_lock().unwrap();
617 }
618
619 #[test]
620 fn reader_selects_generation_published_before_publication_unlock() {
621 let temp = tempfile::tempdir().unwrap();
622 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
623 let root = temp.path().to_path_buf();
624 let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
625 publication.lock().unwrap();
626
627 let started = Arc::new(Barrier::new(2));
628 let reader_started = Arc::clone(&started);
629 let reader_root = root.clone();
630 let reader = std::thread::spawn(move || {
631 reader_started.wait();
632 PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
633 });
634 started.wait();
635
636 publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
637 publication.unlock().unwrap();
638
639 let snapshot = reader.join().unwrap();
640 assert_eq!(snapshot.generation(), "generation_b");
641 assert_eq!(
642 fs::read_to_string(snapshot.lock_path()).unwrap(),
643 "version = 4\n# lock b\n"
644 );
645 }
646
647 #[test]
648 fn malformed_pointer_cannot_escape_generation_root() {
649 let temp = tempfile::tempdir().unwrap();
650 fs::create_dir_all(package_state_dir(temp.path())).unwrap();
651 fs::write(
652 package_current_path(temp.path()),
653 "schema_version = 1\ngeneration = \"../outside\"\n",
654 )
655 .unwrap();
656 File::create(package_publication_lock_path(temp.path())).unwrap();
657
658 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
659 assert!(
660 error.to_string().contains("invalid package generation id"),
661 "unexpected error: {error}"
662 );
663 }
664
665 #[test]
666 fn lock_package_name_cannot_escape_packages_root() {
667 let temp = tempfile::tempdir().unwrap();
668 publish_fixture(
669 temp.path(),
670 "generation_a",
671 "version = 4\n\n[[package]]\nname = \"../outside\"\n",
672 );
673
674 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
675 assert!(
676 error
677 .to_string()
678 .contains("invalid or duplicate package name"),
679 "unexpected error: {error}"
680 );
681 }
682
683 #[cfg(unix)]
684 #[test]
685 fn symlinked_generation_root_cannot_escape_generations_directory() {
686 let temp = tempfile::tempdir().unwrap();
687 publish_fixture(temp.path(), "generation_a", "version = 4\n");
688 let generation = generation_root(temp.path(), "generation_a");
689 let outside = temp.path().join("outside-generation");
690 fs::rename(&generation, &outside).unwrap();
691 std::os::unix::fs::symlink(&outside, &generation).unwrap();
692
693 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
694 assert!(
695 error.to_string().contains("escapes"),
696 "unexpected error: {error}"
697 );
698 }
699}