Skip to main content

kunobi_daemon/
socket_path.rs

1//! Unix socket paths that fit, checked where names and directories are chosen.
2//!
3//! A Unix socket address has a fixed-size path field, and the OS reports an
4//! overflow only as an invalid argument, at bind or connect time, naming
5//! neither the length nor the limit. A service usually derives several sockets
6//! from one directory, so the one that overflows is often not the one anybody
7//! checked. Here a name is checked when it is written, as a `const`, and a
8//! directory once, against every socket it will hold.
9//!
10//! ```
11//! use kunobi_daemon::socket_path::{SocketDir, SocketName};
12//!
13//! const DAEMON: SocketName = SocketName::new("daemon.sock");
14//! const CONTROL: SocketName = SocketName::new("daemon.ctl");
15//!
16//! let dir = SocketDir::new("/tmp/kache-1f2e", &[DAEMON, CONTROL])?;
17//! let control = dir.path(CONTROL)?;
18//! # Ok::<(), std::io::Error>(())
19//! ```
20//!
21//! Windows names a pipe after the path, which has no such limit, so every check
22//! passes there.
23use std::{
24    io,
25    path::{Path, PathBuf},
26};
27
28/// Longest Unix socket path, in bytes, on this platform: `sockaddr_un.sun_path`
29/// less the NUL that terminates it. `None` where local endpoints are not Unix
30/// sockets.
31pub const MAX_SOCKET_PATH_BYTES: Option<usize> = if cfg!(unix) {
32    Some(if cfg!(any(target_os = "linux", target_os = "android")) {
33        107
34    } else {
35        103
36    })
37} else {
38    None
39};
40
41/// Longest socket name [`SocketName::new`] accepts. A name is a file name, not a
42/// place to spend the directory's budget.
43pub const MAX_NAME_BYTES: usize = 48;
44
45/// A socket file name, checked when it is constructed.
46///
47/// [`SocketName::new`] is a `const fn` that panics on an empty name, `.` or
48/// `..`, a path separator, `:` or NUL, or a name longer than
49/// [`MAX_NAME_BYTES`]. Windows reads a leading `c:` as a drive, so joining such
50/// a name to a directory would replace the directory, and a later `:` names an
51/// alternate data stream.
52///
53/// Declared as a `const`, a bad name fails the build:
54///
55/// ```compile_fail
56/// use kunobi_daemon::socket_path::SocketName;
57/// const NESTED: SocketName = SocketName::new("run/daemon.sock");
58/// ```
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
60pub struct SocketName(&'static str);
61
62impl SocketName {
63    /// Check `name` and wrap it. Use it in a `const` so a bad name is a
64    /// compile error rather than a runtime one.
65    pub const fn new(name: &'static str) -> Self {
66        let bytes = name.as_bytes();
67        assert!(!bytes.is_empty(), "socket name is empty");
68        assert!(
69            bytes.len() <= MAX_NAME_BYTES,
70            "socket name is longer than MAX_NAME_BYTES"
71        );
72        assert!(!matches!(bytes, b"." | b".."), "socket name is `.` or `..`");
73        let mut index = 0;
74        while index < bytes.len() {
75            let byte = bytes[index];
76            assert!(
77                byte != b'/' && byte != b'\\' && byte != b':' && byte != 0,
78                "socket name contains a path separator, `:` or NUL"
79            );
80            index += 1;
81        }
82        Self(name)
83    }
84
85    /// The file name.
86    pub const fn as_str(&self) -> &'static str {
87        self.0
88    }
89}
90
91/// Fails when `socket` is too long to bind or connect to as a Unix socket.
92///
93/// The error names the length and the limit, which the OS does not. Passes on
94/// platforms without the limit.
95pub fn check_socket_path(socket: &Path) -> io::Result<()> {
96    let Some(limit) = MAX_SOCKET_PATH_BYTES else {
97        return Ok(());
98    };
99    let bytes = path_bytes(socket);
100    if bytes > limit {
101        return Err(io::Error::new(
102            io::ErrorKind::InvalidInput,
103            format!(
104                "socket path is {bytes} bytes, but a Unix socket path can be at most \
105                 {limit} on this platform: {}",
106                socket.display()
107            ),
108        ));
109    }
110    Ok(())
111}
112
113/// Longest directory, in bytes, that still fits every socket in `names`, or
114/// `None` on a platform without the limit.
115pub fn max_dir_bytes(names: &[SocketName]) -> Option<usize> {
116    let limit = MAX_SOCKET_PATH_BYTES?;
117    let longest = names.iter().map(|name| name.0.len()).max().unwrap_or(0);
118    // One separator between the directory and the name.
119    Some(limit.saturating_sub(longest + 1))
120}
121
122/// A directory checked once against every socket it will hold.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct SocketDir {
125    dir: PathBuf,
126}
127
128impl SocketDir {
129    /// Fails when `dir` joined with the longest of `names` would not fit.
130    ///
131    /// Declare every socket the directory will hold, including names derived
132    /// at runtime (declare the longest a derivation can produce, e.g. with its
133    /// widest generation suffix). The error says how long the directory may be.
134    pub fn new(dir: impl Into<PathBuf>, names: &[SocketName]) -> io::Result<Self> {
135        let dir = dir.into();
136        if let (Some(limit), Some(longest)) = (
137            MAX_SOCKET_PATH_BYTES,
138            names.iter().max_by_key(|name| name.0.len()),
139        ) {
140            let bytes = path_bytes(&dir.join(longest.0));
141            if bytes > limit {
142                return Err(io::Error::new(
143                    io::ErrorKind::InvalidInput,
144                    format!(
145                        "socket `{}` in this directory is {bytes} bytes, but a Unix socket path \
146                         can be at most {limit} on this platform, so the directory can be at \
147                         most {} bytes: {}",
148                        longest.0,
149                        limit.saturating_sub(longest.0.len() + 1),
150                        dir.display()
151                    ),
152                ));
153            }
154        }
155        Ok(Self { dir })
156    }
157
158    /// The directory itself.
159    pub fn dir(&self) -> &Path {
160        &self.dir
161    }
162
163    /// The socket `name` in this directory. Checked again, so a name that was
164    /// not declared to [`SocketDir::new`] cannot slip through.
165    pub fn path(&self, name: SocketName) -> io::Result<PathBuf> {
166        let path = self.dir.join(name.0);
167        check_socket_path(&path)?;
168        Ok(path)
169    }
170}
171
172#[cfg(unix)]
173fn path_bytes(path: &Path) -> usize {
174    use std::os::unix::ffi::OsStrExt;
175    path.as_os_str().as_bytes().len()
176}
177
178#[cfg(not(unix))]
179fn path_bytes(path: &Path) -> usize {
180    path.as_os_str().len()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    const DAEMON: SocketName = SocketName::new("daemon.sock");
188    // Only the Unix tests reach the limit it overflows.
189    #[cfg(unix)]
190    const CONTROL: SocketName = SocketName::new("daemon.control.v2.sock");
191
192    #[test]
193    fn a_name_is_checked_where_it_is_declared() {
194        assert_eq!(DAEMON.as_str(), "daemon.sock");
195        for bad in ["", ".", "..", "a/b", "a\\b", "a\0b"] {
196            let name = bad.to_string().leak();
197            assert!(
198                std::panic::catch_unwind(|| SocketName::new(name)).is_err(),
199                "{bad:?} was accepted"
200            );
201        }
202        let long = "a".repeat(MAX_NAME_BYTES + 1).leak();
203        assert!(std::panic::catch_unwind(|| SocketName::new(long)).is_err());
204        let limit = "a".repeat(MAX_NAME_BYTES).leak();
205        assert_eq!(SocketName::new(limit).as_str().len(), MAX_NAME_BYTES);
206    }
207
208    #[cfg(unix)]
209    #[test]
210    fn a_directory_is_checked_against_its_longest_socket() {
211        // The kache runner that failed: daemon.sock fit, the control socket did not.
212        let dir = "/Users/zondax-ci/actions-runner/runner-3/_work/_temp/kache-runtime-36081599948-1-build-sign";
213        let daemon_only = SocketDir::new(dir, &[DAEMON]);
214        let both = SocketDir::new(dir, &[DAEMON, CONTROL]);
215        if MAX_SOCKET_PATH_BYTES == Some(103) {
216            assert!(daemon_only.is_ok());
217            let error = both.unwrap_err().to_string();
218            assert!(error.contains("daemon.control.v2.sock"), "{error}");
219            assert!(error.contains("at most 80 bytes"), "{error}");
220        }
221    }
222
223    #[cfg(unix)]
224    #[test]
225    fn a_directory_at_the_budget_fits_and_one_byte_more_does_not() {
226        let limit = MAX_SOCKET_PATH_BYTES.unwrap();
227        let budget = max_dir_bytes(&[DAEMON, CONTROL]).unwrap();
228        let dir = format!("/{}", "d".repeat(budget - 1));
229        assert_eq!(dir.len(), budget);
230        let fits = SocketDir::new(&dir, &[DAEMON, CONTROL]).unwrap();
231        assert_eq!(path_bytes(&fits.path(CONTROL).unwrap()), limit);
232        assert!(SocketDir::new(format!("{dir}d"), &[DAEMON, CONTROL]).is_err());
233    }
234
235    #[cfg(unix)]
236    #[test]
237    fn an_undeclared_name_is_still_checked() {
238        let budget = max_dir_bytes(&[DAEMON]).unwrap();
239        let dir = SocketDir::new(format!("/{}", "d".repeat(budget - 1)), &[DAEMON]).unwrap();
240        assert!(dir.path(DAEMON).is_ok());
241        assert!(dir.path(CONTROL).is_err());
242    }
243
244    #[test]
245    fn a_path_names_its_length_and_the_limit() {
246        if let Some(limit) = MAX_SOCKET_PATH_BYTES {
247            let path = format!("/{}", "a".repeat(limit));
248            let error = check_socket_path(Path::new(&path)).unwrap_err().to_string();
249            assert!(error.contains(&format!("{} bytes", limit + 1)), "{error}");
250            assert!(error.contains(&format!("at most {limit}")), "{error}");
251        } else {
252            assert!(check_socket_path(Path::new(&"a".repeat(4096))).is_ok());
253            assert_eq!(max_dir_bytes(&[DAEMON]), None);
254        }
255    }
256}
257
258/// Name and directory rules over generated names and directories.
259#[cfg(test)]
260mod properties {
261    use super::*;
262    use proptest::prelude::*;
263    use std::ffi::OsStr;
264
265    fn config() -> ProptestConfig {
266        ProptestConfig {
267            cases: 256,
268            failure_persistence: None,
269            ..ProptestConfig::default()
270        }
271    }
272
273    /// Names that follow the documented rules, including spaces and dots,
274    /// which are ordinary file name characters.
275    fn valid_name() -> impl Strategy<Value = String> {
276        "[a-zA-Z0-9._ -]{1,48}".prop_filter("`.` and `..` are not names", |name| {
277            name != "." && name != ".."
278        })
279    }
280
281    fn any_name() -> impl Strategy<Value = String> {
282        prop_oneof![
283            3 => valid_name(),
284            // Separators, NUL and dots.
285            1 => "[a-z./\\\\\\x00]{0,4}",
286            // A drive letter and colon, which Windows paths read as a prefix.
287            1 => "[a-zA-Z]:[a-z.]{0,6}",
288            1 => "\\PC{0,64}",
289            1 => "[a-z]{40,60}",
290        ]
291    }
292
293    /// Leaked, because a name is `&'static str`; a few hundred short strings.
294    fn declare(name: String) -> SocketName {
295        SocketName::new(name.leak())
296    }
297
298    proptest! {
299        #![proptest_config(config())]
300
301        #[test]
302        fn a_name_is_accepted_exactly_when_the_rules_allow_it(name in any_name()) {
303            let allowed = !name.is_empty()
304                && name.len() <= MAX_NAME_BYTES
305                && name != "."
306                && name != ".."
307                && !name.contains(['/', '\\', ':', '\0']);
308            let name: &'static str = name.leak();
309            prop_assert_eq!(std::panic::catch_unwind(|| SocketName::new(name)).is_ok(), allowed);
310        }
311
312        #[test]
313        fn a_socket_path_is_its_name_directly_inside_the_directory(
314            dir in "(/[a-z0-9-]{1,8}){1,3}",
315            name in valid_name(),
316        ) {
317            let name = declare(name);
318            // Short enough for every platform's limit.
319            let socket_dir = SocketDir::new(&dir, &[name]).unwrap();
320            let path = socket_dir.path(name).unwrap();
321            prop_assert_eq!(path.parent(), Some(Path::new(&dir)), "{:?}", path);
322            prop_assert_eq!(path.file_name(), Some(OsStr::new(name.as_str())), "{:?}", path);
323        }
324    }
325
326    #[cfg(unix)]
327    proptest! {
328        #![proptest_config(config())]
329
330        #[test]
331        fn a_directory_fits_exactly_when_it_is_within_the_budget(
332            names in proptest::collection::vec(valid_name(), 1..4),
333            undeclared in valid_name(),
334            dir in "(/[a-z0-9-]{1,16}){1,10}",
335        ) {
336            let limit = MAX_SOCKET_PATH_BYTES.unwrap();
337            let names: Vec<SocketName> = names.into_iter().map(declare).collect();
338            let budget = max_dir_bytes(&names).unwrap();
339            let checked = SocketDir::new(&dir, &names);
340            prop_assert_eq!(checked.is_ok(), dir.len() <= budget);
341            if let Ok(checked) = checked {
342                for &name in &names {
343                    prop_assert!(checked.path(name).is_ok(), "declared {:?} does not fit", name);
344                }
345                // A name that was not declared is checked on its own.
346                let undeclared = declare(undeclared);
347                prop_assert_eq!(
348                    checked.path(undeclared).is_ok(),
349                    dir.len() + 1 + undeclared.as_str().len() <= limit
350                );
351            }
352        }
353
354        #[test]
355        fn a_directory_at_the_budget_fits_and_one_byte_more_does_not(
356            names in proptest::collection::vec(valid_name(), 1..4),
357        ) {
358            let names: Vec<SocketName> = names.into_iter().map(declare).collect();
359            let budget = max_dir_bytes(&names).unwrap();
360            let dir = format!("/{}", "d".repeat(budget - 1));
361            let longest = names.iter().map(|name| name.as_str().len()).max().unwrap();
362            let fits = SocketDir::new(&dir, &names).unwrap();
363            for &name in &names {
364                let path = fits.path(name).unwrap();
365                prop_assert!(path_bytes(&path) <= MAX_SOCKET_PATH_BYTES.unwrap());
366                if name.as_str().len() == longest {
367                    prop_assert_eq!(path_bytes(&path), MAX_SOCKET_PATH_BYTES.unwrap());
368                }
369            }
370            // Not `format!("{dir}d")`: the proptest! macro hides `dir` from
371            // an inline format argument.
372            prop_assert!(SocketDir::new(dir.clone() + "d", &names).is_err());
373        }
374    }
375}