Skip to main content

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}