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