Skip to main content

agent_engine/
ipc.rs

1//! Daemon IPC endpoint — one API, two transports.
2//!
3//! * **Unix**: a Unix domain socket at the endpoint path (the daemon binds
4//!   it inside the 0700 run dir and chmods it 0600, see
5//!   `daemon::listener::bind`).
6//! * **Windows**: a named pipe whose name is derived from the endpoint path
7//!   ([`pipe_name`]), so `daemon.json`, [`crate::daemon::registry::DaemonPaths`]
8//!   and every caller keep speaking in paths; nothing is created on disk at
9//!   the path. The pipe carries what the socket's file mode carries on Unix:
10//!   - the server instances get an owner-only DACL (this user and SYSTEM)
11//!     and refuse remote clients — the 0600 analogue; and
12//!   - a client refuses a server process that does not run as this user —
13//!     the 0700-dir analogue: no other account can squat the name while no
14//!     daemon is serving it.
15//!
16//! Read halves report a peer that went away as EOF on both platforms (named
17//! pipes surface it as `BrokenPipe`), so frame readers keep one EOF path.
18
19use std::io;
20use std::path::Path;
21
22pub use imp::*;
23
24#[cfg(unix)]
25mod imp {
26    use super::*;
27    use tokio::net::{UnixListener, UnixStream};
28
29    /// A connection the daemon accepted.
30    pub type ServerStream = UnixStream;
31    pub type ServerReadHalf = tokio::net::unix::OwnedReadHalf;
32    pub type ServerWriteHalf = tokio::net::unix::OwnedWriteHalf;
33    /// A client's connection to the daemon.
34    pub type ClientStream = UnixStream;
35    pub type ClientReadHalf = tokio::net::unix::OwnedReadHalf;
36    pub type ClientWriteHalf = tokio::net::unix::OwnedWriteHalf;
37
38    pub fn split_server(s: ServerStream) -> (ServerReadHalf, ServerWriteHalf) {
39        s.into_split()
40    }
41
42    pub fn split_client(s: ClientStream) -> (ClientReadHalf, ClientWriteHalf) {
43        s.into_split()
44    }
45
46    pub async fn connect(path: &Path) -> io::Result<ClientStream> {
47        UnixStream::connect(path).await
48    }
49
50    /// The daemon's accept side.
51    pub struct Listener(UnixListener);
52
53    impl Listener {
54        /// Plain bind (tests, tools). The daemon binds through
55        /// `daemon::listener::bind`, which adds the 0600 / CLOEXEC handling.
56        pub fn bind(path: &Path) -> io::Result<Self> {
57            UnixListener::bind(path).map(Self)
58        }
59
60        pub async fn accept(&mut self) -> io::Result<ServerStream> {
61            self.0.accept().await.map(|(s, _)| s)
62        }
63
64        pub fn as_unix(&self) -> &UnixListener {
65            &self.0
66        }
67    }
68
69    impl From<UnixListener> for Listener {
70        fn from(l: UnixListener) -> Self {
71            Self(l)
72        }
73    }
74}
75
76#[cfg(windows)]
77mod imp {
78    use super::*;
79    use std::pin::Pin;
80    use std::task::{Context, Poll};
81    use std::time::{Duration, Instant};
82
83    use tokio::io::{AsyncRead, ReadBuf};
84    use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions};
85
86    /// A connection the daemon accepted.
87    pub type ServerStream = NamedPipeServer;
88    pub type ServerReadHalf = EofOnBrokenPipe<tokio::io::ReadHalf<NamedPipeServer>>;
89    pub type ServerWriteHalf = tokio::io::WriteHalf<NamedPipeServer>;
90    /// A client's connection to the daemon.
91    pub type ClientStream = NamedPipeClient;
92    pub type ClientReadHalf = EofOnBrokenPipe<tokio::io::ReadHalf<NamedPipeClient>>;
93    pub type ClientWriteHalf = tokio::io::WriteHalf<NamedPipeClient>;
94
95    pub fn split_server(s: ServerStream) -> (ServerReadHalf, ServerWriteHalf) {
96        let (r, w) = tokio::io::split(s);
97        (EofOnBrokenPipe(r), w)
98    }
99
100    pub fn split_client(s: ClientStream) -> (ClientReadHalf, ClientWriteHalf) {
101        let (r, w) = tokio::io::split(s);
102        (EofOnBrokenPipe(r), w)
103    }
104
105    /// A named pipe reports the other end closing as `BrokenPipe`; a Unix
106    /// socket reports it as a zero-byte read. Read halves map the former to
107    /// the latter so every frame reader has one EOF path.
108    pub struct EofOnBrokenPipe<R>(R);
109
110    impl<R: AsyncRead + Unpin> AsyncRead for EofOnBrokenPipe<R> {
111        fn poll_read(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &mut ReadBuf<'_>) -> Poll<io::Result<()>> {
112            match Pin::new(&mut self.0).poll_read(cx, buf) {
113                Poll::Ready(Err(e)) if e.kind() == io::ErrorKind::BrokenPipe => Poll::Ready(Ok(())),
114                other => other,
115            }
116        }
117    }
118
119    /// How long `connect` keeps retrying `ERROR_PIPE_BUSY` (every instance
120    /// is between an accept and the next instance's creation).
121    const BUSY_RETRY: Duration = Duration::from_secs(2);
122
123    /// Open the daemon's pipe and check that its server runs as this user.
124    /// `NotFound` when nobody serves the endpoint (the `ECONNREFUSED` /
125    /// `ENOENT` of the socket world).
126    pub async fn connect(path: &Path) -> io::Result<ClientStream> {
127        use std::os::windows::io::AsRawHandle;
128        let name = pipe_name(path);
129        let deadline = Instant::now() + BUSY_RETRY;
130        let client = loop {
131            match ClientOptions::new().open(&name) {
132                Ok(c) => break c,
133                Err(e)
134                    if e.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_PIPE_BUSY as i32)
135                        && Instant::now() < deadline =>
136                {
137                    tokio::time::sleep(Duration::from_millis(5)).await;
138                }
139                Err(e) => return Err(e),
140            }
141        };
142        win::verify_server_is_current_user(client.as_raw_handle())?;
143        Ok(client)
144    }
145
146    /// The daemon's accept side: always one listening pipe instance, so the
147    /// name is never unserved (and never up for grabs) while the daemon runs.
148    pub struct Listener {
149        name: String,
150        security: win::PipeSecurity,
151        next: NamedPipeServer,
152    }
153
154    impl Listener {
155        /// Create the first instance. Refuses a name that is already served
156        /// (`first_pipe_instance`) — never share an endpoint with another
157        /// process, the rule `bind` keeps on Unix by never unlinking first.
158        pub fn bind(path: &Path) -> io::Result<Self> {
159            let name = pipe_name(path);
160            let security = win::PipeSecurity::current_user_only()?;
161            let next = security.create(ServerOptions::new().first_pipe_instance(true).reject_remote_clients(true), &name)?;
162            Ok(Self { name, security, next })
163        }
164
165        fn instance(&self) -> io::Result<NamedPipeServer> {
166            self.security.create(ServerOptions::new().reject_remote_clients(true), &self.name)
167        }
168
169        /// Wait for a client on the listening instance and hand it out; a
170        /// fresh instance takes its place first. Cancel-safe (tokio's
171        /// `connect` is): a dropped accept resumes on the same instance.
172        pub async fn accept(&mut self) -> io::Result<ServerStream> {
173            if let Err(e) = self.next.connect().await {
174                // A client that came and went before the connect leaves the
175                // instance unusable: replace it (new one first).
176                if let Ok(fresh) = self.instance() {
177                    self.next = fresh;
178                }
179                return Err(e);
180            }
181            // On failure `next` stays the connected instance; the next accept
182            // returns at once (already connected) and retries the creation.
183            let fresh = self.instance()?;
184            Ok(std::mem::replace(&mut self.next, fresh))
185        }
186    }
187
188    /// A pipe instance with the daemon pipe's protections, for other
189    /// per-user endpoints (the session event pipes): an owner-only DACL
190    /// (this user and SYSTEM) and no remote clients. `first` refuses a name
191    /// that is already served.
192    pub(crate) fn create_owner_only_pipe(name: &str, first: bool) -> io::Result<NamedPipeServer> {
193        win::PipeSecurity::current_user_only()?
194            .create(ServerOptions::new().first_pipe_instance(first).reject_remote_clients(true), name)
195    }
196
197    /// Refuse a connected pipe whose server process runs as another account.
198    pub(crate) fn verify_pipe_server(client: &NamedPipeClient) -> io::Result<()> {
199        use std::os::windows::io::AsRawHandle;
200        win::verify_server_is_current_user(client.as_raw_handle())
201    }
202
203    /// The pipe name for an endpoint path. Hashed: pipe names live in one
204    /// flat, machine-wide namespace and may not contain `\`.
205    ///
206    /// On Unix every spelling of a socket path reaches the same inode; a
207    /// hash only matches the same string. So one spelling is hashed: the
208    /// absolute path with its directory resolved (`canonicalize`: 8.3 short
209    /// names, junctions, symlinks, `..`) when it exists, without the `\\?\`
210    /// prefix `canonicalize` adds, `\` separators, lowercase (paths are
211    /// case-insensitive) — the daemon's run dir is canonical while a
212    /// caller's may not be.
213    pub fn pipe_name(path: &Path) -> String {
214        use sha2::{Digest, Sha256};
215        let key = endpoint_key(path);
216        let digest = Sha256::digest(key.as_bytes());
217        let hex: String = digest[..16].iter().map(|b| format!("{b:02x}")).collect();
218        format!(r"\\.\pipe\synaps-daemon-{hex}")
219    }
220
221    fn endpoint_key(path: &Path) -> String {
222        let abs = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
223        let resolved = match (abs.parent(), abs.file_name()) {
224            (Some(dir), Some(name)) => dir.canonicalize().map(|d| d.join(name)).unwrap_or_else(|_| abs.clone()),
225            _ => abs,
226        };
227        let s = resolved.to_string_lossy().replace('/', "\\");
228        let s = if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
229            format!(r"\\{rest}")
230        } else if let Some(rest) = s.strip_prefix(r"\\?\") {
231            rest.to_string()
232        } else {
233            s
234        };
235        s.to_lowercase()
236    }
237
238    mod win {
239        use std::ffi::c_void;
240        use std::io;
241        use std::ptr::null_mut;
242
243        use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
244        use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, HANDLE};
245        use windows_sys::Win32::Security::Authorization::{
246            ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1,
247        };
248        use windows_sys::Win32::Security::{
249            EqualSid, GetTokenInformation, TokenUser, PSECURITY_DESCRIPTOR, PSID, SECURITY_ATTRIBUTES, TOKEN_QUERY,
250            TOKEN_USER,
251        };
252        use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId;
253        use windows_sys::Win32::System::Threading::{
254            GetCurrentProcess, OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION,
255        };
256
257        struct Handle(HANDLE);
258        impl Drop for Handle {
259            fn drop(&mut self) {
260                // SAFETY: a handle we opened and own.
261                unsafe { CloseHandle(self.0) };
262            }
263        }
264
265        /// A process token's `TOKEN_USER` (8-aligned: it holds pointers).
266        struct TokenUserBuf(Vec<u64>);
267
268        impl TokenUserBuf {
269            fn of(process: HANDLE) -> io::Result<Self> {
270                let mut token: HANDLE = null_mut();
271                // SAFETY: valid process handle and out-pointer.
272                if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
273                    return Err(io::Error::last_os_error());
274                }
275                let token = Handle(token);
276                let mut len = 0u32;
277                // SAFETY: size query (fails with ERROR_INSUFFICIENT_BUFFER, sets len).
278                unsafe { GetTokenInformation(token.0, TokenUser, null_mut(), 0, &mut len) };
279                if len == 0 {
280                    return Err(io::Error::last_os_error());
281                }
282                let mut buf = vec![0u64; (len as usize).div_ceil(8)];
283                // SAFETY: buf holds at least `len` bytes.
284                if unsafe { GetTokenInformation(token.0, TokenUser, buf.as_mut_ptr().cast(), len, &mut len) } == 0 {
285                    return Err(io::Error::last_os_error());
286                }
287                Ok(Self(buf))
288            }
289
290            fn current() -> io::Result<Self> {
291                // SAFETY: the pseudo-handle needs no close.
292                Self::of(unsafe { GetCurrentProcess() })
293            }
294
295            fn sid(&self) -> PSID {
296                // SAFETY: filled by GetTokenInformation(TokenUser).
297                unsafe { (*(self.0.as_ptr() as *const TOKEN_USER)).User.Sid }
298            }
299        }
300
301        fn sid_string(sid: PSID) -> io::Result<String> {
302            let mut s: *mut u16 = null_mut();
303            // SAFETY: valid SID; s receives a LocalAlloc'd string.
304            if unsafe { ConvertSidToStringSidW(sid, &mut s) } == 0 {
305                return Err(io::Error::last_os_error());
306            }
307            // SAFETY: NUL-terminated wide string from the call above.
308            let out = unsafe {
309                let len = (0..).take_while(|&i| *s.add(i) != 0).count();
310                String::from_utf16_lossy(std::slice::from_raw_parts(s, len))
311            };
312            // SAFETY: allocated by ConvertSidToStringSidW.
313            unsafe { LocalFree(s.cast()) };
314            Ok(out)
315        }
316
317        /// Security descriptor for daemon pipe instances: a protected DACL
318        /// granting full access to this user and SYSTEM only (the default
319        /// pipe DACL also grants Everyone read).
320        pub struct PipeSecurity(PSECURITY_DESCRIPTOR);
321
322        // SAFETY: an immutable, self-contained LocalAlloc'd descriptor.
323        unsafe impl Send for PipeSecurity {}
324        unsafe impl Sync for PipeSecurity {}
325
326        impl PipeSecurity {
327            pub fn current_user_only() -> io::Result<Self> {
328                let user = sid_string(TokenUserBuf::current()?.sid())?;
329                let sddl: Vec<u16> = format!("D:P(A;;GA;;;SY)(A;;GA;;;{user})").encode_utf16().chain([0]).collect();
330                let mut sd: PSECURITY_DESCRIPTOR = null_mut();
331                // SAFETY: NUL-terminated SDDL; sd receives a LocalAlloc'd descriptor.
332                if unsafe { ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.as_ptr(), SDDL_REVISION_1, &mut sd, null_mut()) } == 0 {
333                    return Err(io::Error::last_os_error());
334                }
335                Ok(Self(sd))
336            }
337
338            pub fn create(&self, opts: &ServerOptions, name: &str) -> io::Result<NamedPipeServer> {
339                let mut sa = SECURITY_ATTRIBUTES {
340                    nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
341                    lpSecurityDescriptor: self.0,
342                    bInheritHandle: 0,
343                };
344                // SAFETY: sa and the descriptor outlive the call.
345                unsafe { opts.create_with_security_attributes_raw(name, &mut sa as *mut SECURITY_ATTRIBUTES as *mut c_void) }
346            }
347        }
348
349        impl Drop for PipeSecurity {
350            fn drop(&mut self) {
351                // SAFETY: allocated by ConvertStringSecurityDescriptorToSecurityDescriptorW.
352                unsafe { LocalFree(self.0) };
353            }
354        }
355
356        /// Refuse a pipe whose server process runs as another account.
357        pub fn verify_server_is_current_user(pipe: std::os::windows::io::RawHandle) -> io::Result<()> {
358            let mut pid = 0u32;
359            // SAFETY: a connected client pipe handle.
360            if unsafe { GetNamedPipeServerProcessId(pipe as HANDLE, &mut pid) } == 0 {
361                return Err(io::Error::last_os_error());
362            }
363            let refuse = |why: String| {
364                io::Error::new(
365                    io::ErrorKind::PermissionDenied,
366                    format!("daemon pipe is served by pid {pid}, {why}; refusing to connect"),
367                )
368            };
369            // SAFETY: plain OpenProcess; null on failure.
370            let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
371            if process.is_null() {
372                return Err(refuse(format!("whose owner cannot be read ({})", io::Error::last_os_error())));
373            }
374            let process = Handle(process);
375            let theirs = TokenUserBuf::of(process.0).map_err(|e| refuse(format!("whose owner cannot be read ({e})")))?;
376            let ours = TokenUserBuf::current()?;
377            // SAFETY: two valid SIDs.
378            if unsafe { EqualSid(ours.sid(), theirs.sid()) } == 0 {
379                return Err(refuse("which runs as another user".into()));
380            }
381            Ok(())
382        }
383    }
384}
385
386#[cfg(all(test, windows))]
387mod tests {
388    use super::*;
389    use tokio::io::{AsyncReadExt, AsyncWriteExt};
390
391    fn endpoint() -> (tempfile::TempDir, std::path::PathBuf) {
392        let d = tempfile::tempdir().unwrap();
393        let p = d.path().join("daemon.sock");
394        (d, p)
395    }
396
397    #[test]
398    fn pipe_name_is_stable_and_case_and_separator_insensitive() {
399        let a = pipe_name(Path::new(r"C:\Users\Me\.synaps-cli\run\daemon.sock"));
400        let b = pipe_name(Path::new("c:/users/me/.synaps-cli/run/DAEMON.sock"));
401        assert_eq!(a, b);
402        assert!(a.starts_with(r"\\.\pipe\synaps-daemon-"));
403        assert_ne!(a, pipe_name(Path::new(r"C:\Users\Me\.synaps-cli\run\daemon-work.sock")));
404    }
405
406    #[test]
407    fn pipe_name_resolves_the_directory_spelling() {
408        let (d, p) = endpoint();
409        let canon = d.path().canonicalize().unwrap().join("daemon.sock");
410        assert!(canon.to_string_lossy().starts_with(r"\\?\"));
411        assert_eq!(pipe_name(&p), pipe_name(&canon));
412        let dotted = d.path().join("sub").join("..").join("daemon.sock");
413        assert_eq!(pipe_name(&p), pipe_name(&dotted));
414    }
415
416    #[tokio::test]
417    async fn round_trip_and_peer_close_reads_as_eof() {
418        let (_d, path) = endpoint();
419        let mut l = Listener::bind(&path).unwrap();
420        let srv = tokio::spawn(async move {
421            let s = l.accept().await.unwrap();
422            let (mut r, mut w) = split_server(s);
423            let mut buf = [0u8; 5];
424            r.read_exact(&mut buf).await.unwrap();
425            w.write_all(&buf).await.unwrap();
426            // The client closes: EOF, not BrokenPipe.
427            let mut rest = Vec::new();
428            r.read_to_end(&mut rest).await.unwrap();
429            assert!(rest.is_empty());
430        });
431        let c = connect(&path).await.unwrap();
432        let (mut r, mut w) = split_client(c);
433        w.write_all(b"hello").await.unwrap();
434        let mut buf = [0u8; 5];
435        r.read_exact(&mut buf).await.unwrap();
436        assert_eq!(&buf, b"hello");
437        drop((r, w));
438        srv.await.unwrap();
439    }
440
441    #[tokio::test]
442    async fn serves_several_clients_and_refuses_a_second_server() {
443        let (_d, path) = endpoint();
444        let mut l = Listener::bind(&path).unwrap();
445        assert!(Listener::bind(&path).is_err(), "a served name is never shared");
446        let srv = tokio::spawn(async move {
447            for _ in 0..3 {
448                let s = l.accept().await.unwrap();
449                let (_r, mut w) = split_server(s);
450                w.write_all(b"x").await.unwrap();
451            }
452        });
453        for _ in 0..3 {
454            let (mut r, _w) = split_client(connect(&path).await.unwrap());
455            let mut b = [0u8; 1];
456            r.read_exact(&mut b).await.unwrap();
457        }
458        srv.await.unwrap();
459    }
460
461    #[tokio::test]
462    async fn connect_without_a_server_is_not_found() {
463        let (_d, path) = endpoint();
464        let e = connect(&path).await.unwrap_err();
465        assert_eq!(e.kind(), io::ErrorKind::NotFound);
466    }
467}