Skip to main content

arcbox_helper/validate/
socket_target.rs

1use std::path::Component;
2use std::str::FromStr;
3
4/// A validated socket target path (e.g. `/Users/alice/.arcbox/run/docker.sock`).
5///
6/// Guarantees:
7/// - Under `/Users/<username>/.arcbox/` or `/Users/<username>/.arcbox-dev/`
8/// - Ends with `.sock` (strict lowercase)
9/// - No `..` path traversal components
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct SocketTarget(String);
12
13impl SocketTarget {
14    pub fn as_str(&self) -> &str {
15        &self.0
16    }
17}
18
19impl FromStr for SocketTarget {
20    type Err = String;
21
22    fn from_str(s: &str) -> Result<Self, Self::Err> {
23        let path = std::path::Path::new(s);
24
25        let rest = s
26            .strip_prefix("/Users/")
27            .ok_or_else(|| format!("socket target '{s}' must be under /Users/"))?;
28
29        let (username, after_user) = rest
30            .split_once('/')
31            .ok_or_else(|| format!("socket target '{s}' has no path after username"))?;
32
33        if username.is_empty() {
34            return Err(format!("socket target '{s}' has empty username"));
35        }
36
37        if !after_user.starts_with(".arcbox/") && !after_user.starts_with(".arcbox-dev/") {
38            return Err(format!(
39                "socket target '{s}' must be under ~/.arcbox/ or ~/.arcbox-dev/"
40            ));
41        }
42
43        #[allow(clippy::case_sensitive_file_extension_comparisons)]
44        if !s.ends_with(".sock") {
45            return Err(format!("socket target '{s}' must end with .sock"));
46        }
47
48        if path.components().any(|c| matches!(c, Component::ParentDir)) {
49            return Err(format!(
50                "socket target '{s}' contains '..' path traversal component"
51            ));
52        }
53
54        Ok(Self(s.to_owned()))
55    }
56}
57
58impl std::fmt::Display for SocketTarget {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str(&self.0)
61    }
62}