1use std::{
24 io,
25 path::{Path, PathBuf},
26};
27
28pub 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
41pub const MAX_NAME_BYTES: usize = 48;
44
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
60pub struct SocketName(&'static str);
61
62impl SocketName {
63 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 pub const fn as_str(&self) -> &'static str {
87 self.0
88 }
89}
90
91pub 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
113pub 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 Some(limit.saturating_sub(longest + 1))
120}
121
122#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct SocketDir {
125 dir: PathBuf,
126}
127
128impl SocketDir {
129 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 pub fn dir(&self) -> &Path {
160 &self.dir
161 }
162
163 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 #[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 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#[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 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 1 => "[a-z./\\\\\\x00]{0,4}",
286 1 => "[a-zA-Z]:[a-z.]{0,6}",
288 1 => "\\PC{0,64}",
289 1 => "[a-z]{40,60}",
290 ]
291 }
292
293 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 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 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 prop_assert!(SocketDir::new(dir.clone() + "d", &names).is_err());
373 }
374 }
375}