Skip to main content

hotl_platform/ipc/
mod.rs

1//! [`Ipc`] — a private, same-user, local-only session endpoint.
2
3use std::future::Future;
4use std::io;
5use std::path::PathBuf;
6
7#[cfg(unix)]
8mod unix;
9#[cfg(unix)]
10pub use unix::{UnixIpc, UnixIpcListener, UnixIpcStream};
11#[cfg(unix)]
12pub type ActiveIpc = UnixIpc;
13
14#[cfg(windows)]
15mod windows;
16#[cfg(windows)]
17pub use windows::{WindowsIpc, WindowsIpcListener, WindowsIpcStream};
18#[cfg(windows)]
19pub type ActiveIpc = WindowsIpc;
20
21/// A private, same-user, local-only session endpoint.
22///
23/// CONTRACT: [`bind_private`](Ipc::bind_private) returns an endpoint reachable
24/// **only** by the current user, and that restriction is applied at bind, not
25/// after. The authorization boundary is the OS object's own access control —
26/// the `0600` mode on Unix, the DACL on Windows.
27/// [`authenticate_peer`](Ipc::authenticate_peer) is defence in depth and is
28/// never the only check.
29pub trait Ipc: crate::sealed::Sealed {
30    type Listener: IpcListener<Stream = Self::Stream>;
31    type Stream: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static;
32
33    /// Whether a dead server leaves an artifact that must be swept.
34    ///
35    /// Unix: `true` — a socket file, hence the caller's unlink guard. Windows:
36    /// `false`, because a named pipe is a kernel object that vanishes with its
37    /// last handle. The const says so, so the guard is a documented no-op
38    /// rather than dead machinery ported for symmetry.
39    const LEAVES_STALE_ARTIFACT: bool;
40
41    fn bind_private(&self, id: &str) -> io::Result<Self::Listener>;
42    fn connect(&self, id: &str) -> impl Future<Output = io::Result<Self::Stream>> + Send;
43
44    /// Reject a peer that is not the current user.
45    ///
46    /// Unix: `SO_PEERCRED` via `peer_cred()`. Windows:
47    /// `ImpersonateNamedPipeClient` + `EqualSid` + `RevertToSelf`, which is the
48    /// correct analogue because it captures the client's token *as of the
49    /// connection*.
50    ///
51    /// **Not `GetNamedPipeClientProcessId` + `OpenProcess`.** That is the
52    /// obvious-looking answer and it is racy — the pid can be reused between
53    /// the connect and the lookup, where `SO_PEERCRED` cannot. Written down
54    /// because someone will propose it.
55    fn authenticate_peer(&self, stream: &Self::Stream) -> Result<(), PeerReject>;
56
57    /// Is a server listening on `id`?
58    ///
59    /// Tri-state on purpose. A two-state bool is how the Windows port ships a
60    /// bug: `ERROR_PIPE_BUSY` means a server **exists** and all its instances
61    /// are busy — i.e. LIVE. Reading it as dead makes hotl steal a running
62    /// session's pipe name.
63    fn liveness(&self, id: &str) -> Liveness;
64
65    fn list_live(&self) -> Vec<String>;
66
67    /// The on-disk artifact for `id`, when the platform has one. `None` on
68    /// Windows, where there is nothing to unlink.
69    fn artifact_path(&self, id: &str) -> Option<PathBuf>;
70}
71
72/// Accepting is a method on the listener rather than the adapter, because a
73/// named-pipe server must create the *next* instance as part of accepting the
74/// current one — there is state to carry that a stateless call cannot.
75pub trait IpcListener: Send {
76    type Stream;
77    fn accept(&mut self) -> impl Future<Output = io::Result<Self::Stream>> + Send;
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Liveness {
82    Live,
83    Dead,
84}
85
86#[derive(Debug)]
87pub struct PeerReject(pub String);
88
89impl std::fmt::Display for PeerReject {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(f, "{}", self.0)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
99
100    /// One body, both transports: bind, connect, authenticate, round-trip a
101    /// frame, and see the endpoint go from live to dead.
102    #[test]
103    fn active_adapter_upholds_the_contract() {
104        // Hold the env lock: this test derives its socket path from `data()`,
105        // which a sibling test rewrites via `XDG_DATA_HOME`. See `ENV_GUARD`.
106        let _env = crate::ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
107        let rt = tokio::runtime::Builder::new_multi_thread()
108            .enable_all()
109            .build()
110            .unwrap();
111        rt.block_on(async {
112            let ipc = crate::IPC;
113            let id = format!("hotl-ipc-test-{}", std::process::id());
114            assert_eq!(ipc.liveness(&id), Liveness::Dead, "nothing bound yet");
115
116            let mut listener = ipc.bind_private(&id).unwrap();
117            assert_eq!(ipc.liveness(&id), Liveness::Live);
118            assert!(ipc.list_live().contains(&id), "a bound endpoint is listed");
119
120            // A real accept loop tolerates a peer that connects and vanishes,
121            // and it must here: the two `liveness` probes above each left a
122            // dead connection queued in the backlog ahead of the real client.
123            //
124            // Authentication alone cannot be the filter, and that is a platform
125            // difference worth stating: macOS's `getpeereid` *fails* on a peer
126            // that already closed, so a dead connection is rejected there by
127            // accident. Linux's `SO_PEERCRED` still reports the credentials of
128            // the peer that connected, so the same dead connection
129            // authenticates fine and is only distinguishable by the fact that
130            // it never says anything. Discard on either signal and keep
131            // accepting — which is exactly what `session_server::authenticate`
132            // does with its bounded handshake read.
133            let server = tokio::spawn(async move {
134                loop {
135                    let s = listener.accept().await.unwrap();
136                    if crate::IPC.authenticate_peer(&s).is_err() {
137                        continue;
138                    }
139                    let (r, mut w) = tokio::io::split(s);
140                    let mut lines = BufReader::new(r).lines();
141                    let Ok(Some(got)) = lines.next_line().await else {
142                        continue; // connected and vanished — a probe, not a client
143                    };
144                    w.write_all(format!("echo:{got}\n").as_bytes())
145                        .await
146                        .unwrap();
147                    w.flush().await.unwrap();
148                    return;
149                }
150            });
151
152            let client = ipc.connect(&id).await.unwrap();
153            let (r, mut w) = tokio::io::split(client);
154            w.write_all(b"ping\n").await.unwrap();
155            w.flush().await.unwrap();
156            let mut lines = BufReader::new(r).lines();
157            assert_eq!(lines.next_line().await.unwrap().unwrap(), "echo:ping");
158            server.await.unwrap();
159
160            // An adapter that names an artifact must admit it leaves one, and
161            // vice versa — the const and the path have to agree or a caller's
162            // sweep is either dead code or a missing cleanup.
163            assert_eq!(
164                ipc.artifact_path(&id).is_some(),
165                <ActiveIpc as Ipc>::LEAVES_STALE_ARTIFACT
166            );
167            if let Some(p) = ipc.artifact_path(&id) {
168                let _ = std::fs::remove_file(p);
169            }
170        });
171    }
172}