Skip to main content

astrid_kernel/
socket.rs

1use std::path::PathBuf;
2
3use astrid_core::session_token::SessionToken;
4use tokio::net::UnixListener;
5use tracing::warn;
6
7/// Path to the local Unix Domain Socket for the kernel.
8#[must_use]
9pub(crate) fn kernel_socket_path() -> PathBuf {
10    use astrid_core::dirs::AstridHome;
11    match AstridHome::resolve() {
12        Ok(home) => home.socket_path(),
13        Err(e) => {
14            warn!(error = %e, "Failed to resolve ASTRID_HOME; falling back to /tmp/.astrid/run/system.sock");
15            PathBuf::from("/tmp/.astrid/run/system.sock")
16        },
17    }
18}
19
20/// Maximum byte length for a Unix domain socket path.
21/// macOS/FreeBSD/OpenBSD `sockaddr_un.sun_path` is 104 bytes; Linux is 108.
22#[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
23const MAX_SOCKET_PATH_LEN: usize = 104;
24#[cfg(not(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd")))]
25const MAX_SOCKET_PATH_LEN: usize = 108;
26
27/// Binds a local Unix Domain Socket for the OS.
28/// Returns the bound listener so it can be passed into the WASM execution context.
29///
30/// # Errors
31/// Returns an error if the socket cannot be bound, the path exceeds the
32/// platform's `sun_path` limit, or another kernel instance is already
33/// listening on the socket.
34pub(crate) fn bind_session_socket() -> Result<UnixListener, std::io::Error> {
35    let path = kernel_socket_path();
36
37    prepare_socket_path(&path)?;
38
39    // Also clean stale readiness file as defense-in-depth for daemon
40    // crashes that bypassed graceful shutdown.
41    remove_readiness_file();
42
43    if let Some(parent) = path.parent() {
44        std::fs::create_dir_all(parent).map_err(|e| {
45            std::io::Error::other(format!(
46                "Failed to create socket parent directory {}: {e}",
47                parent.display()
48            ))
49        })?;
50
51        // Enforce 0o700 on the sessions directory. AstridHome::ensure() does
52        // this at boot, but if the directory was just created by create_dir_all
53        // it inherits the process umask (commonly 0o755, making the socket
54        // listable by other users).
55        #[cfg(unix)]
56        {
57            use std::os::unix::fs::PermissionsExt;
58            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
59        }
60    }
61
62    UnixListener::bind(&path)
63}
64
65/// Generate a random session token and write it to the token file.
66///
67/// Returns both the token and the path it was written to. The caller should
68/// store the path so that the exact same path is used for cleanup at shutdown
69/// (avoids fallback mismatch if the env changes between boot and shutdown).
70///
71/// The token is written with 0o600 permissions so only the owning user
72/// can read it. The CLI reads this token at connect time and sends it
73/// as part of the handshake.
74///
75/// # Errors
76/// Returns an error if `ASTRID_HOME` cannot be resolved or the token file
77/// cannot be written. Unlike socket/CLI paths, there is no `/tmp` fallback
78/// because writing a secret token under a world-listable directory would
79/// undermine the authentication it provides.
80pub(crate) fn generate_session_token() -> Result<(SessionToken, PathBuf), std::io::Error> {
81    use astrid_core::dirs::AstridHome;
82
83    let token = SessionToken::generate();
84
85    let home = AstridHome::resolve().map_err(|e| {
86        std::io::Error::other(format!(
87            "Cannot generate session token: failed to resolve ASTRID_HOME: {e}"
88        ))
89    })?;
90
91    let path = home.token_path();
92    token.write_to_file(&path)?;
93    Ok((token, path))
94}
95
96/// Validate a socket path and handle stale/live socket detection.
97///
98/// Extracted from `bind_session_socket` for testability. Returns `Ok(())`
99/// if the path is safe to bind (stale socket removed or no socket exists).
100/// Returns `Err` if the path is too long or another kernel is listening.
101fn prepare_socket_path(path: &std::path::Path) -> Result<(), std::io::Error> {
102    let path_len = path.as_os_str().as_encoded_bytes().len();
103    if path_len >= MAX_SOCKET_PATH_LEN {
104        return Err(std::io::Error::other(format!(
105            "Socket path is {path_len} bytes, exceeding the platform limit of {MAX_SOCKET_PATH_LEN} bytes: {}",
106            path.display()
107        )));
108    }
109
110    if path.is_symlink() {
111        warn!(path = %path.display(), "Removing unexpected symlink at socket path");
112        std::fs::remove_file(path).map_err(|e| {
113            std::io::Error::other(format!(
114                "Failed to remove symlink at socket path {}: {e}",
115                path.display()
116            ))
117        })?;
118    } else if path.exists() {
119        match std::os::unix::net::UnixStream::connect(path) {
120            Ok(_stream) => {
121                return Err(std::io::Error::other(format!(
122                    "Another kernel instance is already running on this socket: {}",
123                    path.display()
124                )));
125            },
126            Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
127                // No listener attached: stale socket, safe to remove.
128                std::fs::remove_file(path).map_err(|e| {
129                    std::io::Error::other(format!(
130                        "Failed to remove stale socket {}: {e}",
131                        path.display()
132                    ))
133                })?;
134            },
135            Err(e) => {
136                // Other errors (EACCES, etc.) may indicate a live kernel
137                // under a different user or transient issue. Don't delete.
138                return Err(std::io::Error::other(format!(
139                    "Failed to probe existing socket {}: {e}",
140                    path.display()
141                )));
142            },
143        }
144    }
145
146    Ok(())
147}
148
149/// Path to the daemon readiness sentinel file.
150///
151/// NOTE: This is intentionally duplicated in `astrid-cli/src/socket_client.rs`
152/// because the CLI cannot depend on `astrid-kernel`. The canonical path
153/// definition is `AstridHome::ready_path()` in `astrid-core`.
154#[must_use]
155pub fn readiness_path() -> PathBuf {
156    use astrid_core::dirs::AstridHome;
157    match AstridHome::resolve() {
158        Ok(home) => home.ready_path(),
159        Err(e) => {
160            warn!(
161                error = %e,
162                "Failed to resolve ASTRID_HOME; falling back to /tmp/.astrid/run/system.ready"
163            );
164            PathBuf::from("/tmp/.astrid/run/system.ready")
165        },
166    }
167}
168
169/// Write the readiness sentinel file to signal that the daemon is fully
170/// initialized and accepting connections.
171///
172/// This must be called **after** `load_all_capsules()` completes (which
173/// includes `await_capsule_readiness()`). The CLI polls for this file
174/// instead of the socket file to avoid connecting before the accept loop
175/// is running.
176///
177/// # Errors
178/// Returns an error if the file cannot be written. The caller should treat
179/// this as a fatal boot failure - without the sentinel, the CLI will never
180/// detect that the daemon is ready.
181pub fn write_readiness_file() -> Result<(), std::io::Error> {
182    use std::fs::OpenOptions;
183
184    let path = readiness_path();
185
186    // Ensure the parent directory exists (defense-in-depth for contexts
187    // where bind_session_socket() has not run first).
188    if let Some(parent) = path.parent() {
189        std::fs::create_dir_all(parent)?;
190    }
191
192    // Create the sentinel file with owner-only permissions set atomically
193    // via OpenOptions::mode() to avoid a TOCTOU window where the file exists
194    // with default permissions before chmod.
195    let mut opts = OpenOptions::new();
196    opts.write(true).create(true).truncate(true);
197
198    #[cfg(unix)]
199    {
200        use std::os::unix::fs::OpenOptionsExt;
201        opts.mode(0o600);
202    }
203
204    opts.open(&path)?;
205    Ok(())
206}
207
208/// Remove the readiness sentinel file (best-effort).
209///
210/// Called during shutdown and stale-file cleanup. Errors are silently
211/// ignored - a missing file is not an error, and if removal fails the
212/// CLI's pre-spawn cleanup will handle it on next boot.
213pub fn remove_readiness_file() {
214    let _ = std::fs::remove_file(readiness_path());
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn path_too_long_is_rejected() {
223        // Build a path that exceeds the platform limit.
224        let long_name = "a".repeat(MAX_SOCKET_PATH_LEN + 10);
225        let path = PathBuf::from(format!("/tmp/{long_name}.sock"));
226        let err = prepare_socket_path(&path).unwrap_err();
227        assert!(
228            err.to_string().contains("exceeding the platform limit"),
229            "unexpected error: {err}"
230        );
231    }
232
233    #[test]
234    fn stale_socket_is_removed() {
235        // Bind a listener, drop it (making the socket stale), then verify
236        // prepare_socket_path removes it.
237        let dir = tempfile::tempdir().unwrap();
238        let sock = dir.path().join("test.sock");
239
240        // Create and immediately drop a listener to leave a stale socket file.
241        let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
242        drop(_listener);
243
244        assert!(sock.exists(), "socket file should exist after bind");
245        prepare_socket_path(&sock).unwrap();
246        assert!(!sock.exists(), "stale socket should have been removed");
247    }
248
249    #[test]
250    fn live_socket_is_rejected() {
251        let dir = tempfile::tempdir().unwrap();
252        let sock = dir.path().join("test.sock");
253
254        // Keep the listener alive so connect succeeds.
255        let _listener = std::os::unix::net::UnixListener::bind(&sock).unwrap();
256
257        let err = prepare_socket_path(&sock).unwrap_err();
258        assert!(
259            err.to_string().contains("already running"),
260            "unexpected error: {err}"
261        );
262    }
263
264    #[test]
265    fn symlink_is_removed() {
266        let dir = tempfile::tempdir().unwrap();
267        let target = dir.path().join("target");
268        std::fs::write(&target, "not a socket").unwrap();
269
270        let sock = dir.path().join("test.sock");
271        std::os::unix::fs::symlink(&target, &sock).unwrap();
272        assert!(sock.is_symlink());
273
274        prepare_socket_path(&sock).unwrap();
275        assert!(!sock.exists(), "symlink should have been removed");
276        assert!(target.exists(), "target should be untouched");
277    }
278
279    #[test]
280    fn nonexistent_path_succeeds() {
281        let dir = tempfile::tempdir().unwrap();
282        let sock = dir.path().join("does_not_exist.sock");
283        prepare_socket_path(&sock).unwrap();
284    }
285}