Skip to main content

rightkit_daemon/
transport.rs

1//! Current-user-only local transport: Unix socket (macOS, other Unix) and named
2//! pipe (Windows). One request is in flight per connection (lock-step), so a
3//! connection never needs concurrent reads and writes on one handle.
4
5use crate::host_identity::{HostConnectError, HostExpectation, HostProcess};
6use crate::suite::Suite;
7use std::io::{self, Read, Write};
8use std::sync::Arc;
9
10/// Outcome of accepting one connection.
11pub(crate) enum Accepted {
12    /// The peer was authenticated as the current user.
13    Conn(Conn),
14    /// The peer failed OS-level authentication and was closed unread. The reason is the
15    /// typed peer error, content-free.
16    Rejected(String),
17    /// The peer left before it could be identified (a probe, or the shutdown wake-up).
18    /// Closed; not worth a rejection event.
19    Vanished,
20}
21
22/// Default for [`HostOptions::max_pipe_instances`](crate::HostOptions::max_pipe_instances).
23pub const DEFAULT_MAX_PIPE_INSTANCES: u32 = 16;
24
25/// An accepted or connected duplex stream.
26pub(crate) struct Conn {
27    pub reader: Box<dyn Read + Send>,
28    pub writer: Box<dyn Write + Send>,
29    /// Non-blocking: has the peer closed its end? Safe to call from another thread.
30    pub peer_closed: Arc<dyn Fn() -> bool + Send + Sync>,
31    pub shutdown: Arc<dyn Fn() + Send + Sync>,
32    /// Who the OS says is on the other end. Filled in for an accepted connection, where the
33    /// peer check has just run; empty for a connection this process opened.
34    pub peer: crate::peer::PeerIdentity,
35}
36
37fn fnv(s: &str) -> u32 {
38    let mut h: u32 = 0x811c_9dc5;
39    for b in s
40        .to_lowercase()
41        .replace('\\', "/")
42        .trim_end_matches('/')
43        .bytes()
44    {
45        h ^= u32::from(b);
46        h = h.wrapping_mul(0x0100_0193);
47    }
48    h
49}
50
51/// Longest Unix socket path this crate will bind: `sockaddr_un.sun_path` is 104 bytes on macOS
52/// and 108 on Linux, each including the trailing NUL, so 103 is the portable ceiling.
53pub const MAX_UNIX_SOCKET_PATH: usize = 103;
54
55/// In-suite socket paths longer than this move to the per-user temp dir. Kept below
56/// [`MAX_UNIX_SOCKET_PATH`] on purpose: it is the historical threshold and changing it would move
57/// the endpoint of services that bind today.
58const IN_SUITE_PATH_LIMIT: usize = 100;
59
60/// The endpoint for a service cannot be named within the OS socket path limit, even at the
61/// short fallback location. Names the offending path, its length and the limit.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct EndpointError {
64    pub path: String,
65    pub len: usize,
66    pub limit: usize,
67}
68
69impl std::fmt::Display for EndpointError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(
72            f,
73            "service socket path {:?} is {} bytes, over the {}-byte limit; set a shorter TMPDIR",
74            self.path, self.len, self.limit
75        )
76    }
77}
78impl std::error::Error for EndpointError {}
79
80/// 64-bit FNV-1a. Stable across Rust versions and processes (std's hashers are not), so a
81/// client and a server derive the same fallback name.
82fn fnv1a64(s: &str) -> u64 {
83    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
84    for b in s.bytes() {
85        h ^= u64::from(b);
86        h = h.wrapping_mul(0x0000_0100_0000_01b3);
87    }
88    h
89}
90
91/// File name of the bounded fallback socket: `rk-<tag>-<fnv1a64(service)>.sock`, 33 bytes
92/// whatever the service name length (the tag is 8 hex digits, the hash 16).
93fn fallback_file_name(tag: &str, service: &str) -> String {
94    format!("rk-{tag}-{:016x}.sock", fnv1a64(service))
95}
96
97/// The fallback name used before the bounded form existed: `rk-<tag>-<service>.sock`.
98fn legacy_fallback_file_name(tag: &str, service: &str) -> String {
99    format!("rk-{tag}-{service}.sock")
100}
101
102fn suite_tag(suite: &Suite) -> String {
103    format!("{:08x}", fnv(&suite.root().to_string_lossy()))
104}
105
106/// Unix endpoint with the temp dir injected (the seam tests use). Returns the in-suite path when
107/// it fits, else the bounded fallback under `temp`, else a typed error.
108fn unix_endpoint_in(
109    suite: &Suite,
110    service: &str,
111    temp: &std::path::Path,
112) -> Result<String, EndpointError> {
113    let p = suite.service_dir(service).join(format!("{service}.sock"));
114    if p.as_os_str().len() <= IN_SUITE_PATH_LIMIT {
115        return Ok(p.to_string_lossy().into_owned());
116    }
117    let f = temp
118        .join(fallback_file_name(&suite_tag(suite), service))
119        .to_string_lossy()
120        .into_owned();
121    if f.len() <= MAX_UNIX_SOCKET_PATH {
122        Ok(f)
123    } else {
124        Err(EndpointError {
125            len: f.len(),
126            path: f,
127            limit: MAX_UNIX_SOCKET_PATH,
128        })
129    }
130}
131
132/// The pre-upgrade fallback path, only when it would have fitted the socket limit (so an old
133/// host could actually have bound it). `None` means there is nothing to stay compatible with.
134fn legacy_unix_endpoint_in(suite: &Suite, service: &str, temp: &std::path::Path) -> Option<String> {
135    let p = suite.service_dir(service).join(format!("{service}.sock"));
136    if p.as_os_str().len() <= IN_SUITE_PATH_LIMIT {
137        return None;
138    }
139    let f = temp
140        .join(legacy_fallback_file_name(&suite_tag(suite), service))
141        .to_string_lossy()
142        .into_owned();
143    (f.len() <= MAX_UNIX_SOCKET_PATH).then_some(f)
144}
145
146/// Endpoint for `service` on `suite`, or the typed reason none can be named. Windows: a pipe
147/// name that carries the user and a hash of the suite root, so an isolated suite never collides
148/// with the user's installed host (at most 96 characters for a 48-byte service name and a
149/// 20-character user, far under the 256-character pipe name limit). Unix: a socket in the
150/// private service dir, or, when that path would exceed the `sockaddr_un` limit, a bounded
151/// `rk-<tag>-<hash>.sock` in the per-user temp dir.
152///
153/// This is the single place an endpoint is computed; the host binds it and publishes it in its
154/// discovery record, and clients connect to the recorded endpoint.
155pub fn try_endpoint_for(suite: &Suite, service: &str) -> Result<String, EndpointError> {
156    if cfg!(windows) {
157        let tag = suite_tag(suite);
158        // `USERNAME` stays: it is only a label in the pipe name (so two users' pipes do not
159        // collide), never a root, trust or mode selector. A launcher that changes it changes only
160        // the name host and client compute; the pipe's current-user DACL and the client's
161        // same-user check still decide who may connect. No API alternative in the current
162        // dependency features, and none is added.
163        let user: String = std::env::var("USERNAME")
164            .unwrap_or_default()
165            .chars()
166            .filter(|c| c.is_ascii_alphanumeric())
167            .take(20)
168            .collect();
169        Ok(format!(r"\\.\pipe\rightkit-{service}-{user}-{tag}"))
170    } else {
171        unix_endpoint_in(suite, service, &std::env::temp_dir())
172    }
173}
174
175/// [`try_endpoint_for`] for callers that cannot handle the error: on failure it returns the path
176/// that did not fit, so a bind attempt fails with the OS error rather than panicking here.
177pub fn endpoint_for(suite: &Suite, service: &str) -> String {
178    try_endpoint_for(suite, service).unwrap_or_else(|e| e.path)
179}
180
181/// Endpoints a client may find a live host on, in order: the current endpoint, then (Unix only)
182/// the pre-upgrade long fallback name when it would have fitted. Compatibility decision: for one
183/// release a new client still finds an old host on the old fallback name; that name is
184/// connect-only and is never bound. Where the old name never fitted the limit no old host could
185/// have bound it, so there is no second candidate.
186pub fn endpoint_candidates(suite: &Suite, service: &str) -> Vec<String> {
187    candidates_in(suite, service, &std::env::temp_dir())
188}
189
190fn candidates_in(suite: &Suite, service: &str, temp: &std::path::Path) -> Vec<String> {
191    let mut v = Vec::new();
192    if cfg!(windows) {
193        if let Ok(e) = try_endpoint_for(suite, service) {
194            v.push(e);
195        }
196        return v;
197    }
198    if let Ok(e) = unix_endpoint_in(suite, service, temp) {
199        v.push(e);
200    }
201    if let Some(old) = legacy_unix_endpoint_in(suite, service, temp) {
202        if !v.contains(&old) {
203            v.push(old);
204        }
205    }
206    v
207}
208
209/// The first of [`endpoint_candidates`] that answers a connect.
210pub fn find_live_endpoint(suite: &Suite, service: &str) -> Option<String> {
211    endpoint_candidates(suite, service)
212        .into_iter()
213        .find(|e| probe_endpoint(e))
214}
215
216/// Does something answer at `endpoint`? (Connect, then drop.)
217pub fn probe_endpoint(endpoint: &str) -> bool {
218    connect(endpoint).is_ok()
219}
220
221#[cfg(unix)]
222mod imp {
223    use super::*;
224    use std::os::fd::AsRawFd;
225    use std::os::unix::fs::{MetadataExt, PermissionsExt};
226    use std::os::unix::net::{UnixListener, UnixStream};
227    use std::path::PathBuf;
228
229    use crate::peer::{verify_peer_is_current_user, PeerError, PeerIdentity};
230
231    extern "C" {
232        fn recv(fd: i32, buf: *mut u8, len: usize, flags: i32) -> isize;
233    }
234    // MSG_PEEK | MSG_DONTWAIT: 0x2 | 0x80 on macOS, 0x2 | 0x40 on Linux.
235    #[cfg(target_os = "macos")]
236    const PEEK_NOWAIT: i32 = 0x2 | 0x80;
237    #[cfg(not(target_os = "macos"))]
238    const PEEK_NOWAIT: i32 = 0x2 | 0x40;
239
240    pub fn current_uid() -> u32 {
241        crate::peer::current_euid()
242    }
243
244    /// Authenticate `s` with `verify` before it is wrapped: nothing is read from a
245    /// stream that fails. The stream is dropped (closed) on every non-`Conn` outcome.
246    fn admit(
247        s: UnixStream,
248        verify: impl FnOnce(&UnixStream) -> Result<PeerIdentity, PeerError>,
249    ) -> io::Result<Accepted> {
250        match verify(&s) {
251            Ok(peer) => wrap(s).map(|conn| Accepted::Conn(Conn { peer, ..conn })),
252            Err(e) if e.peer_gone() => Ok(Accepted::Vanished),
253            Err(e) => Ok(Accepted::Rejected(e.to_string())),
254        }
255    }
256
257    fn wrap(s: UnixStream) -> io::Result<Conn> {
258        let r = s.try_clone()?;
259        let w = s.try_clone()?;
260        let watch = s.try_clone()?;
261        let sd = s;
262        Ok(Conn {
263            peer: crate::peer::PeerIdentity::default(),
264            reader: Box::new(r),
265            writer: Box::new(w),
266            peer_closed: Arc::new(move || {
267                let mut b = [0u8; 1];
268                let n = unsafe { recv(watch.as_raw_fd(), b.as_mut_ptr(), 1, PEEK_NOWAIT) };
269                n == 0
270                    || (n < 0
271                        && !matches!(
272                            io::Error::last_os_error().kind(),
273                            io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
274                        ))
275            }),
276            shutdown: Arc::new(move || {
277                let _ = sd.shutdown(std::net::Shutdown::Both);
278            }),
279        })
280    }
281
282    pub struct Listener {
283        l: UnixListener,
284        path: PathBuf,
285    }
286
287    impl Listener {
288        /// `max_instances` is a Windows pipe-instance cap; a Unix socket has no such notion.
289        pub fn bind(endpoint: &str, _max_instances: u32) -> io::Result<Listener> {
290            let path = PathBuf::from(endpoint);
291            if let Ok(md) = std::fs::symlink_metadata(&path) {
292                // Only replace a dead socket that we own; never follow or unlink anything else.
293                if md.uid() != current_uid() || !md.file_type().is_socket_like() {
294                    return Err(io::Error::new(
295                        io::ErrorKind::AddrInUse,
296                        "endpoint path is occupied by something that is not our socket",
297                    ));
298                }
299                if UnixStream::connect(&path).is_ok() {
300                    return Err(io::Error::new(
301                        io::ErrorKind::AddrInUse,
302                        "a live host already serves this endpoint",
303                    ));
304                }
305                let _ = std::fs::remove_file(&path);
306            }
307            let l = UnixListener::bind(&path)?;
308            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
309            Ok(Listener { l, path })
310        }
311        /// A peer that is not the current user is `Rejected`; the caller keeps accepting.
312        pub fn accept(&self) -> io::Result<Accepted> {
313            let (s, _) = self.l.accept()?;
314            admit(s, verify_peer_is_current_user)
315        }
316        #[cfg(test)]
317        pub fn accept_with(
318            &self,
319            verify: impl FnOnce(&UnixStream) -> Result<PeerIdentity, PeerError>,
320        ) -> io::Result<Accepted> {
321            let (s, _) = self.l.accept()?;
322            admit(s, verify)
323        }
324        pub fn wake(&self) {
325            let _ = UnixStream::connect(&self.path);
326        }
327        pub fn cleanup(&self) {
328            let _ = std::fs::remove_file(&self.path);
329        }
330    }
331
332    trait SocketLike {
333        fn is_socket_like(&self) -> bool;
334    }
335    impl SocketLike for std::fs::FileType {
336        fn is_socket_like(&self) -> bool {
337            use std::os::unix::fs::FileTypeExt;
338            self.is_socket()
339        }
340    }
341
342    /// Connect, then verify the server is the current user before any byte is exchanged
343    /// (a squatter on the endpoint path would otherwise receive our `hello`).
344    pub fn connect(endpoint: &str) -> io::Result<Conn> {
345        let s = UnixStream::connect(endpoint)?;
346        verify_peer_is_current_user(&s).map_err(io::Error::from)?;
347        wrap(s)
348    }
349
350    /// Connect, then verify WHO the host is before any byte is exchanged: the same-user check
351    /// (reported as a typed identity refusal here) and the host's executable against `expect`.
352    /// The only thing that has happened on the wire when this returns an error is the kernel-level
353    /// `connect`: the host sees a connection that closes without a byte, and the stream is dropped
354    /// (closed) on every error path.
355    pub fn connect_expecting(
356        endpoint: &str,
357        expect: &HostExpectation,
358    ) -> Result<(Conn, HostProcess), HostConnectError> {
359        let s = UnixStream::connect(endpoint).map_err(HostConnectError::Io)?;
360        let host = crate::host_identity::verify_unix_host(&s, expect)?;
361        let conn = wrap(s).map_err(HostConnectError::Io)?;
362        Ok((conn, host))
363    }
364}
365
366#[cfg(windows)]
367mod imp {
368    use super::*;
369    use crate::peer::{
370        owner_only_security_attributes, verify_pipe_peer_is_current_user,
371        verify_pipe_server_is_current_user, OwnerOnlySecurityAttributes,
372    };
373    use std::os::windows::io::RawHandle;
374    use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering};
375    use std::sync::{Mutex, RwLock};
376    use windows::core::PCWSTR;
377    use windows::Win32::Foundation::{CloseHandle, HANDLE};
378    use windows::Win32::Storage::FileSystem::{
379        ReadFile, WriteFile, FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAG_OVERLAPPED,
380        PIPE_ACCESS_DUPLEX,
381    };
382    use windows::Win32::System::Pipes::{
383        ConnectNamedPipe, CreateNamedPipeW, PeekNamedPipe, PIPE_READMODE_BYTE,
384        PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT,
385    };
386    use windows::Win32::System::Threading::CreateEventW;
387    use windows::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED};
388
389    pub fn current_uid() -> u32 {
390        0
391    }
392
393    struct SendHandle(HANDLE);
394    unsafe impl Send for SendHandle {}
395
396    pub struct Listener {
397        name: Vec<u16>,
398        attrs: OwnerOnlySecurityAttributes,
399        /// Cap on simultaneous instances of this pipe (live connections + the one waiting).
400        max_instances: u32,
401        first: AtomicBool,
402        endpoint: String,
403        /// The first instance, created at bind time (FILE_FLAG_FIRST_PIPE_INSTANCE) and
404        /// kept open so the name is never unowned between bind and the first accept.
405        pending: Mutex<Option<SendHandle>>,
406    }
407
408    fn wide(s: &str) -> Vec<u16> {
409        s.encode_utf16().chain(std::iter::once(0)).collect()
410    }
411
412    const ERROR_BROKEN_PIPE: u32 = 109;
413    const ERROR_IO_PENDING: u32 = 997;
414    const ERROR_OPERATION_ABORTED: u32 = 995;
415    const ERROR_NO_DATA: u32 = 232;
416    const ERROR_PIPE_CONNECTED: u32 = 535;
417    const FILE_FLAG_OVERLAPPED_BITS: u32 = 0x4000_0000;
418    // CreateFile security quality-of-service flags (winbase.h).
419    const SECURITY_SQOS_PRESENT_BITS: u32 = 0x0010_0000;
420    const SECURITY_IDENTIFICATION_BITS: u32 = 0x0001_0000;
421
422    fn code(e: &windows::core::Error) -> u32 {
423        (e.code().0 as u32) & 0xFFFF
424    }
425
426    /// One pipe handle opened with FILE_FLAG_OVERLAPPED. Reads and writes each use
427    /// their own OVERLAPPED + event, so a read blocked in one thread never blocks a
428    /// write, a peer check, or `cancel` from another thread (a synchronous handle
429    /// serialises all I/O on the file object). `cancel` issues CancelIoEx, which
430    /// completes every pending operation with ERROR_OPERATION_ABORTED.
431    struct PipeInner {
432        /// `Some(handle)` until `cancel` closes it. Operations hold the read lock for
433        /// their whole (possibly blocked) duration; `cancel` takes the write lock only
434        /// after CancelIoEx has released them, so the handle is never closed under I/O.
435        h: RwLock<Option<HANDLE>>,
436        raw: AtomicIsize,
437        cancelled: AtomicBool,
438        closing: Mutex<()>,
439    }
440    unsafe impl Send for PipeInner {}
441    unsafe impl Sync for PipeInner {}
442    impl Drop for PipeInner {
443        fn drop(&mut self) {
444            if let Some(h) = self.h.get_mut().unwrap_or_else(|e| e.into_inner()).take() {
445                unsafe {
446                    let _ = CloseHandle(h);
447                }
448            }
449        }
450    }
451
452    #[derive(Clone)]
453    struct Pipe(Arc<PipeInner>);
454
455    impl Pipe {
456        fn new(h: HANDLE) -> Pipe {
457            Pipe(Arc::new(PipeInner {
458                h: RwLock::new(Some(h)),
459                raw: AtomicIsize::new(h.0 as isize),
460                cancelled: AtomicBool::new(false),
461                closing: Mutex::new(()),
462            }))
463        }
464
465        /// Interrupt every pending operation, then close the handle so the peer sees
466        /// the disconnect now rather than when the last clone drops. Idempotent.
467        fn cancel(&self) {
468            let _one = self.0.closing.lock().unwrap_or_else(|e| e.into_inner());
469            self.0.cancelled.store(true, Ordering::SeqCst);
470            let raw = HANDLE(self.0.raw.load(Ordering::SeqCst) as *mut _);
471            if self.0.h.read().map(|g| g.is_some()).unwrap_or(false) {
472                // Safe: only `cancel` (serialised by `closing`) ever closes the handle.
473                unsafe {
474                    let _ = CancelIoEx(raw, None);
475                }
476            }
477            let mut g = self.0.h.write().unwrap_or_else(|e| e.into_inner()); // waits for aborted ops to unwind
478            if let Some(h) = g.take() {
479                unsafe {
480                    let _ = CloseHandle(h);
481                }
482            }
483        }
484
485        /// Run one overlapped operation to completion. `start` issues the call with
486        /// the prepared OVERLAPPED; the wait happens here, outside any shared lock.
487        fn run(
488            &self,
489            start: impl FnOnce(HANDLE, *mut OVERLAPPED) -> windows::core::Result<()>,
490        ) -> io::Result<usize> {
491            if self.0.cancelled.load(Ordering::SeqCst) {
492                return Err(io::Error::new(
493                    io::ErrorKind::ConnectionAborted,
494                    "connection closed",
495                ));
496            }
497            let guard = self.0.h.read().unwrap_or_else(|e| e.into_inner());
498            let Some(handle) = *guard else {
499                return Err(io::Error::new(
500                    io::ErrorKind::ConnectionAborted,
501                    "connection closed",
502                ));
503            };
504            unsafe {
505                let ev =
506                    CreateEventW(None, true, false, PCWSTR::null()).map_err(io::Error::other)?;
507                let mut ov = OVERLAPPED {
508                    hEvent: ev,
509                    ..Default::default()
510                };
511                let started = start(handle, &mut ov);
512                // `cancel` may have run its CancelIoEx before this operation was issued;
513                // re-check now that it is registered so it cannot wait forever.
514                if self.0.cancelled.load(Ordering::SeqCst) {
515                    let _ = CancelIoEx(handle, Some(&ov));
516                }
517                let outcome = match started {
518                    Ok(()) => Ok(true),
519                    Err(e) if code(&e) == ERROR_IO_PENDING => Ok(true),
520                    // ConnectNamedPipe: the client connected before the call. Nothing to wait for.
521                    Err(e) if code(&e) == ERROR_PIPE_CONNECTED => Ok(false),
522                    Err(e) => Err(e),
523                };
524                let res = match outcome {
525                    Ok(false) => Ok(0),
526                    Ok(true) => {
527                        let mut n = 0u32;
528                        // bWait = true: blocks only this call; CancelIoEx releases it.
529                        match GetOverlappedResult(handle, &ov, &mut n, true) {
530                            Ok(()) => Ok(n as usize),
531                            Err(e) => Err(e),
532                        }
533                    }
534                    Err(e) => Err(e),
535                };
536                let _ = CloseHandle(ev);
537                match res {
538                    Ok(n) => Ok(n),
539                    Err(e) => match code(&e) {
540                        ERROR_BROKEN_PIPE | ERROR_NO_DATA => {
541                            Err(io::Error::new(io::ErrorKind::BrokenPipe, "pipe closed"))
542                        }
543                        ERROR_OPERATION_ABORTED => Err(io::Error::new(
544                            io::ErrorKind::ConnectionAborted,
545                            "operation cancelled",
546                        )),
547                        _ => Err(io::Error::other(e)),
548                    },
549                }
550            }
551        }
552    }
553
554    struct Half(Pipe);
555    impl Read for Half {
556        fn read(&mut self, b: &mut [u8]) -> io::Result<usize> {
557            match self
558                .0
559                .run(|h, ov| unsafe { ReadFile(h, Some(b), None, Some(ov)) })
560            {
561                Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(0), // peer closed: EOF
562                r => r,
563            }
564        }
565    }
566    impl Write for Half {
567        fn write(&mut self, b: &[u8]) -> io::Result<usize> {
568            self.0
569                .run(|h, ov| unsafe { WriteFile(h, Some(b), None, Some(ov)) })
570        }
571        fn flush(&mut self) -> io::Result<()> {
572            Ok(()) // writes complete when the pipe buffer accepts them
573        }
574    }
575
576    fn wrap(pipe: Pipe) -> Conn {
577        let (w, watch, sd) = (pipe.clone(), pipe.clone(), pipe.clone());
578        Conn {
579            peer: crate::peer::PeerIdentity::default(),
580            reader: Box::new(Half(pipe)),
581            writer: Box::new(Half(w)),
582            peer_closed: Arc::new(move || {
583                if watch.0.cancelled.load(Ordering::SeqCst) {
584                    return true;
585                }
586                // PeekNamedPipe never blocks and is not serialised with a pending read.
587                let g = watch.0.h.read().unwrap_or_else(|e| e.into_inner());
588                match *g {
589                    Some(h) => unsafe { PeekNamedPipe(h, None, 0, None, None, None).is_err() },
590                    None => true,
591                }
592            }),
593            // Real cancellation: pending reads/writes return now; the handle closes
594            // when the last clone (reader, writer, this closure) drops.
595            shutdown: Arc::new(move || sd.cancel()),
596        }
597    }
598
599    impl Listener {
600        /// `max_instances` is clamped to 1..=254 (255 means unlimited to Win32). Every
601        /// instance of one pipe name must pass the same value.
602        pub fn bind(endpoint: &str, max_instances: u32) -> io::Result<Listener> {
603            let attrs = owner_only_security_attributes()?;
604            let l = Listener {
605                name: wide(endpoint),
606                attrs,
607                max_instances: max_instances.clamp(1, 254),
608                first: AtomicBool::new(true),
609                endpoint: endpoint.to_string(),
610                pending: Mutex::new(None),
611            };
612            // Create the first instance now so a squatter fails the bind, not a later accept.
613            // FILE_FLAG_FIRST_PIPE_INSTANCE on a name whose previous owner is still closing its
614            // instances fails ERROR_ACCESS_DENIED (5): report that as the address being in use.
615            let h = l.instance().map_err(|e| {
616                if e.raw_os_error() == Some(5) {
617                    io::Error::new(
618                        io::ErrorKind::AddrInUse,
619                        format!("pipe {endpoint} still held: {e}"),
620                    )
621                } else {
622                    e
623                }
624            })?;
625            *l.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(SendHandle(h));
626            Ok(l)
627        }
628
629        fn instance(&self) -> io::Result<HANDLE> {
630            let mut open = PIPE_ACCESS_DUPLEX;
631            if self.first.swap(false, Ordering::SeqCst) {
632                open |= FILE_FLAG_FIRST_PIPE_INSTANCE;
633            }
634            let h = unsafe {
635                CreateNamedPipeW(
636                    PCWSTR(self.name.as_ptr()),
637                    open | FILE_FLAG_OVERLAPPED,
638                    PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
639                    self.max_instances,
640                    64 * 1024,
641                    64 * 1024,
642                    0,
643                    Some(self.attrs.raw()),
644                )
645            };
646            if h.is_invalid() {
647                return Err(io::Error::last_os_error());
648            }
649            Ok(h)
650        }
651
652        pub fn accept(&self) -> io::Result<Accepted> {
653            let pending = self
654                .pending
655                .lock()
656                .unwrap_or_else(|e| e.into_inner())
657                .take();
658            let h = match pending {
659                Some(p) => p.0,
660                None => self.instance()?,
661            };
662            let pipe = Pipe::new(h); // owns (and on error closes) the instance
663            let connected = pipe.run(|h, ov| unsafe { ConnectNamedPipe(h, Some(ov)) });
664            connected.map_err(|e| io::Error::new(e.kind(), format!("accept: {e}")))?;
665            // Authenticate before a single byte is read. A rejected instance is closed
666            // (the peer sees a disconnect); the next accept creates a fresh instance.
667            let raw = pipe.0.raw.load(Ordering::SeqCst) as RawHandle;
668            match verify_pipe_peer_is_current_user(raw) {
669                Ok(peer) => Ok(Accepted::Conn(Conn { peer, ..wrap(pipe) })),
670                Err(e) => {
671                    pipe.cancel();
672                    if e.peer_gone() {
673                        Ok(Accepted::Vanished)
674                    } else {
675                        Ok(Accepted::Rejected(e.to_string()))
676                    }
677                }
678            }
679        }
680        pub fn wake(&self) {
681            let _ = open_overlapped(&self.endpoint);
682        }
683        pub fn cleanup(&self) {}
684    }
685
686    /// Connect, then verify the server process runs as the current user before any byte is
687    /// exchanged. The pipe is opened with SQOS + SECURITY_IDENTIFICATION, so the server can
688    /// identify the caller but never impersonate it.
689    pub fn connect(endpoint: &str) -> io::Result<Conn> {
690        let pipe = open_overlapped(endpoint)?;
691        let raw = pipe.0.raw.load(Ordering::SeqCst) as RawHandle;
692        if let Err(e) = verify_pipe_server_is_current_user(raw) {
693            pipe.cancel();
694            return Err(e.into());
695        }
696        Ok(wrap(pipe))
697    }
698
699    /// Connect, verify the server is the current user, then verify WHICH program it is before any
700    /// byte is exchanged (see `host_identity::verify_pipe_host`); the pipe is cancelled (closed)
701    /// on every error path.
702    pub fn connect_expecting(
703        endpoint: &str,
704        expect: &HostExpectation,
705    ) -> Result<(Conn, HostProcess), HostConnectError> {
706        let pipe = open_overlapped(endpoint).map_err(HostConnectError::Io)?;
707        let raw = pipe.0.raw.load(Ordering::SeqCst) as RawHandle;
708        if let Err(e) = verify_pipe_server_is_current_user(raw) {
709            pipe.cancel();
710            return Err(HostConnectError::Io(e.into()));
711        }
712        match crate::host_identity::verify_pipe_host(raw, expect) {
713            Ok(host) => Ok((wrap(pipe), host)),
714            Err(e) => {
715                pipe.cancel();
716                Err(e)
717            }
718        }
719    }
720
721    fn open_overlapped(endpoint: &str) -> io::Result<Pipe> {
722        use std::os::windows::fs::OpenOptionsExt;
723        use std::os::windows::io::IntoRawHandle;
724        const ERROR_FILE_NOT_FOUND: i32 = 2;
725        const ERROR_PIPE_BUSY: i32 = 231;
726        let start = std::time::Instant::now();
727        loop {
728            match std::fs::OpenOptions::new()
729                .read(true)
730                .write(true)
731                .custom_flags(FILE_FLAG_OVERLAPPED_BITS)
732                .security_qos_flags(SECURITY_SQOS_PRESENT_BITS | SECURITY_IDENTIFICATION_BITS)
733                .open(endpoint)
734            {
735                Ok(f) => return Ok(Pipe::new(HANDLE(f.into_raw_handle() as *mut _))),
736                Err(e) => {
737                    let limit = match e.raw_os_error() {
738                        Some(ERROR_PIPE_BUSY) => std::time::Duration::from_secs(3),
739                        Some(ERROR_FILE_NOT_FOUND) => std::time::Duration::from_secs(1),
740                        _ => return Err(e),
741                    };
742                    if start.elapsed() >= limit {
743                        return Err(e);
744                    }
745                    std::thread::sleep(std::time::Duration::from_millis(25));
746                }
747            }
748        }
749    }
750}
751
752pub(crate) use imp::{connect, connect_expecting, current_uid, Listener};
753
754/// Is process `pid` alive? A dead pid marks a discovery record stale and a
755/// takeover request abandoned. Hardened from HeardRight's version: pid 0 and
756/// pids that do not fit `pid_t` are never alive (they would signal process
757/// groups), and on Windows an exited process whose handle is still open is dead.
758#[cfg(unix)]
759pub fn pid_alive(pid: u32) -> bool {
760    extern "C" {
761        fn kill(pid: i32, sig: i32) -> i32;
762    }
763    if pid == 0 || pid > i32::MAX as u32 {
764        return false;
765    }
766    unsafe {
767        kill(pid as i32, 0) == 0 || io::Error::last_os_error().raw_os_error() == Some(1)
768        /* EPERM: exists */
769    }
770}
771#[cfg(windows)]
772pub fn pid_alive(pid: u32) -> bool {
773    use windows::Win32::Foundation::{CloseHandle, ERROR_ACCESS_DENIED};
774    use windows::Win32::System::Threading::{
775        GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
776    };
777    const STILL_ACTIVE: u32 = 259;
778    if pid == 0 {
779        return false;
780    }
781    unsafe {
782        match OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) {
783            Ok(h) => {
784                let mut code = 0u32;
785                let ok = GetExitCodeProcess(h, &mut code).is_ok();
786                let _ = CloseHandle(h);
787                ok && code == STILL_ACTIVE
788            }
789            Err(e) => e.code() == ERROR_ACCESS_DENIED.to_hresult(),
790        }
791    }
792}
793
794/// A raw duplex byte stream to an endpoint, for callers that speak their own
795/// line protocol over the shared transport (HeardRight's v1 `EngineFrame`
796/// dialect, which has no `hello`). Same current-user-only transport as
797/// [`crate::Client`].
798pub struct Stream {
799    pub reader: Box<dyn Read + Send>,
800    pub writer: Box<dyn Write + Send>,
801    shutdown: Arc<dyn Fn() + Send + Sync>,
802}
803
804impl Stream {
805    /// Abort the stream from any thread: a blocked read/write returns, and the
806    /// host sees a disconnect (which cancels the request it is serving).
807    pub fn abort_handle(&self) -> Arc<dyn Fn() + Send + Sync> {
808        self.shutdown.clone()
809    }
810}
811
812/// Connect to `endpoint`. On Windows a busy pipe is retried up to 3 s and a
813/// not-yet-created one up to 1 s (HeardRight's behaviour).
814///
815/// The host is checked to be the current user and nothing more: whichever same-user program
816/// answers at `endpoint` receives whatever you write. Use [`open_stream_expecting`] to name the
817/// program you mean to talk to.
818pub fn open_stream(endpoint: &str) -> io::Result<Stream> {
819    let c = connect(endpoint)?;
820    Ok(Stream {
821        reader: c.reader,
822        writer: c.writer,
823        shutdown: c.shutdown,
824    })
825}
826
827/// [`open_stream`] that first verifies the host is the program `expect` names, before a byte is
828/// written (see [`Client::connect_expecting`](crate::Client::connect_expecting) for the checks and
829/// what each platform verifies). A host that is not the expected one is
830/// [`ConnectError::HostIdentity`](crate::ConnectError::HostIdentity); a host that cannot be
831/// reached is [`ConnectError::Service`](crate::ConnectError::Service) with the retryable
832/// `service_unavailable`.
833pub fn open_stream_expecting(
834    endpoint: &str,
835    expect: &HostExpectation,
836) -> Result<Stream, crate::ConnectError> {
837    match connect_expecting(endpoint, expect) {
838        Ok((c, _host)) => Ok(Stream {
839            reader: c.reader,
840            writer: c.writer,
841            shutdown: c.shutdown,
842        }),
843        Err(HostConnectError::Io(e)) => Err(crate::ConnectError::Service(
844            crate::ServiceError::retryable(
845                crate::ERROR_UNAVAILABLE,
846                format!("host unreachable: {e}"),
847            ),
848        )),
849        Err(HostConnectError::Identity(e)) => Err(crate::ConnectError::HostIdentity(e)),
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use std::sync::atomic::{AtomicU32, Ordering};
857
858    static SEQ: AtomicU32 = AtomicU32::new(0);
859
860    fn unique(tag: &str) -> String {
861        let n = SEQ.fetch_add(1, Ordering::SeqCst);
862        let t = std::time::SystemTime::now()
863            .duration_since(std::time::UNIX_EPOCH)
864            .map(|d| d.subsec_nanos())
865            .unwrap_or(0);
866        format!("{tag}-{}-{n}-{t:x}", std::process::id())
867    }
868
869    #[cfg(unix)]
870    fn endpoint(dir: &tempfile::TempDir) -> String {
871        dir.path().join("t.sock").to_string_lossy().into_owned()
872    }
873    #[cfg(windows)]
874    fn endpoint(_: &NoDir) -> String {
875        format!(r"\\.\pipe\rightkit-transport-test-{}", unique("p"))
876    }
877    #[cfg(unix)]
878    fn tmp() -> tempfile::TempDir {
879        tempfile::Builder::new().prefix("rk").tempdir().unwrap()
880    }
881    /// Named pipes need no directory; this stands in for the Unix temporary directory.
882    #[cfg(windows)]
883    struct NoDir;
884    #[cfg(windows)]
885    fn tmp() -> NoDir {
886        NoDir
887    }
888
889    /// A same-user client and host round-trip over the real transport, with the peer
890    /// check running on both ends (accept and connect).
891    #[test]
892    fn same_user_round_trip_authenticates_both_ends() {
893        let _ = unique("x");
894        let dir = tmp();
895        let ep = endpoint(&dir);
896        let l = Listener::bind(&ep, 4).unwrap();
897        let client_ep = ep.clone();
898        let t = std::thread::spawn(move || {
899            let mut c = connect(&client_ep).expect("same-user server must authenticate");
900            c.writer.write_all(b"ping\n").unwrap();
901            c.writer.flush().unwrap();
902            let mut buf = [0u8; 5];
903            c.reader.read_exact(&mut buf).unwrap();
904            buf
905        });
906        let Accepted::Conn(mut conn) = l.accept().unwrap() else {
907            panic!("same-user peer must be accepted");
908        };
909        let mut buf = [0u8; 5];
910        conn.reader.read_exact(&mut buf).unwrap();
911        assert_eq!(&buf, b"ping\n");
912        conn.writer.write_all(b"pong\n").unwrap();
913        conn.writer.flush().unwrap();
914        assert_eq!(&t.join().unwrap(), b"pong\n");
915        l.cleanup();
916    }
917
918    /// A peer the verifier refuses is closed unread and reported as Rejected.
919    #[cfg(unix)]
920    #[test]
921    fn rejected_peer_is_closed_unread() {
922        use crate::peer::{PeerError, PeerIdentity};
923        let dir = tmp();
924        let ep = endpoint(&dir);
925        let l = Listener::bind(&ep, 4).unwrap();
926        let client_ep = ep.clone();
927        let t = std::thread::spawn(move || {
928            let mut s = std::os::unix::net::UnixStream::connect(&client_ep).unwrap();
929            let _ = s.write_all(b"{\"type\":\"hello\"}\n");
930            let mut b = [0u8; 1];
931            // The host closes without reading or answering: EOF (or a reset), never data.
932            matches!(s.read(&mut b), Ok(0) | Err(_))
933        });
934        let foreign = PeerIdentity {
935            uid: Some(crate::peer::current_euid().wrapping_add(1)),
936            ..Default::default()
937        };
938        match l
939            .accept_with(|_| Err(PeerError::Mismatch(foreign)))
940            .unwrap()
941        {
942            Accepted::Rejected(reason) => assert!(reason.contains("not the current user")),
943            _ => panic!("a foreign peer must be rejected"),
944        }
945        assert!(
946            t.join().unwrap(),
947            "rejected client must see a close, not a reply"
948        );
949        l.cleanup();
950    }
951
952    /// A platform that cannot authenticate the peer rejects instead of admitting it.
953    #[cfg(unix)]
954    #[test]
955    fn unsupported_verifier_never_admits() {
956        use crate::peer::PeerError;
957        let dir = tmp();
958        let ep = endpoint(&dir);
959        let l = Listener::bind(&ep, 4).unwrap();
960        let client_ep = ep.clone();
961        let t = std::thread::spawn(move || std::os::unix::net::UnixStream::connect(client_ep));
962        let a = l
963            .accept_with(|_| Err(PeerError::Unsupported("test")))
964            .unwrap();
965        assert!(matches!(a, Accepted::Rejected(_)));
966        let _ = t.join();
967        l.cleanup();
968    }
969
970    #[cfg(windows)]
971    #[test]
972    fn public_pipe_verifiers_accept_a_same_user_pipe() {
973        use crate::peer::{
974            owner_only_security_attributes, verify_pipe_peer_is_current_user,
975            verify_pipe_server_is_current_user,
976        };
977        use std::os::windows::fs::OpenOptionsExt;
978        use std::os::windows::io::{AsRawHandle, RawHandle};
979        use windows::core::PCWSTR;
980        use windows::Win32::Security::SECURITY_ATTRIBUTES;
981        use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX;
982        use windows::Win32::System::Pipes::{
983            ConnectNamedPipe, CreateNamedPipeW, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE,
984            PIPE_WAIT,
985        };
986        let name = format!(r"\\.\pipe\rightkit-verify-test-{}", unique("v"));
987        let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
988        let attrs = owner_only_security_attributes().unwrap();
989        let server = unsafe {
990            CreateNamedPipeW(
991                PCWSTR(wide.as_ptr()),
992                PIPE_ACCESS_DUPLEX,
993                PIPE_TYPE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS,
994                1,
995                4096,
996                4096,
997                0,
998                Some(attrs.as_ptr() as *const SECURITY_ATTRIBUTES),
999            )
1000        };
1001        assert!(!server.is_invalid());
1002        let client_name = name.clone();
1003        let t = std::thread::spawn(move || {
1004            std::fs::OpenOptions::new()
1005                .read(true)
1006                .write(true)
1007                .security_qos_flags(0x0010_0000 | 0x0001_0000)
1008                .open(client_name)
1009                .unwrap()
1010        });
1011        // Blocking ConnectNamedPipe returns once the client has opened the pipe.
1012        unsafe { ConnectNamedPipe(server, None) }.ok();
1013        let client = t.join().unwrap();
1014        let id = verify_pipe_peer_is_current_user(server.0 as RawHandle).unwrap();
1015        assert_eq!(id.pid, Some(std::process::id()));
1016        assert_eq!(
1017            id.sid,
1018            Some(crate::peer::current_user_sid_string().unwrap())
1019        );
1020        let id = verify_pipe_server_is_current_user(client.as_raw_handle()).unwrap();
1021        assert_eq!(id.pid, Some(std::process::id()));
1022        let _ = unsafe { windows::Win32::Foundation::CloseHandle(server) };
1023    }
1024
1025    // ---- endpoint naming (gap ledger row 167) ----
1026
1027    fn long_service() -> String {
1028        format!("{}-instance", "a".repeat(39))
1029    }
1030
1031    #[test]
1032    fn fallback_file_name_is_pinned_and_bounded() {
1033        // FNV-1a 64 of the service name; changing this string breaks client/server agreement.
1034        assert_eq!(
1035            fallback_file_name("0badc0de", &long_service()),
1036            "rk-0badc0de-3a43f919c4e992b6.sock"
1037        );
1038        assert_eq!(
1039            fallback_file_name("0badc0de", "speech"),
1040            "rk-0badc0de-c1d6288e168cb0d7.sock"
1041        );
1042        assert_eq!(fallback_file_name("0badc0de", &long_service()).len(), 33);
1043        assert_eq!(fnv1a64(""), 0xcbf2_9ce4_8422_2325);
1044    }
1045
1046    #[cfg(unix)]
1047    fn long_root() -> tempfile::TempDir {
1048        // About 60 bytes: long enough that the old `rk-<tag>-<service>.sock` (65 bytes for a
1049        // 48-byte name) never fitted, short enough for the bounded name (33 bytes).
1050        let d = tempfile::Builder::new()
1051            .prefix(&"r".repeat(49))
1052            .tempdir_in("/tmp")
1053            .unwrap();
1054        let n = d.path().as_os_str().len();
1055        assert!((50..=69).contains(&n), "root is {n} bytes");
1056        d
1057    }
1058
1059    #[cfg(unix)]
1060    #[test]
1061    fn a_48_byte_service_name_binds_and_connects_under_a_long_temp_root() {
1062        let suite = Suite::per_run_temp("ep-long").unwrap();
1063        let service = long_service();
1064        assert_eq!(service.len(), 48);
1065        let root = long_root();
1066        let ep = unix_endpoint_in(&suite, &service, root.path()).unwrap();
1067        assert!(ep.starts_with(root.path().to_str().unwrap()));
1068        assert!(
1069            ep.len() <= MAX_UNIX_SOCKET_PATH,
1070            "{ep} is {} bytes",
1071            ep.len()
1072        );
1073        // The pre-fix name for the same inputs would not have fitted.
1074        assert_eq!(legacy_unix_endpoint_in(&suite, &service, root.path()), None);
1075        let l = Listener::bind(&ep, DEFAULT_MAX_PIPE_INSTANCES).unwrap();
1076        assert!(connect(&ep).is_ok());
1077        l.cleanup();
1078        // Both ends derive the same path from the same inputs.
1079        assert_eq!(
1080            candidates_in(&suite, &service, root.path()),
1081            vec![ep.clone()]
1082        );
1083    }
1084
1085    #[cfg(unix)]
1086    #[test]
1087    fn a_new_client_still_finds_an_old_host_on_the_old_fallback_name() {
1088        let suite = Suite::per_run_temp("ep-old").unwrap();
1089        // A 20-byte name with a short temp root: the old name fitted, so an old host may hold it.
1090        let service = "legacy-fallback-name";
1091        let root = tempfile::Builder::new()
1092            .prefix("rk")
1093            .tempdir_in("/tmp")
1094            .unwrap();
1095        // Only reachable when the in-suite path is too long, as under a long $TMPDIR.
1096        if suite
1097            .service_dir(service)
1098            .join(format!("{service}.sock"))
1099            .as_os_str()
1100            .len()
1101            <= IN_SUITE_PATH_LIMIT
1102        {
1103            return;
1104        }
1105        let new = unix_endpoint_in(&suite, service, root.path()).unwrap();
1106        let old = legacy_unix_endpoint_in(&suite, service, root.path()).expect("old name fitted");
1107        assert_ne!(new, old);
1108        assert_eq!(
1109            candidates_in(&suite, service, root.path()),
1110            vec![new.clone(), old.clone()]
1111        );
1112        // An old host holds only the old name.
1113        let _old_host = std::os::unix::net::UnixListener::bind(&old).unwrap();
1114        let live = candidates_in(&suite, service, root.path())
1115            .into_iter()
1116            .find(|e| probe_endpoint(e));
1117        assert_eq!(live.as_deref(), Some(old.as_str()));
1118        // A new host on the new name wins over the old one (it is listed first).
1119        let l = Listener::bind(&new, DEFAULT_MAX_PIPE_INSTANCES).unwrap();
1120        let live = candidates_in(&suite, service, root.path())
1121            .into_iter()
1122            .find(|e| probe_endpoint(e));
1123        assert_eq!(live.as_deref(), Some(new.as_str()));
1124        l.cleanup();
1125    }
1126
1127    #[cfg(unix)]
1128    #[test]
1129    fn an_unfittable_temp_root_is_a_typed_error_naming_path_length_and_limit() {
1130        let suite = Suite::per_run_temp("ep-absurd").unwrap();
1131        let service = long_service();
1132        let temp = std::path::PathBuf::from(format!("/{}", "t".repeat(90)));
1133        let e = unix_endpoint_in(&suite, &service, &temp).unwrap_err();
1134        assert_eq!(e.limit, MAX_UNIX_SOCKET_PATH);
1135        assert_eq!(e.len, e.path.len());
1136        assert!(e.len > e.limit);
1137        assert!(e.path.starts_with(temp.to_str().unwrap()));
1138        let msg = e.to_string();
1139        assert!(msg.contains(&e.path) && msg.contains(&e.len.to_string()) && msg.contains("103"));
1140        assert!(legacy_unix_endpoint_in(&suite, &service, &temp).is_none());
1141        assert!(candidates_in(&suite, &service, &temp).is_empty());
1142    }
1143}