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}