eggup_core/lock.rs
1use std::fs::{self, File, OpenOptions};
2use std::io::Write;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use crate::domain::{ProductId, ReleaseId};
7use crate::error::{Error, RecoveryError, RecoveryResult, Result};
8
9static NEXT_LOCK_NONCE: AtomicU64 = AtomicU64::new(0);
10
11const MAX_LOCK_BYTES: u64 = 4096;
12
13/// The observable state of an installation-domain lock record.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum LockStatus {
16 /// No lock record exists.
17 Available,
18 /// A lock record exists; its bounded contents are returned for diagnosis.
19 /// Eggup never auto-removes this record.
20 Held {
21 /// The lock path that is held.
22 lock: PathBuf,
23 /// Bounded lock-record contents, when readable.
24 contents: Option<String>,
25 },
26 /// A lock record exists but cannot be parsed as UTF-8 or is oversized.
27 /// Still never auto-removed.
28 Malformed {
29 /// The lock path that is malformed.
30 lock: PathBuf,
31 },
32}
33
34/// A bounded, read-only observation of one exact lock record.
35///
36/// This is the only evidence Core can prove about a record: the path, its exact
37/// bounded bytes, and whatever the known record format yields. There is no
38/// process liveness, age, or executable fact here, and none is inferred from
39/// the pathname.
40///
41/// Field values are exposed through accessors rather than public fields so the
42/// record format can stay an implementation detail.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct LockObservation {
45 path: PathBuf,
46 record: String,
47 pid: Option<u32>,
48 nonce: Option<u64>,
49 product: Option<String>,
50 release: Option<String>,
51}
52
53impl LockObservation {
54 /// Returns the observed lock record path.
55 pub fn path(&self) -> &Path {
56 &self.path
57 }
58
59 /// Returns the exact bounded record identity that was observed.
60 ///
61 /// Recovery is authorized against this value, not against the pathname: if
62 /// the object at the path changes, the authorization no longer applies.
63 pub fn record(&self) -> &str {
64 &self.record
65 }
66
67 /// Returns the recorded process id, when the known format supplies one.
68 ///
69 /// This is a fact about the record, not about the current process table.
70 /// Core never uses it as proof of staleness.
71 pub fn pid(&self) -> Option<u32> {
72 self.pid
73 }
74
75 /// Returns the recorded per-process nonce, when the known format supplies one.
76 pub fn nonce(&self) -> Option<u64> {
77 self.nonce
78 }
79
80 /// Returns the recorded product identity, when the known format supplies one.
81 pub fn product(&self) -> Option<&str> {
82 self.product.as_deref()
83 }
84
85 /// Returns the recorded release identity, when the known format supplies one.
86 pub fn release(&self) -> Option<&str> {
87 self.release.as_deref()
88 }
89
90 /// Returns whether the record matched the known bounded format.
91 ///
92 /// A record from an older or unknown format is still observable and still
93 /// byte-identifiable, but it carries no parsed evidence, so a caller that
94 /// needs fields cannot prove staleness from it.
95 pub fn format_known(&self) -> bool {
96 self.pid.is_some() && self.nonce.is_some()
97 }
98
99 fn parse(path: PathBuf, record: String) -> Self {
100 let mut parsed = Self {
101 path,
102 record,
103 pid: None,
104 nonce: None,
105 product: None,
106 release: None,
107 };
108 parsed.parse_fields();
109 parsed
110 }
111
112 /// Conservative parsing: every field must be present and well-formed for
113 /// any of them to be reported. Partial parses are treated as unknown.
114 fn parse_fields(&mut self) {
115 let Some(body) = self.record.strip_suffix('\n') else {
116 return;
117 };
118 let mut pid = None;
119 let mut nonce = None;
120 let mut product = None;
121 let mut release = None;
122 for field in body.split(' ') {
123 let Some((key, value)) = field.split_once('=') else {
124 return;
125 };
126 if value.is_empty() {
127 return;
128 }
129 match key {
130 "pid" => match value.parse::<u32>() {
131 Ok(value) => pid = Some(value),
132 Err(_) => return,
133 },
134 "nonce" => match value.parse::<u64>() {
135 Ok(value) => nonce = Some(value),
136 Err(_) => return,
137 },
138 "product" => product = Some(value.to_string()),
139 "release" => release = Some(value.to_string()),
140 _ => return,
141 }
142 }
143 self.pid = pid;
144 self.nonce = nonce;
145 self.product = product;
146 self.release = release;
147 }
148}
149
150/// The caller's verdict about one observed lock record.
151///
152/// Core does not decide staleness. Only [`ProvenStale`](Self::ProvenStale)
153/// authorizes recovery, and only for the exact observation that was classified.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155#[non_exhaustive]
156pub enum StaleLockDecision {
157 /// The recorded owner is believed to still be working. Retain the record.
158 Active,
159 /// The caller has proven from its own deployment evidence that the exact
160 /// observed owner is gone. Authorizes claiming that exact record.
161 ProvenStale,
162 /// Staleness could not be established. Retain the record and fail closed.
163 Unknown,
164}
165
166/// Consumer-supplied proof that one observed lock record is stale.
167///
168/// Implementations receive only what Core can prove about the record, plus their
169/// own runtime facts. They must not treat PID liveness, record age, executable
170/// name, or service state as universal proof, and they must return
171/// [`StaleLockDecision::Unknown`] whenever their evidence is incomplete.
172///
173/// The verifier cannot delete anything: it returns a verdict, and Core performs
174/// the claim.
175pub trait StaleLockVerifier {
176 /// Classifies one observed record.
177 fn classify(&self, observed: &LockObservation) -> StaleLockDecision;
178}
179
180/// An exclusive ownership record for one installation domain.
181///
182/// The default acquisition path is fail-closed: ambiguous, malformed, oversized,
183/// or otherwise unreadable records never trigger automatic removal, and
184/// [`acquire`](Self::acquire) never recovers a stale record at all. PID liveness
185/// alone cannot prove process identity across this public contract, so Core
186/// never treats it as proof.
187///
188/// Recovery is available, but only when a caller explicitly supplies
189/// [`StaleLockVerifier`] evidence through
190/// [`acquire_with_recovery`](Self::acquire_with_recovery), and only for the
191/// exact record that evidence classified.
192#[derive(Debug)]
193pub struct MutationLock {
194 path: PathBuf,
195 token: String,
196 cleanup: bool,
197 _file: File,
198}
199
200impl MutationLock {
201 /// Acquires the installation-root lock using create-new filesystem semantics.
202 ///
203 /// This is the default and never recovers: an existing record always
204 /// returns [`Error::UpdateInProgress`] and is left untouched.
205 ///
206 /// # Errors
207 ///
208 /// Returns [`Error::UpdateInProgress`] when a lock record already exists,
209 /// and [`Error::Io`] when the record cannot be created or written.
210 ///
211 /// This entry point never recovers a record: an existing record is always
212 /// contention, is never displaced, and is never deleted.
213 pub fn acquire(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Self> {
214 match Self::create_new(root, product, release)? {
215 Some(lock) => Ok(lock),
216 None => Err(Error::UpdateInProgress {
217 lock: lock_path(root),
218 }),
219 }
220 }
221
222 /// Acquires the lock, recovering a stale record the caller has proven stale.
223 ///
224 /// The sequence is deliberately conservative:
225 ///
226 /// 1. create-new;
227 /// 2. on an existing record, observe it within bounds;
228 /// 3. ask the caller's verifier for a verdict on that exact observation;
229 /// 4. only on [`StaleLockDecision::ProvenStale`], re-read the record and
230 /// rename the pathname into a unique Eggup-owned claim path in the same
231 /// directory;
232 /// 5. re-read the claimed object and require it to still equal the authorized
233 /// observation;
234 /// 6. create-new the real lock, then delete the claimed stale record only
235 /// after ownership is established.
236 ///
237 /// A record that is malformed, oversized, a symlink, non-regular, or
238 /// unreadable is never claimed. `Active` and `Unknown` verdicts retain the
239 /// record. If the record changes before the claim, no stale deletion happens
240 /// and contention is returned. If another writer creates a new lock after the
241 /// claim, that writer wins and its record is never removed.
242 ///
243 /// # Errors
244 ///
245 /// Returns [`RecoveryError::Core`] carrying [`Error::UpdateInProgress`]
246 /// when the record is contended, when the caller's verifier does not
247 /// return [`StaleLockDecision::ProvenStale`] for the exact record core
248 /// observed, or when another writer wins the race after a claim; and
249 /// carrying [`Error::Io`] when the record cannot be created, read, or
250 /// written.
251 ///
252 /// Returns [`RecoveryError::RecoveryRequired`] when a displaced stale
253 /// record could not be removed or verified automatically. The real
254 /// retained evidence path is in [`RecoveryError::evidence`].
255 ///
256 /// Core never decides staleness itself: PID liveness, record age,
257 /// executable name, and service state stay consumer policy.
258 pub fn acquire_with_recovery(
259 root: &Path,
260 product: &ProductId,
261 release: &ReleaseId,
262 verifier: &dyn StaleLockVerifier,
263 ) -> RecoveryResult<Self> {
264 if let Some(lock) = Self::create_new(root, product, release)? {
265 return Ok(lock);
266 }
267 let observed = match Self::observe(root)? {
268 Some(observation) => observation,
269 None => {
270 // The record disappeared between create-new and observation.
271 // Retrying is safe: create-new still decides the winner.
272 return match Self::create_new(root, product, release)? {
273 Some(lock) => Ok(lock),
274 None => Err(Error::UpdateInProgress {
275 lock: lock_path(root),
276 }
277 .into()),
278 };
279 }
280 };
281 if verifier.classify(&observed) != StaleLockDecision::ProvenStale {
282 return Err(Error::UpdateInProgress {
283 lock: observed.path().to_path_buf(),
284 }
285 .into());
286 }
287 let claim = Self::claim(root, &observed)?;
288 fire_post_claim_hook();
289 // Ownership of the installation domain is only established by a fresh
290 // create-new. If another writer won the race, its record is left alone.
291 match Self::create_new(root, product, release) {
292 Ok(Some(lock)) => {
293 // A claim that cannot be removed means recovery did not complete
294 // cleanly. Reporting it with its real path is the only truthful
295 // outcome; returning `lock` here would claim a success that left
296 // undisplaced evidence behind. Dropping `lock` on this path
297 // releases the record again, so the domain is left as found.
298 remove_claim(&claim)?;
299 Ok(lock)
300 }
301 Ok(None) => {
302 // Another writer holds the domain now. Our displaced record is
303 // ours to clean up; if it cannot be cleaned, the caller is told
304 // exactly where the evidence is.
305 remove_claim(&claim)?;
306 Err(Error::UpdateInProgress {
307 lock: lock_path(root),
308 }
309 .into())
310 }
311 Err(error) => {
312 // We displaced the stale record but could not take the domain.
313 // Retaining the evidence and reporting it is the only truthful
314 // outcome; silently dropping the record would let the caller
315 // believe recovery succeeded.
316 match remove_claim(&claim) {
317 Ok(()) => Err(error.into()),
318 Err(retained) => Err(retained),
319 }
320 }
321 }
322 }
323
324 /// Observes the lock record without acquiring or removing it.
325 ///
326 /// Returns `Ok(None)` when no record exists, and a bounded observation for a
327 /// regular, non-symlink, UTF-8, in-bounds record. Records that are oversized,
328 /// non-UTF-8, symlinked, non-regular, or unreadable yield `Err`, because
329 /// Core cannot prove what they are and must therefore never displace them.
330 ///
331 /// # Errors
332 ///
333 /// Returns [`Error::UpdateInProgress`] when a record exists that core
334 /// cannot prove safe — malformed, oversized, non-UTF-8, symlinked, or
335 /// non-regular — and [`Error::Io`] when the record cannot be read.
336 ///
337 /// An observation never acquires, displaces, or removes anything.
338 pub fn observe(root: &Path) -> Result<Option<LockObservation>> {
339 match Self::inspect(root)? {
340 LockStatus::Available => Ok(None),
341 LockStatus::Held {
342 lock,
343 contents: Some(contents),
344 } => Ok(Some(LockObservation::parse(lock, contents))),
345 LockStatus::Held { lock, .. } | LockStatus::Malformed { lock } => {
346 Err(Error::UpdateInProgress { lock })
347 }
348 }
349 }
350
351 /// Inspects the lock record without acquiring or removing it.
352 ///
353 /// Returns [`LockStatus::Available`] when no record exists,
354 /// [`LockStatus::Held`] when a bounded readable record exists, and
355 /// [`LockStatus::Malformed`] when the record is missing, unreadable,
356 /// oversized, or non-UTF-8. This function never deletes anything.
357 ///
358 /// # Errors
359 ///
360 /// Returns [`Error::Io`] when the record exists but cannot be read. A
361 /// malformed, oversized, symlinked, non-regular, or non-UTF-8 record is
362 /// reported as [`LockStatus::Malformed`], not as an error.
363 ///
364 /// This function never deletes anything.
365 pub fn inspect(root: &Path) -> Result<LockStatus> {
366 let path = lock_path(root);
367 let meta = match fs::symlink_metadata(&path) {
368 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
369 return Ok(LockStatus::Available);
370 }
371 Err(source) => return Err(Error::io("reading mutation lock", source)),
372 Ok(m) => m,
373 };
374 if !meta.is_file() {
375 return Ok(LockStatus::Malformed { lock: path });
376 }
377 #[cfg(unix)]
378 {
379 if meta.file_type().is_symlink() {
380 return Ok(LockStatus::Malformed { lock: path });
381 }
382 }
383 if meta.len() > MAX_LOCK_BYTES {
384 return Ok(LockStatus::Malformed { lock: path });
385 }
386 match fs::read_to_string(&path) {
387 Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(LockStatus::Held {
388 lock: path,
389 contents: Some(contents),
390 }),
391 _ => Ok(LockStatus::Malformed { lock: path }),
392 }
393 }
394
395 /// Returns the lock record path.
396 pub fn path(&self) -> &Path {
397 &self.path
398 }
399
400 /// Preserves the lock and record for manual recovery when cleanup is unsafe.
401 pub(crate) fn preserve(&mut self) {
402 self.cleanup = false;
403 }
404
405 /// Returns a freshly created lock, or `None` when a record already exists.
406 fn create_new(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Option<Self>> {
407 let path = lock_path(root);
408 let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
409 let token = format!(
410 "pid={} nonce={} product={} release={}\n",
411 std::process::id(),
412 nonce,
413 product,
414 release
415 );
416 if token.len() as u64 > MAX_LOCK_BYTES {
417 return Err(Error::invalid("lock record exceeds bound"));
418 }
419 let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
420 Ok(file) => file,
421 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
422 Err(source) => return Err(Error::io("creating mutation lock", source)),
423 };
424 if let Err(source) = file.write_all(token.as_bytes()) {
425 let _ = fs::remove_file(&path);
426 return Err(Error::io("writing mutation lock", source));
427 }
428 #[cfg(unix)]
429 {
430 use std::os::unix::fs::PermissionsExt;
431 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
432 }
433 Ok(Some(Self {
434 path,
435 token,
436 cleanup: true,
437 _file: file,
438 }))
439 }
440
441 /// Moves the authorized record aside into a unique Eggup-owned claim path.
442 ///
443 /// The claim lives in the same directory as the lock so the move is a
444 /// same-filesystem rename. The claimed object is re-read and must still equal
445 /// the authorized observation; if it does not, the displacement is undone
446 /// where that is safe, and otherwise reported with its real retained path.
447 fn claim(root: &Path, observed: &LockObservation) -> RecoveryResult<PathBuf> {
448 let path = observed.path().to_path_buf();
449 let claim = claim_path(root);
450 // Re-read immediately before displacing: the verdict was given for a
451 // specific observation, not for a pathname.
452 match read_record(&path) {
453 Ok(current) if current == observed.record() => {}
454 Ok(_) => return Err(Error::UpdateInProgress { lock: path }.into()),
455 Err(error) => return Err(error.into()),
456 }
457 fs::rename(&path, &claim)
458 .map_err(|source| Error::io("claiming the authorized stale lock record", source))?;
459 match read_record(&claim) {
460 Ok(claimed) if claimed == observed.record() => Ok(claim),
461 // The displaced object is not the authorized one, so the claim is not
462 // authorized either: undo it if that is safe, otherwise retain it.
463 Ok(claimed) => Err(restore_claim(&claim, &path, &claimed)),
464 // The record is displaced but unverified. Reporting only the
465 // underlying error would leave real Eggup-owned bytes behind with
466 // no indication of where they are.
467 Err(error) => Err(RecoveryError::RecoveryRequired {
468 evidence: claim,
469 detail: format!("the claimed stale lock record could not be verified: {error}"),
470 }),
471 }
472 }
473}
474
475impl Drop for MutationLock {
476 fn drop(&mut self) {
477 if !self.cleanup {
478 return;
479 }
480 let Ok(metadata) = fs::symlink_metadata(&self.path) else {
481 return;
482 };
483 if !metadata.is_file() {
484 return;
485 }
486 let Ok(contents) = fs::read_to_string(&self.path) else {
487 return;
488 };
489 if contents == self.token {
490 let _ = fs::remove_file(&self.path);
491 }
492 }
493}
494
495/// Test-only seam fired in the window between the claim and the create-new retry.
496///
497/// A competing writer that wins that window is the case the whole claim
498/// protocol exists to survive, but the window is microseconds wide. Without a
499/// deterministic seam the only way to exercise the branch is a real
500/// multi-process race, which would prove nothing about ordering. It is
501/// `cfg(test)` so no production binary carries it.
502#[cfg(test)]
503pub(crate) static POST_CLAIM_HOOK: std::sync::Mutex<Option<Box<dyn Fn() + Send>>> =
504 std::sync::Mutex::new(None);
505
506#[cfg(test)]
507fn fire_post_claim_hook() {
508 // `take` so the hook fires exactly once even if recovery retries.
509 let hook = POST_CLAIM_HOOK.lock().ok().and_then(|mut slot| slot.take());
510 if let Some(hook) = hook {
511 hook();
512 }
513}
514
515/// The call site stays ungated so production builds do not carry a branch on a
516/// test-only symbol.
517#[cfg(not(test))]
518fn fire_post_claim_hook() {}
519
520fn lock_path(root: &Path) -> PathBuf {
521 root.join(".eggup-mutation.lock")
522}
523
524fn claim_path(root: &Path) -> PathBuf {
525 use std::time::{SystemTime, UNIX_EPOCH};
526 let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
527 let nanos = SystemTime::now()
528 .duration_since(UNIX_EPOCH)
529 .map(|d| d.as_nanos())
530 .unwrap_or(0);
531 root.join(format!(
532 ".eggup-stale-claim-{}-{nonce}-{nanos}",
533 std::process::id()
534 ))
535}
536
537/// Reads a lock record only when it is a safe, bounded, UTF-8 regular file.
538fn read_record(path: &Path) -> Result<String> {
539 let metadata = match fs::symlink_metadata(path) {
540 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
541 return Err(Error::UpdateInProgress {
542 lock: path.to_path_buf(),
543 })
544 }
545 Err(source) => return Err(Error::io("reading lock record", source)),
546 Ok(metadata) => metadata,
547 };
548 if metadata.file_type().is_symlink() || !metadata.is_file() {
549 return Err(Error::UpdateInProgress {
550 lock: path.to_path_buf(),
551 });
552 }
553 if metadata.len() > MAX_LOCK_BYTES {
554 return Err(Error::UpdateInProgress {
555 lock: path.to_path_buf(),
556 });
557 }
558 match fs::read_to_string(path) {
559 Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(contents),
560 _ => Err(Error::UpdateInProgress {
561 lock: path.to_path_buf(),
562 }),
563 }
564}
565
566/// Puts a displaced record back only if the lock pathname is still free.
567///
568/// The restore deliberately does **not** `rename` the claim back. POSIX `rename`
569/// atomically *replaces* an existing destination, so a writer that took the lock
570/// between a freeness check and a rename would have its record unlinked
571/// underneath it while it still believes it holds the domain. `create_new` can
572/// never replace anything, so re-materializing the record through it makes the
573/// restore safe on every platform.
574///
575/// On success the lock path holds the displaced record again and the caller
576/// reports contention; the displaced writer owns the domain, which is the
577/// correct outcome for a record Core was never authorized to displace.
578pub(crate) fn restore_claim(claim: &Path, lock: &Path, record: &str) -> RecoveryError {
579 match OpenOptions::new().write(true).create_new(true).open(lock) {
580 Ok(mut file) => {
581 if let Err(source) = file.write_all(record.as_bytes()) {
582 // The lock path now holds a partial record. That is fail-closed:
583 // `acquire` still refuses it, and the full bytes remain at the
584 // claim, which is what the error names.
585 return RecoveryError::RecoveryRequired {
586 evidence: claim.to_path_buf(),
587 detail: format!(
588 "a displaced lock record was re-created at {} but could not be written: {source}",
589 lock.display()
590 ),
591 };
592 }
593 // The claim is now a duplicate of a record that owns the lock path,
594 // so removing it cannot lose the only copy.
595 let _ = fs::remove_file(claim);
596 RecoveryError::Core(Error::UpdateInProgress {
597 lock: lock.to_path_buf(),
598 })
599 }
600 Err(_) => RecoveryError::RecoveryRequired {
601 evidence: claim.to_path_buf(),
602 detail: "the displaced lock record was not the authorized one and the lock path was re-taken; retained evidence requires inspection"
603 .to_string(),
604 },
605 }
606}
607
608/// Deletes a claim this transaction created and has already proven it owns.
609fn remove_claim(claim: &Path) -> RecoveryResult<()> {
610 match fs::remove_file(claim) {
611 Ok(()) => Ok(()),
612 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
613 Err(source) => Err(RecoveryError::RecoveryRequired {
614 evidence: claim.to_path_buf(),
615 detail: format!("a claimed stale lock record could not be removed: {source}"),
616 }),
617 }
618}