Skip to main content

winx_code_agent/daemon/
transport.rs

1//! Control-plane transport: Unix-domain sockets on Unix, named pipes on Windows.
2//!
3//! The JSON-RPC layer only sees [`DaemonStream`] (an `AsyncRead + AsyncWrite`
4//! value) and [`DaemonListener`]. On Windows the "socket path" keeps its role
5//! as the session identity: the listener creates a marker file at that path
6//! and derives the pipe name from it, so every lifecycle routine that scans,
7//! probes, or removes `*.sock` files works unchanged on both platforms.
8
9use std::io;
10use std::path::Path;
11
12#[cfg(unix)]
13pub use unix_impl::{connect, peer_is_same_user, DaemonListener, DaemonStream};
14#[cfg(windows)]
15pub use windows_impl::{connect, peer_is_same_user, pipe_name, DaemonListener, DaemonStream};
16
17/// Restrict a filesystem artifact to its owner. Unix applies `mode`; Windows
18/// relies on the per-user ACL that `%LOCALAPPDATA%` already carries.
19#[cfg_attr(not(unix), allow(clippy::unused_async))] // only the Unix branch awaits
20pub async fn restrict_to_owner(path: &Path, mode: u32) -> io::Result<()> {
21    #[cfg(unix)]
22    {
23        use std::os::unix::fs::PermissionsExt as _;
24        tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).await
25    }
26    #[cfg(not(unix))]
27    {
28        let _ = (path, mode);
29        Ok(())
30    }
31}
32
33#[cfg(unix)]
34mod unix_impl {
35    use std::io;
36    use std::path::Path;
37
38    use tokio::net::{UnixListener, UnixStream};
39
40    pub type DaemonStream = UnixStream;
41
42    pub struct DaemonListener {
43        inner: UnixListener,
44    }
45
46    impl DaemonListener {
47        pub fn bind(path: &Path) -> io::Result<Self> {
48            Ok(Self { inner: UnixListener::bind(path)? })
49        }
50
51        pub async fn accept(&mut self) -> io::Result<DaemonStream> {
52            self.inner.accept().await.map(|(stream, _)| stream)
53        }
54    }
55
56    pub async fn connect(path: &Path) -> io::Result<DaemonStream> {
57        UnixStream::connect(path).await
58    }
59
60    /// Only the daemon's own uid may drive it; `SO_PEERCRED` is authoritative.
61    pub fn peer_is_same_user(stream: &DaemonStream) -> io::Result<bool> {
62        Ok(stream.peer_cred()?.uid() == crate::os::unix::effective_uid())
63    }
64}
65
66#[cfg(windows)]
67mod windows_impl {
68    use std::io;
69    use std::path::{Path, PathBuf};
70    use std::pin::Pin;
71    use std::task::{Context, Poll};
72    use std::time::{Duration, Instant};
73
74    use sha2::{Digest as _, Sha256};
75    use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
76    use tokio::net::windows::named_pipe::{
77        ClientOptions, NamedPipeClient, NamedPipeServer, ServerOptions,
78    };
79    use windows_sys::Win32::Foundation::ERROR_PIPE_BUSY;
80
81    /// How long a client keeps retrying while every pipe instance is busy.
82    /// The listener always keeps a spare instance, so this only covers the
83    /// instant between one client's `connect` and the next instance's creation.
84    const PIPE_BUSY_RETRY_WINDOW: Duration = Duration::from_secs(2);
85
86    /// Deterministic pipe name for a socket path: the same path always maps to
87    /// the same pipe, so clients need nothing but the path, and two different
88    /// paths (two runtime directories) never collide.
89    pub fn pipe_name(path: &Path) -> String {
90        let normalized = path.to_string_lossy().to_lowercase();
91        let digest = Sha256::digest(normalized.as_bytes());
92        let mut tag = String::with_capacity(24);
93        for byte in &digest[..12] {
94            use std::fmt::Write as _;
95            let _ = write!(tag, "{byte:02x}");
96        }
97        format!(r"\\.\pipe\winx-{tag}")
98    }
99
100    /// One accepted or dialed pipe connection.
101    #[derive(Debug)]
102    pub enum DaemonStream {
103        Server(NamedPipeServer),
104        Client(NamedPipeClient),
105    }
106
107    impl AsyncRead for DaemonStream {
108        fn poll_read(
109            self: Pin<&mut Self>,
110            cx: &mut Context<'_>,
111            buf: &mut ReadBuf<'_>,
112        ) -> Poll<io::Result<()>> {
113            match self.get_mut() {
114                Self::Server(pipe) => Pin::new(pipe).poll_read(cx, buf),
115                Self::Client(pipe) => Pin::new(pipe).poll_read(cx, buf),
116            }
117        }
118    }
119
120    impl AsyncWrite for DaemonStream {
121        fn poll_write(
122            self: Pin<&mut Self>,
123            cx: &mut Context<'_>,
124            data: &[u8],
125        ) -> Poll<io::Result<usize>> {
126            match self.get_mut() {
127                Self::Server(pipe) => Pin::new(pipe).poll_write(cx, data),
128                Self::Client(pipe) => Pin::new(pipe).poll_write(cx, data),
129            }
130        }
131
132        fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
133            match self.get_mut() {
134                Self::Server(pipe) => Pin::new(pipe).poll_flush(cx),
135                Self::Client(pipe) => Pin::new(pipe).poll_flush(cx),
136            }
137        }
138
139        fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
140            match self.get_mut() {
141                Self::Server(pipe) => Pin::new(pipe).poll_shutdown(cx),
142                Self::Client(pipe) => Pin::new(pipe).poll_shutdown(cx),
143            }
144        }
145    }
146
147    impl DaemonStream {
148        /// Non-blocking read used by idle health connections to notice a peer
149        /// that went away.
150        pub fn try_read(&self, buf: &mut [u8]) -> io::Result<usize> {
151            match self {
152                Self::Server(pipe) => pipe.try_read(buf),
153                Self::Client(pipe) => pipe.try_read(buf),
154            }
155        }
156    }
157
158    /// Named-pipe listener that owns the marker file at the socket path.
159    pub struct DaemonListener {
160        name: String,
161        marker: PathBuf,
162        spare: Option<NamedPipeServer>,
163    }
164
165    impl DaemonListener {
166        pub fn bind(path: &Path) -> io::Result<Self> {
167            let name = pipe_name(path);
168            // `first_pipe_instance` makes a second daemon fail to bind instead
169            // of silently sharing the name; `reject_remote_clients` keeps the
170            // pipe off the network. The default DACL grants other local users
171            // read-only access at most, and every Winx client opens read/write,
172            // so a foreign user cannot complete a connection.
173            let spare = ServerOptions::new()
174                .first_pipe_instance(true)
175                .reject_remote_clients(true)
176                .create(&name)?;
177            std::fs::write(path, format!("{name}\n{}\n", std::process::id()))?;
178            Ok(Self { name, marker: path.to_path_buf(), spare: Some(spare) })
179        }
180
181        pub async fn accept(&mut self) -> io::Result<DaemonStream> {
182            let server = match self.spare.take() {
183                Some(server) => server,
184                None => ServerOptions::new().reject_remote_clients(true).create(&self.name)?,
185            };
186            server.connect().await?;
187            // Create the next instance before handing this one out so a client
188            // racing in finds a free instance instead of `ERROR_PIPE_BUSY`.
189            self.spare = ServerOptions::new().reject_remote_clients(true).create(&self.name).ok();
190            Ok(DaemonStream::Server(server))
191        }
192
193        pub fn marker_path(&self) -> &Path {
194            &self.marker
195        }
196    }
197
198    /// Dial the daemon behind `path`. A missing server surfaces as
199    /// `NotFound`, which callers already treat like a refused Unix socket.
200    pub async fn connect(path: &Path) -> io::Result<DaemonStream> {
201        let name = pipe_name(path);
202        let deadline = Instant::now() + PIPE_BUSY_RETRY_WINDOW;
203        loop {
204            match ClientOptions::new().open(&name) {
205                Ok(client) => return Ok(DaemonStream::Client(client)),
206                Err(error)
207                    if error.raw_os_error() == Some(ERROR_PIPE_BUSY.cast_signed())
208                        && Instant::now() < deadline =>
209                {
210                    tokio::time::sleep(Duration::from_millis(20)).await;
211                }
212                Err(error) => return Err(error),
213            }
214        }
215    }
216
217    /// See [`DaemonListener::bind`]: the pipe ACL already excludes other users
218    /// from read/write connections, so an accepted connection is same-user.
219    pub fn peer_is_same_user(_stream: &DaemonStream) -> io::Result<bool> {
220        Ok(true)
221    }
222
223    #[cfg(test)]
224    mod tests {
225        use super::pipe_name;
226        use std::path::Path;
227
228        #[test]
229        fn pipe_names_are_stable_and_case_insensitive() {
230            let lower = pipe_name(Path::new(r"c:\users\x\winx\winxd.sock"));
231            let upper = pipe_name(Path::new(r"C:\Users\X\winx\winxd.sock"));
232            assert_eq!(lower, upper);
233            assert!(lower.starts_with(r"\\.\pipe\winx-"));
234            assert_ne!(lower, pipe_name(Path::new(r"C:\Users\X\winx\other.sock")));
235        }
236    }
237}