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