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 pub fn acquire(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Self> {
206 match Self::create_new(root, product, release)? {
207 Some(lock) => Ok(lock),
208 None => Err(Error::UpdateInProgress {
209 lock: lock_path(root),
210 }),
211 }
212 }
213
214 /// Acquires the lock, recovering a stale record the caller has proven stale.
215 ///
216 /// The sequence is deliberately conservative:
217 ///
218 /// 1. create-new;
219 /// 2. on an existing record, observe it within bounds;
220 /// 3. ask the caller's verifier for a verdict on that exact observation;
221 /// 4. only on [`StaleLockDecision::ProvenStale`], re-read the record and
222 /// rename the pathname into a unique Eggup-owned claim path in the same
223 /// directory;
224 /// 5. re-read the claimed object and require it to still equal the authorized
225 /// observation;
226 /// 6. create-new the real lock, then delete the claimed stale record only
227 /// after ownership is established.
228 ///
229 /// A record that is malformed, oversized, a symlink, non-regular, or
230 /// unreadable is never claimed. `Active` and `Unknown` verdicts retain the
231 /// record. If the record changes before the claim, no stale deletion happens
232 /// and contention is returned. If another writer creates a new lock after the
233 /// claim, that writer wins and its record is never removed.
234 pub fn acquire_with_recovery(
235 root: &Path,
236 product: &ProductId,
237 release: &ReleaseId,
238 verifier: &dyn StaleLockVerifier,
239 ) -> RecoveryResult<Self> {
240 if let Some(lock) = Self::create_new(root, product, release)? {
241 return Ok(lock);
242 }
243 let observed = match Self::observe(root)? {
244 Some(observation) => observation,
245 None => {
246 // The record disappeared between create-new and observation.
247 // Retrying is safe: create-new still decides the winner.
248 return match Self::create_new(root, product, release)? {
249 Some(lock) => Ok(lock),
250 None => Err(Error::UpdateInProgress {
251 lock: lock_path(root),
252 }
253 .into()),
254 };
255 }
256 };
257 if verifier.classify(&observed) != StaleLockDecision::ProvenStale {
258 return Err(Error::UpdateInProgress {
259 lock: observed.path().to_path_buf(),
260 }
261 .into());
262 }
263 let claim = Self::claim(root, &observed)?;
264 fire_post_claim_hook();
265 // Ownership of the installation domain is only established by a fresh
266 // create-new. If another writer won the race, its record is left alone.
267 match Self::create_new(root, product, release) {
268 Ok(Some(lock)) => {
269 // A claim that cannot be removed means recovery did not complete
270 // cleanly. Reporting it with its real path is the only truthful
271 // outcome; returning `lock` here would claim a success that left
272 // undisplaced evidence behind. Dropping `lock` on this path
273 // releases the record again, so the domain is left as found.
274 remove_claim(&claim)?;
275 Ok(lock)
276 }
277 Ok(None) => {
278 // Another writer holds the domain now. Our displaced record is
279 // ours to clean up; if it cannot be cleaned, the caller is told
280 // exactly where the evidence is.
281 remove_claim(&claim)?;
282 Err(Error::UpdateInProgress {
283 lock: lock_path(root),
284 }
285 .into())
286 }
287 Err(error) => {
288 // We displaced the stale record but could not take the domain.
289 // Retaining the evidence and reporting it is the only truthful
290 // outcome; silently dropping the record would let the caller
291 // believe recovery succeeded.
292 match remove_claim(&claim) {
293 Ok(()) => Err(error.into()),
294 Err(retained) => Err(retained),
295 }
296 }
297 }
298 }
299
300 /// Observes the lock record without acquiring or removing it.
301 ///
302 /// Returns `Ok(None)` when no record exists, and a bounded observation for a
303 /// regular, non-symlink, UTF-8, in-bounds record. Records that are oversized,
304 /// non-UTF-8, symlinked, non-regular, or unreadable yield `Err`, because
305 /// Core cannot prove what they are and must therefore never displace them.
306 pub fn observe(root: &Path) -> Result<Option<LockObservation>> {
307 match Self::inspect(root)? {
308 LockStatus::Available => Ok(None),
309 LockStatus::Held {
310 lock,
311 contents: Some(contents),
312 } => Ok(Some(LockObservation::parse(lock, contents))),
313 LockStatus::Held { lock, .. } | LockStatus::Malformed { lock } => {
314 Err(Error::UpdateInProgress { lock })
315 }
316 }
317 }
318
319 /// Inspects the lock record without acquiring or removing it.
320 ///
321 /// Returns [`LockStatus::Available`] when no record exists,
322 /// [`LockStatus::Held`] when a bounded readable record exists, and
323 /// [`LockStatus::Malformed`] when the record is missing, unreadable,
324 /// oversized, or non-UTF-8. This function never deletes anything.
325 pub fn inspect(root: &Path) -> Result<LockStatus> {
326 let path = lock_path(root);
327 let meta = match fs::symlink_metadata(&path) {
328 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
329 return Ok(LockStatus::Available);
330 }
331 Err(source) => return Err(Error::io("reading mutation lock", source)),
332 Ok(m) => m,
333 };
334 if !meta.is_file() {
335 return Ok(LockStatus::Malformed { lock: path });
336 }
337 #[cfg(unix)]
338 {
339 if meta.file_type().is_symlink() {
340 return Ok(LockStatus::Malformed { lock: path });
341 }
342 }
343 if meta.len() > MAX_LOCK_BYTES {
344 return Ok(LockStatus::Malformed { lock: path });
345 }
346 match fs::read_to_string(&path) {
347 Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(LockStatus::Held {
348 lock: path,
349 contents: Some(contents),
350 }),
351 _ => Ok(LockStatus::Malformed { lock: path }),
352 }
353 }
354
355 /// Returns the lock record path.
356 pub fn path(&self) -> &Path {
357 &self.path
358 }
359
360 /// Preserves the lock and record for manual recovery when cleanup is unsafe.
361 pub(crate) fn preserve(&mut self) {
362 self.cleanup = false;
363 }
364
365 /// Returns a freshly created lock, or `None` when a record already exists.
366 fn create_new(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Option<Self>> {
367 let path = lock_path(root);
368 let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
369 let token = format!(
370 "pid={} nonce={} product={} release={}\n",
371 std::process::id(),
372 nonce,
373 product,
374 release
375 );
376 if token.len() as u64 > MAX_LOCK_BYTES {
377 return Err(Error::invalid("lock record exceeds bound"));
378 }
379 let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
380 Ok(file) => file,
381 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => return Ok(None),
382 Err(source) => return Err(Error::io("creating mutation lock", source)),
383 };
384 if let Err(source) = file.write_all(token.as_bytes()) {
385 let _ = fs::remove_file(&path);
386 return Err(Error::io("writing mutation lock", source));
387 }
388 #[cfg(unix)]
389 {
390 use std::os::unix::fs::PermissionsExt;
391 let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
392 }
393 Ok(Some(Self {
394 path,
395 token,
396 cleanup: true,
397 _file: file,
398 }))
399 }
400
401 /// Moves the authorized record aside into a unique Eggup-owned claim path.
402 ///
403 /// The claim lives in the same directory as the lock so the move is a
404 /// same-filesystem rename. The claimed object is re-read and must still equal
405 /// the authorized observation; if it does not, the displacement is undone
406 /// where that is safe, and otherwise reported with its real retained path.
407 fn claim(root: &Path, observed: &LockObservation) -> RecoveryResult<PathBuf> {
408 let path = observed.path().to_path_buf();
409 let claim = claim_path(root);
410 // Re-read immediately before displacing: the verdict was given for a
411 // specific observation, not for a pathname.
412 match read_record(&path) {
413 Ok(current) if current == observed.record() => {}
414 Ok(_) => return Err(Error::UpdateInProgress { lock: path }.into()),
415 Err(error) => return Err(error.into()),
416 }
417 fs::rename(&path, &claim)
418 .map_err(|source| Error::io("claiming the authorized stale lock record", source))?;
419 match read_record(&claim) {
420 Ok(claimed) if claimed == observed.record() => Ok(claim),
421 // The displaced object is not the authorized one, so the claim is not
422 // authorized either: undo it if that is safe, otherwise retain it.
423 Ok(claimed) => Err(restore_claim(&claim, &path, &claimed)),
424 // The record is displaced but unverified. Reporting only the
425 // underlying error would leave real Eggup-owned bytes behind with
426 // no indication of where they are.
427 Err(error) => Err(RecoveryError::RecoveryRequired {
428 evidence: claim,
429 detail: format!("the claimed stale lock record could not be verified: {error}"),
430 }),
431 }
432 }
433}
434
435impl Drop for MutationLock {
436 fn drop(&mut self) {
437 if !self.cleanup {
438 return;
439 }
440 let Ok(metadata) = fs::symlink_metadata(&self.path) else {
441 return;
442 };
443 if !metadata.is_file() {
444 return;
445 }
446 let Ok(contents) = fs::read_to_string(&self.path) else {
447 return;
448 };
449 if contents == self.token {
450 let _ = fs::remove_file(&self.path);
451 }
452 }
453}
454
455/// Test-only seam fired in the window between the claim and the create-new retry.
456///
457/// A competing writer that wins that window is the case the whole claim
458/// protocol exists to survive, but the window is microseconds wide. Without a
459/// deterministic seam the only way to exercise the branch is a real
460/// multi-process race, which would prove nothing about ordering. It is
461/// `cfg(test)` so no production binary carries it.
462#[cfg(test)]
463pub(crate) static POST_CLAIM_HOOK: std::sync::Mutex<Option<Box<dyn Fn() + Send>>> =
464 std::sync::Mutex::new(None);
465
466#[cfg(test)]
467fn fire_post_claim_hook() {
468 // `take` so the hook fires exactly once even if recovery retries.
469 let hook = POST_CLAIM_HOOK.lock().ok().and_then(|mut slot| slot.take());
470 if let Some(hook) = hook {
471 hook();
472 }
473}
474
475/// The call site stays ungated so production builds do not carry a branch on a
476/// test-only symbol.
477#[cfg(not(test))]
478fn fire_post_claim_hook() {}
479
480fn lock_path(root: &Path) -> PathBuf {
481 root.join(".eggup-mutation.lock")
482}
483
484fn claim_path(root: &Path) -> PathBuf {
485 use std::time::{SystemTime, UNIX_EPOCH};
486 let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
487 let nanos = SystemTime::now()
488 .duration_since(UNIX_EPOCH)
489 .map(|d| d.as_nanos())
490 .unwrap_or(0);
491 root.join(format!(
492 ".eggup-stale-claim-{}-{nonce}-{nanos}",
493 std::process::id()
494 ))
495}
496
497/// Reads a lock record only when it is a safe, bounded, UTF-8 regular file.
498fn read_record(path: &Path) -> Result<String> {
499 let metadata = match fs::symlink_metadata(path) {
500 Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
501 return Err(Error::UpdateInProgress {
502 lock: path.to_path_buf(),
503 })
504 }
505 Err(source) => return Err(Error::io("reading lock record", source)),
506 Ok(metadata) => metadata,
507 };
508 if metadata.file_type().is_symlink() || !metadata.is_file() {
509 return Err(Error::UpdateInProgress {
510 lock: path.to_path_buf(),
511 });
512 }
513 if metadata.len() > MAX_LOCK_BYTES {
514 return Err(Error::UpdateInProgress {
515 lock: path.to_path_buf(),
516 });
517 }
518 match fs::read_to_string(path) {
519 Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(contents),
520 _ => Err(Error::UpdateInProgress {
521 lock: path.to_path_buf(),
522 }),
523 }
524}
525
526/// Puts a displaced record back only if the lock pathname is still free.
527///
528/// The restore deliberately does **not** `rename` the claim back. POSIX `rename`
529/// atomically *replaces* an existing destination, so a writer that took the lock
530/// between a freeness check and a rename would have its record unlinked
531/// underneath it while it still believes it holds the domain. `create_new` can
532/// never replace anything, so re-materializing the record through it makes the
533/// restore safe on every platform.
534///
535/// On success the lock path holds the displaced record again and the caller
536/// reports contention; the displaced writer owns the domain, which is the
537/// correct outcome for a record Core was never authorized to displace.
538pub(crate) fn restore_claim(claim: &Path, lock: &Path, record: &str) -> RecoveryError {
539 match OpenOptions::new().write(true).create_new(true).open(lock) {
540 Ok(mut file) => {
541 if let Err(source) = file.write_all(record.as_bytes()) {
542 // The lock path now holds a partial record. That is fail-closed:
543 // `acquire` still refuses it, and the full bytes remain at the
544 // claim, which is what the error names.
545 return RecoveryError::RecoveryRequired {
546 evidence: claim.to_path_buf(),
547 detail: format!(
548 "a displaced lock record was re-created at {} but could not be written: {source}",
549 lock.display()
550 ),
551 };
552 }
553 // The claim is now a duplicate of a record that owns the lock path,
554 // so removing it cannot lose the only copy.
555 let _ = fs::remove_file(claim);
556 RecoveryError::Core(Error::UpdateInProgress {
557 lock: lock.to_path_buf(),
558 })
559 }
560 Err(_) => RecoveryError::RecoveryRequired {
561 evidence: claim.to_path_buf(),
562 detail: "the displaced lock record was not the authorized one and the lock path was re-taken; retained evidence requires inspection"
563 .to_string(),
564 },
565 }
566}
567
568/// Deletes a claim this transaction created and has already proven it owns.
569fn remove_claim(claim: &Path) -> RecoveryResult<()> {
570 match fs::remove_file(claim) {
571 Ok(()) => Ok(()),
572 Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
573 Err(source) => Err(RecoveryError::RecoveryRequired {
574 evidence: claim.to_path_buf(),
575 detail: format!("a claimed stale lock record could not be removed: {source}"),
576 }),
577 }
578}