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
10pub const PACKAGE_STATE_DIR: &str = ".harn";
11pub const PACKAGE_CURRENT_FILE: &str = "package-current.toml";
12pub const PACKAGE_GENERATIONS_DIR: &str = "package-generations";
13pub const PACKAGE_PUBLICATION_LOCK_FILE: &str = "package-generation.lock";
14pub const PACKAGE_INSTALL_LOCK_FILE: &str = "package-install.lock";
15pub const GENERATION_MANIFEST_FILE: &str = "generation.toml";
16pub const GENERATION_LOCK_FILE: &str = "harn.lock";
17pub const GENERATION_LEASE_FILE: &str = "lease.lock";
18pub const GENERATION_PACKAGES_DIR: &str = "packages";
19pub const PACKAGE_GENERATION_SCHEMA_VERSION: u32 = 1;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct PackageGenerationPointer {
24 pub schema_version: u32,
25 pub generation: String,
26}
27
28impl PackageGenerationPointer {
29 pub fn new(generation: impl Into<String>) -> Result<Self, PackageSnapshotError> {
30 let generation = generation.into();
31 validate_generation_id(&generation)?;
32 Ok(Self {
33 schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
34 generation,
35 })
36 }
37
38 pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
39 validate_schema_version(self.schema_version, path)?;
40 validate_generation_id(&self.generation)
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct PackageGenerationManifest {
47 pub schema_version: u32,
48 pub generation: String,
49 pub lock_digest: String,
50}
51
52impl PackageGenerationManifest {
53 pub fn new(
54 generation: impl Into<String>,
55 lock_digest: impl Into<String>,
56 ) -> Result<Self, PackageSnapshotError> {
57 let generation = generation.into();
58 validate_generation_id(&generation)?;
59 let lock_digest = lock_digest.into();
60 validate_lock_digest(&lock_digest)?;
61 Ok(Self {
62 schema_version: PACKAGE_GENERATION_SCHEMA_VERSION,
63 generation,
64 lock_digest,
65 })
66 }
67
68 pub fn validate(&self, path: &Path) -> Result<(), PackageSnapshotError> {
69 validate_schema_version(self.schema_version, path)?;
70 validate_generation_id(&self.generation)?;
71 validate_lock_digest(&self.lock_digest)
72 }
73}
74
75#[derive(Debug)]
76pub struct PackageSnapshot {
77 project_root: PathBuf,
78 generation: String,
79 generation_root: PathBuf,
80 packages_root: PathBuf,
81 lock_path: PathBuf,
82 lock_digest: String,
83 package_names: Vec<String>,
84 _lease: File,
85}
86
87impl PackageSnapshot {
88 pub fn acquire(project_root: &Path) -> Result<Option<Self>, PackageSnapshotError> {
93 let project_root = project_root
94 .canonicalize()
95 .map_err(|error| PackageSnapshotError::io("canonicalize", project_root, error))?;
96 let state_path = project_root.join(PACKAGE_STATE_DIR);
97 if !state_path.is_dir() {
98 return Ok(None);
99 }
100 let state_dir = canonical_directory_within(&project_root, &state_path)?;
101 let pointer_path = state_dir.join(PACKAGE_CURRENT_FILE);
102 let publication_lock_path = state_dir.join(PACKAGE_PUBLICATION_LOCK_FILE);
103 if !publication_lock_path.exists() && !pointer_path.exists() {
104 return Ok(None);
105 }
106 require_regular_file(&publication_lock_path)?;
107 let publication_lock = open_existing_lock_file(&publication_lock_path)?;
108 FileExt::lock_shared(&publication_lock)
109 .map_err(|error| PackageSnapshotError::io("lock", &publication_lock_path, error))?;
110
111 if !pointer_path.is_file() {
112 return Ok(None);
113 }
114 require_regular_file(&pointer_path)?;
115
116 let pointer = read_toml::<PackageGenerationPointer>(&pointer_path)?;
117 pointer.validate(&pointer_path)?;
118 let generations_dir =
119 canonical_directory_within(&state_dir, &state_dir.join(PACKAGE_GENERATIONS_DIR))?;
120 let generation_root = canonical_directory_within(
121 &generations_dir,
122 &generations_dir.join(&pointer.generation),
123 )?;
124 let lease_path = generation_root.join(GENERATION_LEASE_FILE);
125 require_regular_file(&lease_path)?;
126 let lease = open_existing_lock_file(&lease_path)?;
127 FileExt::lock_shared(&lease)
128 .map_err(|error| PackageSnapshotError::io("lock", &lease_path, error))?;
129
130 FileExt::unlock(&publication_lock)
133 .map_err(|error| PackageSnapshotError::io("unlock", &publication_lock_path, error))?;
134
135 let manifest_path = generation_root.join(GENERATION_MANIFEST_FILE);
136 require_regular_file(&manifest_path)?;
137 let manifest = read_toml::<PackageGenerationManifest>(&manifest_path)?;
138 manifest.validate(&manifest_path)?;
139 if manifest.generation != pointer.generation {
140 return Err(PackageSnapshotError::Invalid(format!(
141 "{} names generation {:?}, expected {:?}",
142 manifest_path.display(),
143 manifest.generation,
144 pointer.generation
145 )));
146 }
147 let packages_root = canonical_directory_within(
148 &generation_root,
149 &generation_root.join(GENERATION_PACKAGES_DIR),
150 )?;
151 let lock_path = generation_root.join(GENERATION_LOCK_FILE);
152 require_regular_file(&lock_path)?;
153 let lock_bytes = fs::read(&lock_path)
154 .map_err(|error| PackageSnapshotError::io("read", &lock_path, error))?;
155 let actual_lock_digest = package_lock_digest(&lock_bytes);
156 if actual_lock_digest != manifest.lock_digest {
157 return Err(PackageSnapshotError::Invalid(format!(
158 "{} digest mismatch: generation manifest records {}, actual {}",
159 lock_path.display(),
160 manifest.lock_digest,
161 actual_lock_digest
162 )));
163 }
164 let package_names = parse_package_names(&lock_path, &lock_bytes)?;
165
166 Ok(Some(Self {
167 project_root,
168 generation: pointer.generation,
169 generation_root,
170 packages_root,
171 lock_path,
172 lock_digest: manifest.lock_digest,
173 package_names,
174 _lease: lease,
175 }))
176 }
177
178 pub fn acquire_nearest(anchor: &Path) -> Result<Option<Self>, PackageSnapshotError> {
179 let mut cursor = if anchor.is_dir() {
180 Some(anchor)
181 } else {
182 anchor.parent()
183 };
184 while let Some(dir) = cursor {
185 if dir
186 .join(PACKAGE_STATE_DIR)
187 .join(PACKAGE_CURRENT_FILE)
188 .is_file()
189 {
190 return Self::acquire(dir);
191 }
192 if dir.join(".git").exists() {
193 break;
194 }
195 cursor = dir.parent();
196 }
197 Ok(None)
198 }
199
200 pub fn project_root(&self) -> &Path {
201 &self.project_root
202 }
203
204 pub fn generation(&self) -> &str {
205 &self.generation
206 }
207
208 pub fn generation_root(&self) -> &Path {
209 &self.generation_root
210 }
211
212 pub fn packages_root(&self) -> &Path {
213 &self.packages_root
214 }
215
216 pub fn lock_path(&self) -> &Path {
217 &self.lock_path
218 }
219
220 pub fn lock_digest(&self) -> &str {
221 &self.lock_digest
222 }
223
224 pub fn package_names(&self) -> &[String] {
225 &self.package_names
226 }
227}
228
229#[derive(Debug)]
230pub enum PackageSnapshotError {
231 Io {
232 operation: &'static str,
233 path: PathBuf,
234 source: io::Error,
235 },
236 Invalid(String),
237}
238
239impl PackageSnapshotError {
240 fn io(operation: &'static str, path: &Path, source: io::Error) -> Self {
241 Self::Io {
242 operation,
243 path: path.to_path_buf(),
244 source,
245 }
246 }
247}
248
249impl fmt::Display for PackageSnapshotError {
250 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
251 match self {
252 Self::Io {
253 operation,
254 path,
255 source,
256 } => write!(
257 formatter,
258 "failed to {operation} {}: {source}",
259 path.display()
260 ),
261 Self::Invalid(message) => formatter.write_str(message),
262 }
263 }
264}
265
266impl std::error::Error for PackageSnapshotError {
267 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
268 match self {
269 Self::Io { source, .. } => Some(source),
270 Self::Invalid(_) => None,
271 }
272 }
273}
274
275pub fn package_state_dir(project_root: &Path) -> PathBuf {
276 project_root.join(PACKAGE_STATE_DIR)
277}
278
279pub fn package_generations_dir(project_root: &Path) -> PathBuf {
280 package_state_dir(project_root).join(PACKAGE_GENERATIONS_DIR)
281}
282
283pub fn package_publication_lock_path(project_root: &Path) -> PathBuf {
284 package_state_dir(project_root).join(PACKAGE_PUBLICATION_LOCK_FILE)
285}
286
287pub fn package_current_path(project_root: &Path) -> PathBuf {
288 package_state_dir(project_root).join(PACKAGE_CURRENT_FILE)
289}
290
291pub fn generation_root(project_root: &Path, generation: &str) -> PathBuf {
292 package_generations_dir(project_root).join(generation)
293}
294
295pub fn open_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
296 if let Some(parent) = path.parent() {
297 fs::create_dir_all(parent)
298 .map_err(|error| PackageSnapshotError::io("create", parent, error))?;
299 }
300 OpenOptions::new()
301 .read(true)
302 .write(true)
303 .create(true)
304 .truncate(false)
305 .open(path)
306 .map_err(|error| PackageSnapshotError::io("open", path, error))
307}
308
309fn open_existing_lock_file(path: &Path) -> Result<File, PackageSnapshotError> {
310 OpenOptions::new()
311 .read(true)
312 .write(true)
313 .open(path)
314 .map_err(|error| PackageSnapshotError::io("open", path, error))
315}
316
317fn read_toml<T>(path: &Path) -> Result<T, PackageSnapshotError>
318where
319 T: for<'de> Deserialize<'de>,
320{
321 let source =
322 fs::read_to_string(path).map_err(|error| PackageSnapshotError::io("read", path, error))?;
323 toml::from_str(&source).map_err(|error| {
324 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
325 })
326}
327
328fn validate_schema_version(version: u32, path: &Path) -> Result<(), PackageSnapshotError> {
329 if version == PACKAGE_GENERATION_SCHEMA_VERSION {
330 Ok(())
331 } else {
332 Err(PackageSnapshotError::Invalid(format!(
333 "unsupported {} schema version {} (expected {})",
334 path.display(),
335 version,
336 PACKAGE_GENERATION_SCHEMA_VERSION
337 )))
338 }
339}
340
341pub fn validate_generation_id(generation: &str) -> Result<(), PackageSnapshotError> {
342 let path = Path::new(generation);
343 let mut components = path.components();
344 let Some(Component::Normal(component)) = components.next() else {
345 return Err(invalid_generation_id(generation));
346 };
347 if components.next().is_some()
348 || component.to_str() != Some(generation)
349 || generation.starts_with('.')
350 || !generation
351 .bytes()
352 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
353 {
354 return Err(invalid_generation_id(generation));
355 }
356 Ok(())
357}
358
359fn invalid_generation_id(generation: &str) -> PackageSnapshotError {
360 PackageSnapshotError::Invalid(format!("invalid package generation id {generation:?}"))
361}
362
363fn validate_lock_digest(digest: &str) -> Result<(), PackageSnapshotError> {
364 let Some(hex) = digest.strip_prefix("sha256:") else {
365 return Err(PackageSnapshotError::Invalid(format!(
366 "invalid package lock digest {digest:?}"
367 )));
368 };
369 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
370 return Err(PackageSnapshotError::Invalid(format!(
371 "invalid package lock digest {digest:?}"
372 )));
373 }
374 Ok(())
375}
376
377#[derive(Deserialize)]
378struct PublishedLock {
379 #[serde(default, rename = "package")]
380 packages: Vec<PublishedLockEntry>,
381}
382
383#[derive(Deserialize)]
384struct PublishedLockEntry {
385 name: String,
386}
387
388fn parse_package_names(path: &Path, bytes: &[u8]) -> Result<Vec<String>, PackageSnapshotError> {
389 let source = std::str::from_utf8(bytes).map_err(|error| {
390 PackageSnapshotError::Invalid(format!("{} is not UTF-8: {error}", path.display()))
391 })?;
392 let lock = toml::from_str::<PublishedLock>(source).map_err(|error| {
393 PackageSnapshotError::Invalid(format!("failed to parse {}: {error}", path.display()))
394 })?;
395 let mut names = std::collections::BTreeSet::new();
396 for entry in lock.packages {
397 if !is_valid_package_name(&entry.name) || !names.insert(entry.name.clone()) {
398 return Err(PackageSnapshotError::Invalid(format!(
399 "{} contains an invalid or duplicate package name {:?}",
400 path.display(),
401 entry.name
402 )));
403 }
404 }
405 Ok(names.into_iter().collect())
406}
407
408pub fn is_valid_package_name(name: &str) -> bool {
410 !name.is_empty()
411 && name != "."
412 && name != ".."
413 && name
414 .bytes()
415 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
416}
417
418fn encode_hex(bytes: &[u8]) -> String {
419 let mut encoded = String::with_capacity(bytes.len() * 2);
420 for byte in bytes {
421 use std::fmt::Write as _;
422 let _ = write!(encoded, "{byte:02x}");
423 }
424 encoded
425}
426
427pub fn package_lock_digest(bytes: &[u8]) -> String {
428 format!("sha256:{}", encode_hex(&Sha256::digest(bytes)))
429}
430
431fn canonical_directory_within(root: &Path, path: &Path) -> Result<PathBuf, PackageSnapshotError> {
432 let canonical = path
433 .canonicalize()
434 .map_err(|error| PackageSnapshotError::io("canonicalize", path, error))?;
435 if canonical == root || canonical.starts_with(root) {
436 Ok(canonical)
437 } else {
438 Err(PackageSnapshotError::Invalid(format!(
439 "package generation directory escapes {}: {}",
440 root.display(),
441 path.display()
442 )))
443 }
444}
445
446fn require_regular_file(path: &Path) -> Result<(), PackageSnapshotError> {
447 let metadata = fs::symlink_metadata(path)
448 .map_err(|error| PackageSnapshotError::io("stat", path, error))?;
449 if metadata.file_type().is_file() {
450 return Ok(());
451 }
452 Err(PackageSnapshotError::Invalid(format!(
453 "package generation file is not a regular file: {}",
454 path.display()
455 )))
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461 use std::sync::{Arc, Barrier};
462
463 fn publish_fixture(root: &Path, generation: &str, body: &str) {
464 let generation_root = generation_root(root, generation);
465 fs::create_dir_all(generation_root.join(GENERATION_PACKAGES_DIR)).unwrap();
466 fs::write(generation_root.join(GENERATION_LOCK_FILE), body).unwrap();
467 fs::write(generation_root.join(GENERATION_LEASE_FILE), []).unwrap();
468 let digest = package_lock_digest(body.as_bytes());
469 let manifest = PackageGenerationManifest::new(generation, digest).unwrap();
470 fs::write(
471 generation_root.join(GENERATION_MANIFEST_FILE),
472 toml::to_string_pretty(&manifest).unwrap(),
473 )
474 .unwrap();
475 let pointer = PackageGenerationPointer::new(generation).unwrap();
476 fs::create_dir_all(package_state_dir(root)).unwrap();
477 fs::write(
478 package_current_path(root),
479 toml::to_string_pretty(&pointer).unwrap(),
480 )
481 .unwrap();
482 File::create(package_publication_lock_path(root)).unwrap();
483 }
484
485 #[test]
486 fn snapshot_holds_generation_lease_until_drop() {
487 let temp = tempfile::tempdir().unwrap();
488 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
489
490 let snapshot = PackageSnapshot::acquire(temp.path()).unwrap().unwrap();
491 let lease =
492 open_existing_lock_file(&snapshot.generation_root().join(GENERATION_LEASE_FILE))
493 .unwrap();
494 assert!(FileExt::try_lock_exclusive(&lease).is_err());
495
496 drop(snapshot);
497 FileExt::try_lock_exclusive(&lease).unwrap();
498 }
499
500 #[test]
501 fn reader_selects_generation_published_before_publication_unlock() {
502 let temp = tempfile::tempdir().unwrap();
503 publish_fixture(temp.path(), "generation_a", "version = 4\n# lock a\n");
504 let root = temp.path().to_path_buf();
505 let publication = open_lock_file(&package_publication_lock_path(&root)).unwrap();
506 FileExt::lock_exclusive(&publication).unwrap();
507
508 let started = Arc::new(Barrier::new(2));
509 let reader_started = Arc::clone(&started);
510 let reader_root = root.clone();
511 let reader = std::thread::spawn(move || {
512 reader_started.wait();
513 PackageSnapshot::acquire(&reader_root).unwrap().unwrap()
514 });
515 started.wait();
516
517 publish_fixture(&root, "generation_b", "version = 4\n# lock b\n");
518 FileExt::unlock(&publication).unwrap();
519
520 let snapshot = reader.join().unwrap();
521 assert_eq!(snapshot.generation(), "generation_b");
522 assert_eq!(
523 fs::read_to_string(snapshot.lock_path()).unwrap(),
524 "version = 4\n# lock b\n"
525 );
526 }
527
528 #[test]
529 fn malformed_pointer_cannot_escape_generation_root() {
530 let temp = tempfile::tempdir().unwrap();
531 fs::create_dir_all(package_state_dir(temp.path())).unwrap();
532 fs::write(
533 package_current_path(temp.path()),
534 "schema_version = 1\ngeneration = \"../outside\"\n",
535 )
536 .unwrap();
537 File::create(package_publication_lock_path(temp.path())).unwrap();
538
539 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
540 assert!(
541 error.to_string().contains("invalid package generation id"),
542 "unexpected error: {error}"
543 );
544 }
545
546 #[test]
547 fn lock_package_name_cannot_escape_packages_root() {
548 let temp = tempfile::tempdir().unwrap();
549 publish_fixture(
550 temp.path(),
551 "generation_a",
552 "version = 4\n\n[[package]]\nname = \"../outside\"\n",
553 );
554
555 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
556 assert!(
557 error
558 .to_string()
559 .contains("invalid or duplicate package name"),
560 "unexpected error: {error}"
561 );
562 }
563
564 #[cfg(unix)]
565 #[test]
566 fn symlinked_generation_root_cannot_escape_generations_directory() {
567 let temp = tempfile::tempdir().unwrap();
568 publish_fixture(temp.path(), "generation_a", "version = 4\n");
569 let generation = generation_root(temp.path(), "generation_a");
570 let outside = temp.path().join("outside-generation");
571 fs::rename(&generation, &outside).unwrap();
572 std::os::unix::fs::symlink(&outside, &generation).unwrap();
573
574 let error = PackageSnapshot::acquire(temp.path()).unwrap_err();
575 assert!(
576 error.to_string().contains("escapes"),
577 "unexpected error: {error}"
578 );
579 }
580}