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