Skip to main content

daemon/
local_daemon.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local-mode gRPC daemon over a Unix-domain socket.
3//!
4//! Hosts the W2 [`grpc_local_impl`](crate::grpc_local_impl) services on
5//! a UDS inside a single repo, reachable by the same-user CLI for the
6//! latency-sensitive agent loop. No Biscuit, no TLS, no multi-tenant —
7//! local-only, single-user, same-process auth via SO_PEERCRED on Linux and
8//! `getpeereid` on macOS.
9//!
10//! The CLI wraps this behind `heddle agent serve` (W2 / A16). Out of scope
11//! for first ship: multi-user, remote daemon-as-service, TLS. Documented
12//! in the verb's `--help` long form.
13//!
14//! # Lifecycle
15//!
16//! 1. `serve(...)` opens the [`Repository`], the [`OperationDedupStore`],
17//!    and the UDS listener.
18//! 2. A pidfile and the socket path are guarded by [`PidGuard`] so a stale
19//!    daemon's leftover files don't block restart and a clean exit removes
20//!    them.
21//! 3. tonic's [`Server::serve_with_shutdown`] runs the W2 services until the
22//!    `shutdown` future resolves.
23//!
24//! # Cross-platform notes
25//!
26//! Building the daemon binary on Windows is not supported — UDS support
27//! there is nascent. The module compiles only on `unix` and the rest of the
28//! crate doesn't reach for it on other platforms.
29
30#![cfg(unix)]
31
32use std::{
33    path::{Path, PathBuf},
34    sync::{Arc, Mutex},
35};
36
37use api::heddle::api::v1alpha1::state_review_service_server::StateReviewServiceServer;
38use objects::error::{HeddleError, Result};
39use repo::{Repository, operation_dedup::OperationDedupStore};
40use tokio::net::UnixListener;
41use tokio_stream::{StreamExt, wrappers::UnixListenerStream};
42use tonic::transport::Server;
43
44use crate::grpc_local_impl::{GrpcLocalService, LocalStateReviewService};
45
46const PRIVATE_SOCKET_UMASK: libc::mode_t = 0o177;
47
48static SOCKET_BIND_UMASK_LOCK: Mutex<()> = Mutex::new(());
49
50/// Default socket path inside a repo: `<heddle_dir>/sockets/grpc.sock`.
51pub fn default_socket_path(heddle_dir: &Path) -> PathBuf {
52    heddle_dir.join("sockets").join("grpc.sock")
53}
54
55/// Default pidfile path inside a repo: `<heddle_dir>/sockets/grpc.pid`.
56pub fn default_pid_path(heddle_dir: &Path) -> PathBuf {
57    heddle_dir.join("sockets").join("grpc.pid")
58}
59
60/// Configuration for [`serve`]. The socket and pidfile default to the
61/// well-known locations under the repo's `.heddle/sockets/` directory.
62pub struct LocalDaemonConfig {
63    pub socket_path: PathBuf,
64    pub pid_path: PathBuf,
65}
66
67impl LocalDaemonConfig {
68    pub fn from_repo(repo: &Repository) -> Self {
69        let heddle_dir = repo.heddle_dir();
70        Self {
71            socket_path: default_socket_path(heddle_dir),
72            pid_path: default_pid_path(heddle_dir),
73        }
74    }
75
76    pub fn with_socket(mut self, path: PathBuf) -> Self {
77        self.socket_path = path;
78        self
79    }
80}
81
82/// RAII guard that removes the pidfile and socket on drop. Constructed by
83/// [`serve`]; callers don't typically use it directly.
84struct PidGuard {
85    pid_path: PathBuf,
86    socket_path: PathBuf,
87}
88
89/// Magic marker line written to the pidfile so `heddle agent stop` can
90/// distinguish a heddle pidfile from a foreign one before signalling the
91/// PID. See [`PidFileContents`] for the on-disk format.
92pub const PIDFILE_MARKER: &str = "heddle-agent";
93
94/// Parsed pidfile contents. Format on disk is three newline-terminated
95/// lines:
96///
97/// ```text
98/// <pid>
99/// heddle-agent
100/// <start_time_unix_secs>
101/// ```
102///
103/// The marker line lets `agent stop` reject a pidfile that wasn't written
104/// by us. Combined with the same-executable identity check in
105/// [`is_heddle_process`], this closes the "PID got reused after a dirty
106/// crash" hole that the reviewer flagged: even if `<pid>` now belongs to
107/// some unrelated process, we won't SIGTERM it.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct PidFileContents {
110    pub pid: i32,
111    pub started_at_secs: i64,
112}
113
114impl PidFileContents {
115    /// Render the file body. Always trailing-newline so `cat` round-trips.
116    pub fn render(&self) -> String {
117        format!(
118            "{}\n{}\n{}\n",
119            self.pid, PIDFILE_MARKER, self.started_at_secs
120        )
121    }
122
123    /// Parse a pidfile body. Returns `None` when the file isn't in the
124    /// heddle format — the caller should treat this as "not a heddle
125    /// pidfile" and refuse to act on it.
126    pub fn parse(body: &str) -> Option<Self> {
127        let mut lines = body.lines();
128        let pid = lines.next()?.trim().parse::<i32>().ok()?;
129        let marker = lines.next()?.trim();
130        if marker != PIDFILE_MARKER {
131            return None;
132        }
133        let started_at_secs = lines.next()?.trim().parse::<i64>().ok()?;
134        Some(Self {
135            pid,
136            started_at_secs,
137        })
138    }
139}
140
141impl PidGuard {
142    fn install(pid_path: PathBuf, socket_path: PathBuf) -> Result<Self> {
143        if let Some(parent) = pid_path.parent() {
144            std::fs::create_dir_all(parent)?;
145        }
146        // If a stale pidfile exists for a dead PID, clean both files and
147        // proceed. If the PID is alive AND the file contains our marker
148        // AND the running process is this exact executable, refuse to
149        // start. A foreign-format pidfile is treated as stale (we wrote
150        // it, or it's debris) — we don't want to refuse forever because
151        // some other tool dropped a file with the same name.
152        if pid_path.exists() {
153            let raw = std::fs::read_to_string(&pid_path).ok();
154            let parsed = raw.as_deref().and_then(PidFileContents::parse);
155            if let Some(existing) = parsed
156                && pid_alive(existing.pid)
157                && is_heddle_process(existing.pid)
158            {
159                return Err(HeddleError::Conflict(format!(
160                    "heddle agent serve already running on this repo (pid {}); \
161                     stop it first or remove {} if it's stale",
162                    existing.pid,
163                    pid_path.display()
164                )));
165            }
166            // Stale or foreign pidfile; sweep both files.
167            let _ = std::fs::remove_file(&pid_path);
168            if socket_path.exists() {
169                let _ = std::fs::remove_file(&socket_path);
170            }
171        }
172        // Write our own pidfile in the (pid, marker, start_time) format.
173        let contents = PidFileContents {
174            pid: std::process::id() as i32,
175            started_at_secs: std::time::SystemTime::now()
176                .duration_since(std::time::UNIX_EPOCH)
177                .map(|d| d.as_secs() as i64)
178                .unwrap_or(0),
179        };
180        std::fs::write(&pid_path, contents.render())?;
181        Ok(Self {
182            pid_path,
183            socket_path,
184        })
185    }
186}
187
188impl Drop for PidGuard {
189    fn drop(&mut self) {
190        let _ = std::fs::remove_file(&self.pid_path);
191        let _ = std::fs::remove_file(&self.socket_path);
192    }
193}
194
195#[cfg(any(target_os = "linux", target_os = "macos"))]
196pub fn pid_alive(pid: i32) -> bool {
197    // SAFETY: kill(pid, 0) returns 0 on permission-checked success and -1
198    // (errno = ESRCH) when the process no longer exists. No signal is
199    // delivered with sig == 0.
200    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
201}
202
203#[cfg(not(any(target_os = "linux", target_os = "macos")))]
204pub fn pid_alive(_pid: i32) -> bool {
205    // Conservative fallback for other unixes: assume the pidfile is fresh
206    // rather than blowing it away. Operators can `--force-clear` later.
207    true
208}
209
210/// Verify that `pid` belongs to the same executable as this process.
211///
212/// The pidfile marker alone doesn't protect against the "daemon dies
213/// uncleanly, OS reuses the PID" case the reviewer flagged: the marker
214/// stays in the file but the PID now points at someone else. So before
215/// any signal is delivered we also verify that the process at `pid` is
216/// running this exact executable, not merely a path containing "heddle".
217///
218/// On Linux we read the `/proc/{pid}/exe` symlink — the kernel resolves
219/// it to the absolute on-disk path of the running binary. On macOS we
220/// use `libc::proc_pidpath`. On other platforms the check returns
221/// `false` (operators on those platforms can use `--force-clear` to
222/// override; better to refuse than to SIGTERM the wrong process).
223pub fn is_heddle_process(pid: i32) -> bool {
224    process_uid_matches_self(pid) && process_exe_matches_current(pid)
225}
226
227#[cfg(target_os = "linux")]
228fn process_uid_matches_self(pid: i32) -> bool {
229    use std::os::unix::fs::MetadataExt;
230
231    let path = PathBuf::from(format!("/proc/{pid}"));
232    let Ok(metadata) = std::fs::metadata(path) else {
233        return false;
234    };
235    // SAFETY: geteuid() never fails.
236    metadata.uid() == unsafe { libc::geteuid() }
237}
238
239/// Compare the target process's real UID to this process's euid via
240/// `proc_pidinfo(PROC_PIDTBSDINFO)` (libproc). Returns `false` if the
241/// process cannot be inspected — refuse rather than SIGTERM a process
242/// we cannot attribute.
243#[cfg(target_os = "macos")]
244fn process_uid_matches_self(pid: i32) -> bool {
245    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
246    let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
247    // SAFETY: `info` is a valid, zeroed `proc_bsdinfo` and `size` matches
248    // the PROC_PIDTBSDINFO buffer contract from libproc.
249    let ret = unsafe {
250        libc::proc_pidinfo(
251            pid,
252            libc::PROC_PIDTBSDINFO,
253            0,
254            &mut info as *mut _ as *mut libc::c_void,
255            size,
256        )
257    };
258    if ret <= 0 || ret < size {
259        return false;
260    }
261    // SAFETY: geteuid() never fails.
262    info.pbi_uid == unsafe { libc::geteuid() }
263}
264
265/// Platforms without a reliable process-UID API refuse the match.
266/// Combined with `process_exe_path` returning `None` on these targets,
267/// `is_heddle_process` already fails closed; do not claim a UID match
268/// we cannot verify.
269#[cfg(not(any(target_os = "linux", target_os = "macos")))]
270fn process_uid_matches_self(_pid: i32) -> bool {
271    false
272}
273
274fn process_exe_matches_current(pid: i32) -> bool {
275    let Some(process_exe) = process_exe_path(pid) else {
276        return false;
277    };
278    let Ok(current_exe) = std::env::current_exe() else {
279        return false;
280    };
281    executable_identity_matches(&process_exe, &current_exe)
282}
283
284fn executable_identity_matches(process_exe: &Path, current_exe: &Path) -> bool {
285    let Ok(process_exe) = process_exe.canonicalize() else {
286        return false;
287    };
288    let Ok(current_exe) = current_exe.canonicalize() else {
289        return false;
290    };
291    process_exe == current_exe
292}
293
294#[cfg(target_os = "linux")]
295fn process_exe_path(pid: i32) -> Option<PathBuf> {
296    std::fs::read_link(format!("/proc/{pid}/exe")).ok()
297}
298
299#[cfg(target_os = "macos")]
300fn process_exe_path(pid: i32) -> Option<PathBuf> {
301    use std::{ffi::OsString, os::unix::ffi::OsStringExt};
302
303    let mut buf = vec![0u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
304    // SAFETY: buf is owned and large enough per the macOS contract.
305    let len = unsafe { libc::proc_pidpath(pid, buf.as_mut_ptr() as *mut _, buf.len() as u32) };
306    if len <= 0 {
307        return None;
308    }
309    Some(PathBuf::from(OsString::from_vec(
310        buf[..len as usize].to_vec(),
311    )))
312}
313
314#[cfg(not(any(target_os = "linux", target_os = "macos")))]
315fn process_exe_path(_pid: i32) -> Option<PathBuf> {
316    None
317}
318
319/// Open a [`Repository`] at `repo_path`, then run the local gRPC daemon
320/// over the configured UDS until `shutdown` resolves.
321pub async fn serve(
322    repo: Repository,
323    config: LocalDaemonConfig,
324    shutdown: impl std::future::Future<Output = ()> + Send + 'static,
325) -> Result<()> {
326    create_private_socket_parent(&config.socket_path)?;
327    // PidGuard refuses to start when another daemon owns this repo.
328    let _guard = PidGuard::install(config.pid_path.clone(), config.socket_path.clone())?;
329
330    // Remove any stale socket left by a non-graceful previous exit. The
331    // pidfile check above ruled out a live owner.
332    if config.socket_path.exists() {
333        std::fs::remove_file(&config.socket_path)?;
334    }
335    let listener = bind_private_unix_listener(&config.socket_path)?;
336    // Mode 0600 — same-user only. The umask guard in
337    // `bind_private_unix_listener` makes the socket born private; this chmod
338    // remains defense-in-depth and normalizes platforms that report mode bits
339    // differently after bind.
340    set_socket_mode_0600(&config.socket_path)?;
341    listener.set_nonblocking(true).map_err(|e| {
342        HeddleError::Io(std::io::Error::new(
343            e.kind(),
344            format!(
345                "UnixListener::set_nonblocking({}): {e}",
346                config.socket_path.display()
347            ),
348        ))
349    })?;
350    let listener = UnixListener::from_std(listener).map_err(|e| {
351        HeddleError::Io(std::io::Error::new(
352            e.kind(),
353            format!(
354                "UnixListener::from_std({}): {e}",
355                config.socket_path.display()
356            ),
357        ))
358    })?;
359
360    let dedup = Arc::new(OperationDedupStore::open(repo.heddle_dir())?);
361    let inner = GrpcLocalService::new(Arc::new(repo), dedup);
362
363    let state_review = StateReviewServiceServer::new(LocalStateReviewService::new(inner.clone()));
364
365    // Per-connection SO_PEERCRED gate. Mode 0600 already keeps other users
366    // from opening the socket; this filter is the defense-in-depth layer
367    // the module docs promise — every accepted connection is checked
368    // against the daemon's uid (and dropped on mismatch) before tonic ever
369    // sees it.
370    let incoming = UnixListenerStream::new(listener).filter_map(guard_peer_connection);
371
372    Server::builder()
373        .add_service(state_review)
374        .serve_with_incoming_shutdown(incoming, shutdown)
375        .await
376        .map_err(|e| HeddleError::InvalidObject(format!("local daemon transport failed: {e}")))?;
377    Ok(())
378}
379
380fn create_private_socket_parent(socket_path: &Path) -> Result<()> {
381    if let Some(parent) = socket_path.parent() {
382        use std::os::unix::fs::DirBuilderExt;
383        let mut builder = std::fs::DirBuilder::new();
384        builder.recursive(true).mode(0o700);
385        builder.create(parent)?;
386    }
387    Ok(())
388}
389
390fn bind_private_unix_listener(socket_path: &Path) -> Result<std::os::unix::net::UnixListener> {
391    let _lock = SOCKET_BIND_UMASK_LOCK
392        .lock()
393        .map_err(|_| HeddleError::InvalidObject("daemon socket umask lock poisoned".to_string()))?;
394    // Pathname UDS permissions are chosen by the kernel at bind time from
395    // the process umask, so chmod-after-bind is too late for security. This
396    // async `serve` path performs all startup work synchronously before the
397    // daemon accepts connections or spawns service work; no `.await` occurs
398    // while the process-global umask is narrowed. The mutex serializes other
399    // Heddle socket binds in this process, and the guard restores the prior
400    // umask even when bind fails.
401    let _umask = UmaskGuard::set(PRIVATE_SOCKET_UMASK);
402    std::os::unix::net::UnixListener::bind(socket_path).map_err(|e| {
403        HeddleError::Io(std::io::Error::new(
404            e.kind(),
405            format!("UnixListener::bind({}): {e}", socket_path.display()),
406        ))
407    })
408}
409
410struct UmaskGuard {
411    previous: libc::mode_t,
412}
413
414impl UmaskGuard {
415    fn set(mask: libc::mode_t) -> Self {
416        // SAFETY: umask is process-global and always succeeds. The caller
417        // keeps the guarded section synchronous and holds
418        // SOCKET_BIND_UMASK_LOCK for Heddle's own socket-bind paths.
419        let previous = unsafe { libc::umask(mask) };
420        Self { previous }
421    }
422}
423
424impl Drop for UmaskGuard {
425    fn drop(&mut self) {
426        // SAFETY: restoring the previously returned umask is infallible.
427        unsafe {
428            libc::umask(self.previous);
429        }
430    }
431}
432
433#[cfg(unix)]
434fn set_socket_mode_0600(path: &Path) -> Result<()> {
435    use std::os::unix::fs::PermissionsExt;
436    let permissions = std::fs::Permissions::from_mode(0o600);
437    std::fs::set_permissions(path, permissions)?;
438    Ok(())
439}
440
441/// Verify that a connecting peer's UID matches the daemon's own. Wired
442/// into the [`serve`] accept path via [`guard_peer_connection`]: every UDS
443/// connection passes through this check before tonic handles it. The
444/// socket's mode 0600 already keeps other users from opening it; this
445/// SO_PEERCRED check (`getpeereid` on macOS) is the defense-in-depth layer
446/// that enforces the same-user boundary per-connection even if the socket
447/// permissions were somehow widened.
448pub fn check_peer_uid_matches_self(stream: &tokio::net::UnixStream) -> Result<()> {
449    let creds = stream
450        .peer_cred()
451        .map_err(|e| HeddleError::InvalidObject(format!("peer_cred failed: {e}")))?;
452    // SAFETY: geteuid() never fails.
453    let our_uid = unsafe { libc::geteuid() };
454    enforce_peer_uid(creds.uid(), our_uid)
455}
456
457/// Compare a peer's UID against the daemon's effective UID, erroring when
458/// they differ. Split out from [`check_peer_uid_matches_self`] so the
459/// accept/reject decision is unit-testable without a real cross-UID
460/// connection (which would need root or a second user).
461fn enforce_peer_uid(peer_uid: u32, our_uid: u32) -> Result<()> {
462    if peer_uid != our_uid {
463        return Err(HeddleError::Conflict(format!(
464            "peer uid {peer_uid} does not match daemon uid {our_uid}"
465        )));
466    }
467    Ok(())
468}
469
470/// Per-connection gate applied to the [`serve`] accept stream. Each
471/// accepted UDS connection is checked with [`check_peer_uid_matches_self`];
472/// a peer whose UID doesn't match the daemon's is dropped here (its
473/// `UnixStream` is closed) and never handed to tonic. Listener-level I/O
474/// errors pass through unchanged so tonic's own error handling still runs.
475fn guard_peer_connection(
476    conn: std::io::Result<tokio::net::UnixStream>,
477) -> Option<std::io::Result<tokio::net::UnixStream>> {
478    match conn {
479        Ok(stream) => match check_peer_uid_matches_self(&stream) {
480            Ok(()) => Some(Ok(stream)),
481            Err(e) => {
482                tracing::warn!(
483                    error = %e,
484                    "local-daemon: rejecting connection from peer with mismatched uid"
485                );
486                None
487            }
488        },
489        Err(e) => Some(Err(e)),
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use tempfile::TempDir;
496
497    use super::*;
498
499    #[test]
500    #[serial_test::serial(process_global)]
501    fn default_socket_path_lives_under_heddle_dir() {
502        let temp = TempDir::new().unwrap();
503        let heddle = temp.path().join(".heddle");
504        std::fs::create_dir_all(&heddle).unwrap();
505        let path = default_socket_path(&heddle);
506        assert!(path.starts_with(&heddle));
507        assert!(path.ends_with("grpc.sock"));
508    }
509
510    #[test]
511    #[serial_test::serial(process_global)]
512    fn create_private_socket_parent_creates_new_parent_0700() {
513        use std::os::unix::fs::PermissionsExt;
514
515        let temp = TempDir::new().unwrap();
516        let socket = temp
517            .path()
518            .join(".heddle")
519            .join("sockets")
520            .join("grpc.sock");
521        create_private_socket_parent(&socket).unwrap();
522
523        let mode = std::fs::metadata(socket.parent().unwrap())
524            .unwrap()
525            .permissions()
526            .mode()
527            & 0o777;
528        assert_eq!(mode, 0o700, "new socket parent must be private");
529    }
530
531    #[test]
532    fn bind_private_unix_listener_creates_socket_0600_before_chmod() {
533        const TEST_NAME: &str =
534            "local_daemon::tests::bind_private_unix_listener_creates_socket_0600_before_chmod";
535        if !run_umask_test_in_isolated_process(TEST_NAME) {
536            return;
537        }
538
539        use std::os::unix::fs::PermissionsExt;
540
541        let temp = TempDir::new().unwrap();
542        let socket = temp.path().join("grpc.sock");
543
544        let _listener = match bind_private_unix_listener(&socket) {
545            Ok(listener) => listener,
546            Err(HeddleError::Io(err)) if err.kind() == std::io::ErrorKind::PermissionDenied => {
547                eprintln!(
548                    "skipping daemon socket mode test: local Unix listener bind denied: {err}"
549                );
550                return;
551            }
552            Err(err) => panic!("bind private Unix listener: {err}"),
553        };
554
555        let mode = std::fs::metadata(&socket).unwrap().permissions().mode() & 0o777;
556        assert_eq!(
557            mode, 0o600,
558            "socket must be born private before set_socket_mode_0600 runs"
559        );
560    }
561
562    #[test]
563    fn bind_private_unix_listener_restores_umask_after_bind_error() {
564        const TEST_NAME: &str =
565            "local_daemon::tests::bind_private_unix_listener_restores_umask_after_bind_error";
566        if !run_umask_test_in_isolated_process(TEST_NAME) {
567            return;
568        }
569
570        let temp = TempDir::new().unwrap();
571        let socket = temp.path().join("missing").join("grpc.sock");
572        let before = current_umask();
573
574        let result = bind_private_unix_listener(&socket);
575
576        let after = current_umask();
577        assert!(result.is_err(), "bind should fail for a missing parent");
578        assert_eq!(after, before, "bind errors must restore the prior umask");
579    }
580
581    /// Re-run an umask-sensitive assertion as the only selected test in a
582    /// child process. `umask(2)` is process-global, so a mutex or serial-test
583    /// group cannot protect unrelated tests running on other harness threads.
584    /// Keeping the mutation in a one-test child makes the isolation boundary
585    /// structural: the parent workspace test process never observes the
586    /// temporary `PRIVATE_SOCKET_UMASK`.
587    fn run_umask_test_in_isolated_process(test_name: &str) -> bool {
588        const CHILD_TEST_ENV: &str = "HEDDLE_DAEMON_UMASK_TEST_CHILD";
589
590        if std::env::var_os(CHILD_TEST_ENV).as_deref() == Some(std::ffi::OsStr::new(test_name)) {
591            return true;
592        }
593
594        let output = std::process::Command::new(
595            std::env::current_exe().expect("resolve daemon unit-test executable"),
596        )
597        .args(["--exact", test_name, "--nocapture"])
598        .env(CHILD_TEST_ENV, test_name)
599        .output()
600        .expect("spawn isolated daemon umask test");
601        let stdout = String::from_utf8_lossy(&output.stdout);
602        let stderr = String::from_utf8_lossy(&output.stderr);
603        assert!(
604            output.status.success() && stdout.contains("1 passed"),
605            "isolated umask test {test_name} did not run successfully\nstdout:\n{stdout}\nstderr:\n{stderr}"
606        );
607        false
608    }
609
610    fn current_umask() -> libc::mode_t {
611        // SAFETY: reading umask requires the standard set-then-restore
612        // sequence. This helper only runs in the isolated one-test child.
613        unsafe {
614            let current = libc::umask(0);
615            libc::umask(current);
616            current
617        }
618    }
619
620    #[test]
621    #[serial_test::serial(process_global)]
622    fn pid_guard_writes_and_removes_pidfile() {
623        let temp = TempDir::new().unwrap();
624        let pid = temp.path().join("grpc.pid");
625        let sock = temp.path().join("grpc.sock");
626        let guard = PidGuard::install(pid.clone(), sock.clone()).unwrap();
627        assert!(pid.exists());
628        drop(guard);
629        assert!(!pid.exists());
630        assert!(!sock.exists());
631    }
632
633    #[test]
634    #[serial_test::serial(process_global)]
635    fn pid_guard_refuses_when_live_heddle_process_owns_pidfile() {
636        let temp = TempDir::new().unwrap();
637        let pid = temp.path().join("grpc.pid");
638        let sock = temp.path().join("grpc.sock");
639        // Pre-installing a guard writes the current process PID with the
640        // marker. A second install must refuse because the recorded PID is
641        // alive and resolves to this exact test executable.
642        let first = PidGuard::install(pid.clone(), sock.clone()).unwrap();
643        let result = PidGuard::install(pid.clone(), sock.clone());
644        assert!(result.is_err(), "expected refusal for live owner");
645        drop(first);
646    }
647
648    #[test]
649    #[serial_test::serial(process_global)]
650    fn pid_guard_sweeps_stale_pidfile_with_dead_pid() {
651        let temp = TempDir::new().unwrap();
652        let pid = temp.path().join("grpc.pid");
653        let sock = temp.path().join("grpc.sock");
654        // 2_147_483_646 is well above realistic pid_max; almost certainly dead.
655        let stale = PidFileContents {
656            pid: 2_147_483_646,
657            started_at_secs: 0,
658        };
659        std::fs::write(&pid, stale.render()).unwrap();
660        std::fs::write(&sock, "stale").unwrap();
661        let _guard = PidGuard::install(pid.clone(), sock.clone()).unwrap();
662        // The stale socket was removed and our PID is the new one.
663        let raw = std::fs::read_to_string(&pid).unwrap();
664        let parsed = PidFileContents::parse(&raw).expect("guard wrote structured pidfile");
665        assert_eq!(parsed.pid, std::process::id() as i32);
666        assert!(parsed.started_at_secs > 0);
667    }
668
669    #[test]
670    #[serial_test::serial(process_global)]
671    fn pid_guard_sweeps_legacy_unstructured_pidfile() {
672        // Pidfiles written by older daemons that pre-date the marker
673        // are treated as foreign — the new `parse()` returns None and
674        // the install path sweeps them rather than refusing forever.
675        let temp = TempDir::new().unwrap();
676        let pid = temp.path().join("grpc.pid");
677        let sock = temp.path().join("grpc.sock");
678        std::fs::write(&pid, "12345").unwrap();
679        let _guard = PidGuard::install(pid.clone(), sock.clone()).unwrap();
680        let parsed = PidFileContents::parse(&std::fs::read_to_string(&pid).unwrap()).unwrap();
681        assert_eq!(parsed.pid, std::process::id() as i32);
682    }
683
684    #[test]
685    fn pidfile_contents_round_trip() {
686        let original = PidFileContents {
687            pid: 4321,
688            started_at_secs: 1_700_000_000,
689        };
690        let body = original.render();
691        let parsed = PidFileContents::parse(&body).expect("round-trip");
692        assert_eq!(parsed, original);
693    }
694
695    #[test]
696    fn pidfile_contents_rejects_missing_marker() {
697        // Same shape as the structured format but with the wrong marker
698        // — must be rejected so we don't mistake a foreign file for ours.
699        let body = "1234\nnot-heddle-agent\n100\n";
700        assert!(PidFileContents::parse(body).is_none());
701    }
702
703    #[test]
704    fn pidfile_contents_rejects_bare_pid() {
705        // Legacy single-integer pidfile body. Parser refuses because it
706        // can't verify the file is ours.
707        assert!(PidFileContents::parse("12345").is_none());
708    }
709
710    #[test]
711    fn executable_identity_accepts_same_canonical_path() {
712        let current = std::env::current_exe().unwrap();
713        assert!(executable_identity_matches(&current, &current));
714    }
715
716    #[test]
717    fn executable_identity_rejects_spoofed_heddle_path() {
718        let temp = TempDir::new().unwrap();
719        let spoofed = temp.path().join("contains-heddle").join("heddle-spoof");
720        std::fs::create_dir_all(spoofed.parent().unwrap()).unwrap();
721        std::fs::write(&spoofed, "not the current executable").unwrap();
722
723        let current = std::env::current_exe().unwrap();
724
725        assert!(
726            !executable_identity_matches(&spoofed, &current),
727            "a pathname containing heddle must not satisfy executable identity"
728        );
729    }
730
731    #[test]
732    fn is_heddle_process_accepts_self_pid() {
733        assert!(
734            is_heddle_process(std::process::id() as i32),
735            "the current process should resolve to the current executable"
736        );
737    }
738
739    #[test]
740    fn enforce_peer_uid_admits_matching_uid() {
741        // The everyday case: the CLI and the daemon run as the same user,
742        // so the peer's uid equals the daemon's. The gate must admit it.
743        assert!(enforce_peer_uid(1000, 1000).is_ok());
744    }
745
746    #[test]
747    fn enforce_peer_uid_rejects_mismatched_uid() {
748        // A connection from a different uid (only reachable if the socket's
749        // mode 0600 were somehow widened) must be refused with a Conflict.
750        let err = enforce_peer_uid(1001, 1000).unwrap_err();
751        assert!(
752            matches!(err, HeddleError::Conflict(_)),
753            "mismatched peer uid must be a Conflict, got {err:?}"
754        );
755    }
756
757    #[test]
758    fn guard_propagates_listener_io_errors() {
759        // Listener-level accept() errors must reach tonic unchanged — the
760        // peer-cred gate only drops mismatched peers, it never swallows
761        // I/O errors that tonic's own error handling should see.
762        let io_err = std::io::Error::other("accept failed");
763        let out = guard_peer_connection(Err(io_err));
764        assert!(matches!(out, Some(Err(_))), "io errors must propagate");
765    }
766
767    #[tokio::test]
768    async fn guard_admits_same_process_peer() {
769        // Both ends of a socketpair share this process's uid, so the gate
770        // must admit the connection — proving the serve-path filter does
771        // not reject the everyday same-user CLI connection.
772        let (peer, _local) = tokio::net::UnixStream::pair().expect("socketpair");
773        let out = guard_peer_connection(Ok(peer));
774        assert!(
775            matches!(out, Some(Ok(_))),
776            "a same-uid peer must be admitted by the gate"
777        );
778    }
779
780    #[tokio::test]
781    async fn check_peer_uid_matches_self_admits_socketpair() {
782        // Direct check on a real UnixStream: same-process socketpair ends
783        // share our uid, so the SO_PEERCRED / getpeereid comparison passes.
784        let (peer, _local) = tokio::net::UnixStream::pair().expect("socketpair");
785        assert!(check_peer_uid_matches_self(&peer).is_ok());
786    }
787}