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    ///
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}