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#[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
138impl PackageSnapshot {
139 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 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 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 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
494pub 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}