Skip to main content

waos_dev_protocol/
socket.rs

1//! The Unix socket a WaOS listens on, on this machine: where it is, connecting to it, and
2//! listening on it. The socket is in the user's own folder, made for the user alone, so the
3//! system's file permissions decide who may connect (the design's decision 30).
4//!
5//! The invariants a later editor will break:
6//! - A WaOS removes a socket file only when nobody answers on it: its own on the way out, or
7//!   one a WaOS that died left behind. While another WaOS answers, a second one is refused.
8//! - The folder is made for the user alone, and the socket file too, so the path's permissions
9//!   are what keep other users out, whatever the system does with a socket file's mode.
10
11use std::fs::{self, DirBuilder, Permissions};
12use std::io::{self, ErrorKind};
13use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
14use std::path::{Path, PathBuf};
15
16use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
17use tokio::net::{UnixListener, UnixStream};
18
19use crate::connection::Connection;
20
21/// Where WaOS listens on this machine, in the user's own folder: on Apple's systems
22/// `~/Library/Application Support/WaOS/dev.sock`; elsewhere `$XDG_RUNTIME_DIR/waos/dev.sock`,
23/// the folder a system keeps for the user's sockets, or `~/.local/share/waos/dev.sock` when
24/// there is none. macOS allows a socket path 103 bytes long, so a user's name may be 50 bytes;
25/// binding or connecting to a longer path fails with the system's message.
26pub fn socket_path() -> PathBuf {
27    user_folder().join(SOCKET_FILE)
28}
29
30const SOCKET_FILE: &str = "dev.sock";
31
32#[cfg(target_vendor = "apple")]
33fn user_folder() -> PathBuf {
34    home().join("Library/Application Support/WaOS")
35}
36
37#[cfg(not(target_vendor = "apple"))]
38fn user_folder() -> PathBuf {
39    match std::env::var_os("XDG_RUNTIME_DIR") {
40        Some(runtime) if !runtime.is_empty() => PathBuf::from(runtime).join("waos"),
41        _ => home().join(".local/share/waos"),
42    }
43}
44
45/// The user's home, or the root when the system cannot say: a path there fails to bind or
46/// connect with the system's message, which names the path.
47fn home() -> PathBuf {
48    std::env::home_dir().unwrap_or_else(|| PathBuf::from("/"))
49}
50
51impl Connection<UnixStream> {
52    /// Connects to the WaOS listening at `path`: `NotFound` or `ConnectionRefused` when none is.
53    pub async fn connect(path: &Path) -> io::Result<Connection<UnixStream>> {
54        Ok(Connection::new(UnixStream::connect(path).await?))
55    }
56
57    /// The two halves, to receive on one task while another sends.
58    pub fn into_split(self) -> (Connection<OwnedReadHalf>, Connection<OwnedWriteHalf>) {
59        let (reading, writing) = self.stream.into_split();
60        (Connection::new(reading), Connection::new(writing))
61    }
62}
63
64/// Where a WaOS accepts `cargo waos`'s connections. Dropping it removes its socket file, so a
65/// WaOS that quit leaves none; one that died leaves a file the next WaOS replaces.
66#[derive(Debug)]
67pub struct Listener {
68    listener: UnixListener,
69    path: PathBuf,
70}
71
72impl Listener {
73    /// Listens at `path`, making its folder for the user alone. A socket file left there by a
74    /// WaOS that died is replaced; while another WaOS answers on it, this one is refused with
75    /// `AddrInUse`.
76    pub async fn bind(path: &Path) -> io::Result<Listener> {
77        make_private_folder(path)?;
78        clear_stale_socket(path).await?;
79        let listener = UnixListener::bind(path)?;
80        fs::set_permissions(path, Permissions::from_mode(0o600))?;
81        Ok(Listener {
82            listener,
83            path: path.to_owned(),
84        })
85    }
86
87    /// The next connection: one run.
88    pub async fn accept(&self) -> io::Result<Connection<UnixStream>> {
89        let (stream, _) = self.listener.accept().await?;
90        Ok(Connection::new(stream))
91    }
92}
93
94impl Drop for Listener {
95    fn drop(&mut self) {
96        // A file already gone is what this wants.
97        let _ = fs::remove_file(&self.path);
98    }
99}
100
101/// Makes the socket's folder, for the user alone, where it is missing.
102fn make_private_folder(socket: &Path) -> io::Result<()> {
103    let Some(folder) = socket.parent() else {
104        return Ok(());
105    };
106    DirBuilder::new().recursive(true).mode(0o700).create(folder)
107}
108
109/// Removes the socket file at `path` when nobody answers on it; refuses while a WaOS does. The
110/// probe is a connection that closes at once, which that WaOS sees as a run that asked nothing.
111async fn clear_stale_socket(path: &Path) -> io::Result<()> {
112    match UnixStream::connect(path).await {
113        Ok(_answering) => Err(io::Error::new(
114            ErrorKind::AddrInUse,
115            format!("another WaOS answers at {}", path.display()),
116        )),
117        Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
118        Err(error) if error.kind() == ErrorKind::ConnectionRefused => {
119            tracing::info!(path = %path.display(), "replacing the socket file a WaOS that died left");
120            fs::remove_file(path)
121        }
122        Err(error) => Err(error),
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn the_socket_is_in_the_user_folder_under_its_name() {
132        let path = socket_path();
133        assert!(path.starts_with(home()), "{}", path.display());
134        assert!(path.ends_with(SOCKET_FILE), "{}", path.display());
135    }
136}