Skip to main content

sentinelpass_protocol/transport/
unix.rs

1//! Unix domain socket connection (client side; symmetric, also used by the
2//! core server's accept loop).
3
4use super::{TransportError, TransportResult, MAX_MESSAGE_SIZE};
5use std::path::{Path, PathBuf};
6use tokio::io::{AsyncReadExt, AsyncWriteExt};
7
8/// Validate that the socket's parent directory is a PRIVATE runtime
9/// directory (WBS-507): owned by the effective UID, mode 0700, and not a
10/// symlink. When `create` is set (server/default path), a missing directory
11/// is created owner-only; clients never create — they refuse.
12///
13/// This is what removes the `/tmp` fallback in practice: ANY socket path
14/// (default or custom) whose directory is not owner-only is refused by both
15/// the daemon and the clients, fail-closed.
16pub fn ensure_private_socket_dir(socket_path: &Path, create: bool) -> TransportResult<()> {
17    use std::os::unix::fs::PermissionsExt;
18
19    let Some(dir) = socket_path.parent() else {
20        return Err(TransportError::Other(format!(
21            "socket path has no parent directory: {}",
22            socket_path.display()
23        )));
24    };
25
26    match std::fs::symlink_metadata(dir) {
27        Ok(meta) => {
28            if meta.file_type().is_symlink() {
29                return Err(TransportError::Other(format!(
30                    "refusing IPC socket directory (symlink): {}",
31                    dir.display()
32                )));
33            }
34            use std::os::unix::fs::MetadataExt;
35            if meta.uid() != unsafe { libc::geteuid() } {
36                return Err(TransportError::Other(format!(
37                    "refusing IPC socket directory (not owned by the current user): {}",
38                    dir.display()
39                )));
40            }
41            let mode = meta.permissions().mode();
42            if mode & 0o077 != 0 {
43                return Err(TransportError::Other(format!(
44                    "refusing IPC socket directory (not owner-only, mode {:o}): {} \
45                     — the daemon and clients only accept sockets inside a private \
46                     runtime directory (0700)",
47                    mode & 0o777,
48                    dir.display()
49                )));
50            }
51            Ok(())
52        }
53        Err(e) if e.kind() == std::io::ErrorKind::NotFound && create => {
54            std::fs::create_dir_all(dir).map_err(TransportError::Io)?;
55            std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
56                .map_err(TransportError::Io)?;
57            Ok(())
58        }
59        Err(e) => Err(TransportError::Io(e)),
60    }
61}
62
63/// Unix socket connection
64pub struct UnixSocketConnection {
65    stream: tokio::net::UnixStream,
66}
67
68impl UnixSocketConnection {
69    /// Wrap an accepted (server-side) stream.
70    pub fn from_stream(stream: tokio::net::UnixStream) -> Self {
71        Self { stream }
72    }
73
74    /// Create a new connection as a client
75    pub async fn connect(path: PathBuf) -> TransportResult<Self> {
76        // WBS-507: clients refuse sockets outside a private runtime dir.
77        ensure_private_socket_dir(&path, false)?;
78
79        let stream = tokio::net::UnixStream::connect(&path).await.map_err(|e| {
80            TransportError::ConnectionFailed(format!(
81                "Failed to connect to {}: {}",
82                path.display(),
83                e
84            ))
85        })?;
86
87        Ok(Self { stream })
88    }
89
90    /// Read a message from the connection
91    pub async fn read_message(&mut self) -> TransportResult<Vec<u8>> {
92        // Read message length (4 bytes, big-endian)
93        let mut length_buf = [0u8; 4];
94        self.stream.read_exact(&mut length_buf).await?;
95
96        let length = u32::from_be_bytes(length_buf) as usize;
97
98        if length == 0 || length > MAX_MESSAGE_SIZE {
99            return Err(TransportError::MessageTooLarge {
100                size: length,
101                max: MAX_MESSAGE_SIZE,
102            });
103        }
104
105        // Read message payload
106        let mut buffer = vec![0u8; length];
107        self.stream.read_exact(&mut buffer).await?;
108
109        Ok(buffer)
110    }
111
112    /// Write a message to the connection
113    pub async fn write_message(&mut self, data: &[u8]) -> TransportResult<()> {
114        let length = data.len() as u32;
115
116        // Validate message size
117        if length as usize > MAX_MESSAGE_SIZE {
118            return Err(TransportError::MessageTooLarge {
119                size: data.len(),
120                max: MAX_MESSAGE_SIZE,
121            });
122        }
123
124        // Write length prefix
125        self.stream.write_all(&length.to_be_bytes()).await?;
126
127        // Write payload
128        self.stream.write_all(data).await?;
129
130        self.stream.flush().await?;
131
132        Ok(())
133    }
134
135    /// Close the connection
136    pub async fn close(&mut self) -> TransportResult<()> {
137        self.stream.shutdown().await?;
138        Ok(())
139    }
140
141    /// Check if the connection is still open
142    pub fn is_open(&self) -> bool {
143        // Try to get the peer address to check if still connected
144        self.stream.peer_addr().is_ok()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::time::Duration;
152
153    fn private_dir() -> PathBuf {
154        // tempfile dirs can be 0755 on some platforms; make the fixture a
155        // valid private runtime dir explicitly.
156        let dir = tempfile::TempDir::new().unwrap().keep();
157        {
158            use std::os::unix::fs::PermissionsExt;
159            std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
160        }
161        dir
162    }
163
164    #[tokio::test]
165    async fn test_unix_socket_connection_roundtrip() {
166        let dir = private_dir();
167        let socket_path = dir.join(format!("test_ipc_{}.sock", uuid_v4()));
168
169        // Start server
170        let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
171        let server_handle = tokio::spawn(async move {
172            let (stream, _) = listener.accept().await.unwrap();
173            let mut conn = UnixSocketConnection::from_stream(stream);
174            let msg = conn.read_message().await.unwrap();
175            conn.write_message(&msg).await.unwrap();
176            conn.close().await.unwrap();
177        });
178
179        // Connect as client
180        tokio::time::sleep(Duration::from_millis(100)).await;
181        let mut client = UnixSocketConnection::connect(socket_path).await.unwrap();
182
183        // Send and receive
184        let test_data = b"Hello, IPC!";
185        client.write_message(test_data).await.unwrap();
186        let received = client.read_message().await.unwrap();
187
188        assert_eq!(received, test_data);
189
190        server_handle.await.unwrap();
191    }
192
193    /// WBS-507 negative: a client refuses a socket whose directory is not
194    /// owner-only (e.g. a world-traversable /tmp-style directory).
195    #[tokio::test]
196    async fn client_refuses_socket_in_loose_directory() {
197        let outer = tempfile::TempDir::new().unwrap();
198        let loose = outer.path().join("loose");
199        std::fs::create_dir_all(&loose).unwrap();
200        {
201            use std::os::unix::fs::PermissionsExt;
202            std::fs::set_permissions(&loose, std::fs::Permissions::from_mode(0o755)).unwrap();
203        }
204        let socket_path = loose.join("sock");
205
206        let err = match UnixSocketConnection::connect(socket_path).await {
207            Err(err) => err,
208            Ok(_) => panic!("loose socket dir must be refused"),
209        };
210        assert!(
211            err.to_string().contains("owner-only"),
212            "refusal must name the policy: {err}"
213        );
214    }
215
216    /// WBS-507 positive: a client accepts a socket in a 0700 directory.
217    #[tokio::test]
218    async fn client_accepts_socket_in_private_directory() {
219        let dir = private_dir();
220        let socket_path = dir.join("sock");
221        let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
222        tokio::spawn(async move {
223            let listener = listener;
224            // Accept one connection and drop it; presence check is the point.
225            let _ = listener.accept().await;
226        });
227        tokio::time::sleep(Duration::from_millis(50)).await;
228        let client = UnixSocketConnection::connect(socket_path).await;
229        assert!(client.is_ok(), "private-dir socket must be accepted");
230    }
231
232    fn uuid_v4() -> String {
233        // Simple unique suffix without pulling a uuid dependency
234        let nanos = std::time::SystemTime::now()
235            .duration_since(std::time::UNIX_EPOCH)
236            .unwrap()
237            .as_nanos();
238        format!("proto{n}", n = nanos)
239    }
240}