Skip to main content

khive_runtime/daemon/
locks.rs

1#[cfg(unix)]
2use std::os::unix::io::AsRawFd;
3
4#[cfg(unix)]
5use tokio::net::UnixStream;
6
7#[cfg(unix)]
8use super::{lock_path, recoverer_lock_path};
9
10#[cfg(unix)]
11pub(super) fn open_lock_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
12    if let Some(parent) = path.parent() {
13        let _ = std::fs::create_dir_all(parent);
14    }
15    std::fs::OpenOptions::new()
16        .create(true)
17        .truncate(false)
18        .write(true)
19        .open(path)
20}
21
22#[cfg(unix)]
23fn acquire_flock_blocking(path: &std::path::Path, label: &str) -> Option<std::fs::File> {
24    let file = match open_lock_file(path) {
25        Ok(f) => f,
26        Err(e) => {
27            tracing::warn!(target: "khive_runtime::daemon", error = %e, path = ?path, "cannot open {label} lock file");
28            return None;
29        }
30    };
31    // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
32    let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
33    if rc != 0 {
34        tracing::warn!(target: "khive_runtime::daemon", "flock LOCK_EX failed on {label} lock");
35        return None;
36    }
37    Some(file)
38}
39
40/// Acquire an exclusive advisory flock on the recovery/startup lock file.
41///
42/// The returned `File` holds the lock for its lifetime; dropping it releases
43/// it.  Used by both the client (serializing kill+spawn) and the daemon server
44/// (serializing cleanup+bind+pid-write) so the two critical sections are
45/// mutually exclusive across processes.
46#[cfg(unix)]
47pub fn acquire_recovery_lock() -> Option<std::fs::File> {
48    acquire_flock_blocking(&lock_path(), "recovery")
49}
50
51/// Attempt to acquire an exclusive advisory flock on `path`, retrying with a
52/// non-blocking `flock(LOCK_NB)` until `deadline` elapses. Bounded alternative
53/// to `acquire_recovery_lock`/`acquire_daemon_boot_guard`'s unbounded blocking
54/// flock — see `docs/api/daemon.md#try_acquire_flock_until` for why a caller
55/// merely detecting lock freedom needs a deadline instead.
56///
57/// - `Ok(Some(file))` — the lock was free within the deadline.
58/// - `Ok(None)` — `deadline` elapsed while the lock stayed held; an explicit
59///   "could not confirm" outcome, distinct from a hard I/O error.
60/// - `Err(_)` — the lock file could not be opened, or `flock` failed for a
61///   reason other than contention.
62///
63/// Blocking (paces retries with `std::thread::sleep`) — async callers must
64/// run this via `spawn_blocking`.
65#[cfg(unix)]
66fn try_acquire_flock_until(
67    path: &std::path::Path,
68    deadline: std::time::Instant,
69) -> std::io::Result<Option<std::fs::File>> {
70    let file = open_lock_file(path)?;
71    let poll_interval = std::time::Duration::from_millis(10);
72    loop {
73        // SAFETY: flock is a POSIX advisory lock with no memory side-effects.
74        let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
75        if rc == 0 {
76            return Ok(Some(file));
77        }
78        let err = std::io::Error::last_os_error();
79        if err.raw_os_error() != Some(libc::EWOULDBLOCK) {
80            return Err(err);
81        }
82        let now = std::time::Instant::now();
83        if now >= deadline {
84            return Ok(None);
85        }
86        std::thread::sleep(poll_interval.min(deadline - now));
87    }
88}
89
90/// Bounded, deadline-aware variant of [`acquire_daemon_boot_guard`]: attempts
91/// the SAME boot/recovery lock ([`lock_path`]) but gives up at `deadline`
92/// instead of blocking forever. For callers that need to detect "is a boot in
93/// progress right now" without risking an unbounded wait behind a wedged
94/// holder: e.g. khive-mcp's `confirm_genuinely_dead` re-probing rounds,
95/// where `DEAD_CONFIRM_ROUNDS` must bound elapsed time, not just probe count.
96#[cfg(unix)]
97pub fn try_acquire_daemon_boot_guard_until(
98    deadline: std::time::Instant,
99) -> std::io::Result<Option<DaemonBootGuard>> {
100    try_acquire_flock_until(&lock_path(), deadline)
101}
102
103/// Bounded, deadline-aware acquisition of the recoverer-only lock
104/// ([`recoverer_lock_path`]). See [`try_acquire_daemon_boot_guard_until`] for
105/// the shared rationale — a second recoverer waiting for a peer's dead
106/// confirmation/kill/spawn critical section must give up and report
107/// "uncertain" rather than block forever if that peer is itself wedged.
108#[cfg(unix)]
109pub fn try_acquire_recoverer_lock_until(
110    deadline: std::time::Instant,
111) -> std::io::Result<Option<std::fs::File>> {
112    try_acquire_flock_until(&recoverer_lock_path(), deadline)
113}
114
115/// Guard returned by [`acquire_daemon_boot_guard`], held across cold-boot
116/// schema initialization (migrations + pack schema plans / FTS DDL) through
117/// daemon bind + pid-write.
118#[cfg(unix)]
119pub type DaemonBootGuard = std::fs::File;
120
121/// Acquire the recovery/boot lock, treating failure as fatal.
122///
123/// Unlike [`acquire_recovery_lock`] (best-effort, `None` on failure: used by
124/// shutdown cleanup, where skipping unlink is safer than blocking forever),
125/// daemon-mode boot must hold this lock across migrations/FTS DDL through
126/// bind+pid-write. Silently continuing with no lock reopens the cold-boot FTS
127/// race this guard exists to close, so callers that are about to run
128/// daemon-mode boot (or wait for one to quiesce) must fail loudly instead of
129/// proceeding unguarded.
130#[cfg(unix)]
131pub fn acquire_daemon_boot_guard() -> anyhow::Result<DaemonBootGuard> {
132    acquire_recovery_lock()
133        .ok_or_else(|| anyhow::anyhow!("failed to acquire daemon boot/recovery lock"))
134}
135
136/// Identity of a bound Unix socket path, used to tell "the socket I bound" apart
137/// from "a same-path socket some other daemon bound after mine was removed".
138///
139/// A socket path can be recreated by a different process between the time
140/// this daemon captures its identity and the time it later checks it, so `dev`
141/// and `ino` (not the path) are what must match for cleanup to be safe.
142#[cfg(unix)]
143#[derive(Clone, Copy, PartialEq, Eq)]
144pub(super) struct SocketIdentity {
145    dev: u64,
146    ino: u64,
147}
148
149#[cfg(unix)]
150pub(super) fn socket_identity(path: &std::path::Path) -> Option<SocketIdentity> {
151    use std::os::unix::fs::MetadataExt;
152    let meta = std::fs::metadata(path).ok()?;
153    Some(SocketIdentity {
154        dev: meta.dev(),
155        ino: meta.ino(),
156    })
157}
158
159// ── connection principal ──────────────────────────────────────────────────────
160
161/// The uid on the other end of an accepted connection, read from the kernel.
162///
163/// This is the only identity on this socket the caller cannot choose. Every
164/// identity field on the request frame — `namespace`, `actor_id`,
165/// `visible_namespaces`, `config_id` — is supplied by the connecting process,
166/// so none of them can answer "who is this". A check reading self-asserted
167/// fields is not a weak gate, it is not a gate: anyone who wants to pass it
168/// asserts the passing values.
169///
170/// `getpeereid(2)` on macOS/BSD, `SO_PEERCRED` on Linux. Both report the peer's
171/// credentials as recorded by the kernel at connect time.
172#[cfg(unix)]
173pub(crate) fn peer_uid(stream: &UnixStream) -> std::io::Result<u32> {
174    use std::os::fd::AsRawFd;
175    let fd = stream.as_raw_fd();
176
177    #[cfg(any(target_os = "macos", target_os = "ios", target_vendor = "apple"))]
178    {
179        let mut uid: libc::uid_t = 0;
180        let mut gid: libc::gid_t = 0;
181        // SAFETY: `fd` is a live connected socket owned by `stream` for the
182        // duration of this call; both out-params are valid initialized locals.
183        let rc = unsafe { libc::getpeereid(fd, &mut uid, &mut gid) };
184        if rc != 0 {
185            return Err(std::io::Error::last_os_error());
186        }
187        Ok(uid as u32)
188    }
189
190    #[cfg(target_os = "linux")]
191    {
192        let mut cred = libc::ucred {
193            pid: 0,
194            uid: 0,
195            gid: 0,
196        };
197        let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
198        // SAFETY: `fd` is a live connected socket owned by `stream`; `cred` is
199        // an initialized local of exactly `len` bytes, which is what
200        // SO_PEERCRED writes.
201        let rc = unsafe {
202            libc::getsockopt(
203                fd,
204                libc::SOL_SOCKET,
205                libc::SO_PEERCRED,
206                (&mut cred as *mut libc::ucred).cast::<libc::c_void>(),
207                &mut len,
208            )
209        };
210        if rc != 0 {
211            return Err(std::io::Error::last_os_error());
212        }
213        Ok(cred.uid)
214    }
215
216    #[cfg(not(any(
217        target_os = "linux",
218        target_os = "macos",
219        target_os = "ios",
220        target_vendor = "apple"
221    )))]
222    {
223        let _ = fd;
224        Err(std::io::Error::new(
225            std::io::ErrorKind::Unsupported,
226            "peer-credential capture is not implemented for this platform",
227        ))
228    }
229}
230
231/// Whether a connection from `uid` may be served by this daemon.
232///
233/// ADR-096 accepted per-request identity threading **for the single-principal
234/// owner-only socket only**, and rested that on three things: the `0600`
235/// socket, all connections being the same uid, and the database being already
236/// same-uid-accessible. The socket mode is asserted at bind. This asserts the
237/// second, which previously had no representation in the code at all — nothing
238/// read peer identity, so nothing could notice when it stopped being true.
239///
240/// **Principal is not attribution.** Many `actor_id`s over one socket is
241/// exactly what ADR-096 shipped and what every seat on a normal host does;
242/// refusing a second distinct actor would break the accepted design. The
243/// principal is the uid, and this refuses only a genuinely foreign one.
244///
245/// **There is deliberately no configuration escape hatch.** A flag permitting
246/// other uids would not weaken this assertion, it would delete it, in the way
247/// hardest to notice later: the check still exists, its tests still pass, and
248/// the deployment that matters has it off. A deployment that genuinely needs
249/// multiple uids needs a code change and a gated ADR — which is precisely the
250/// decision that should be impossible to make by accident.
251#[cfg(unix)]
252pub(crate) fn uid_is_permitted(peer: u32, daemon_euid: u32) -> bool {
253    peer == daemon_euid
254}