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, 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/// An exclusive ownership record for one installation domain.
35///
36/// The lock is fail-closed: ambiguous, malformed, oversized, or otherwise
37/// unreadable records never trigger automatic removal. There is deliberately
38/// no automatic stale-lock recovery based on PID liveness alone, because PID
39/// reuse cannot prove process identity across the public contract. Operators
40/// establish ownership out-of-band (for example via [`MutationLock::inspect`]
41/// plus deployment-specific process evidence) and remove a stale record
42/// manually.
43#[derive(Debug)]
44pub struct MutationLock {
45    path: PathBuf,
46    token: String,
47    cleanup: bool,
48    _file: File,
49}
50
51impl MutationLock {
52    /// Acquires the installation-root lock using create-new filesystem semantics.
53    pub fn acquire(root: &Path, product: &ProductId, release: &ReleaseId) -> Result<Self> {
54        let path = root.join(".eggup-mutation.lock");
55        let nonce = NEXT_LOCK_NONCE.fetch_add(1, Ordering::Relaxed);
56        let token = format!(
57            "pid={} nonce={} product={} release={}\n",
58            std::process::id(),
59            nonce,
60            product,
61            release
62        );
63        if token.len() as u64 > MAX_LOCK_BYTES {
64            return Err(Error::invalid("lock record exceeds bound"));
65        }
66        let mut file = OpenOptions::new()
67            .write(true)
68            .create_new(true)
69            .open(&path)
70            .map_err(|source| {
71                if source.kind() == std::io::ErrorKind::AlreadyExists {
72                    Error::UpdateInProgress { lock: path.clone() }
73                } else {
74                    Error::io("creating mutation lock", source)
75                }
76            })?;
77        if let Err(source) = file.write_all(token.as_bytes()) {
78            let _ = fs::remove_file(&path);
79            return Err(Error::io("writing mutation lock", source));
80        }
81        #[cfg(unix)]
82        {
83            use std::os::unix::fs::PermissionsExt;
84            let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
85        }
86        Ok(Self {
87            path,
88            token,
89            cleanup: true,
90            _file: file,
91        })
92    }
93
94    /// Inspects the lock record without acquiring or removing it.
95    ///
96    /// Returns [`LockStatus::Available`] when no record exists,
97    /// [`LockStatus::Held`] when a bounded readable record exists, and
98    /// [`LockStatus::Malformed`] when the record is missing, unreadable,
99    /// oversized, or non-UTF-8. This function never deletes anything.
100    pub fn inspect(root: &Path) -> Result<LockStatus> {
101        let path = root.join(".eggup-mutation.lock");
102        let meta = match fs::symlink_metadata(&path) {
103            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
104                return Ok(LockStatus::Available);
105            }
106            Err(source) => return Err(Error::io("reading mutation lock", source)),
107            Ok(m) => m,
108        };
109        if !meta.is_file() {
110            return Ok(LockStatus::Malformed { lock: path });
111        }
112        #[cfg(unix)]
113        {
114            if meta.file_type().is_symlink() {
115                return Ok(LockStatus::Malformed { lock: path });
116            }
117        }
118        if meta.len() > MAX_LOCK_BYTES {
119            return Ok(LockStatus::Malformed { lock: path });
120        }
121        match fs::read_to_string(&path) {
122            Ok(contents) if contents.len() as u64 <= MAX_LOCK_BYTES => Ok(LockStatus::Held {
123                lock: path,
124                contents: Some(contents),
125            }),
126            _ => Ok(LockStatus::Malformed { lock: path }),
127        }
128    }
129
130    /// Returns the lock record path.
131    pub fn path(&self) -> &Path {
132        &self.path
133    }
134
135    /// Preserves the lock and record for manual recovery when cleanup is unsafe.
136    pub(crate) fn preserve(&mut self) {
137        self.cleanup = false;
138    }
139}
140
141impl Drop for MutationLock {
142    fn drop(&mut self) {
143        if !self.cleanup {
144            return;
145        }
146        let Ok(metadata) = fs::symlink_metadata(&self.path) else {
147            return;
148        };
149        if !metadata.is_file() {
150            return;
151        }
152        let Ok(contents) = fs::read_to_string(&self.path) else {
153            return;
154        };
155        if contents == self.token {
156            let _ = fs::remove_file(&self.path);
157        }
158    }
159}