Skip to main content

arcbox_helper/validate/
mod.rs

1//! Input validation for setuid defense-in-depth.
2//!
3//! Every external input is validated before any privileged operation runs.
4//! This module is deliberately strict — it rejects anything that doesn't
5//! match the expected patterns for ArcBox's use cases.
6//!
7//! Types in this module follow the "parse, don't validate" pattern: each
8//! newtype can only be constructed through validated parsing, so downstream
9//! code can rely on invariants being upheld at the type level.
10
11mod bridge_iface;
12mod cli_name;
13mod cli_target;
14mod dns_port;
15mod domain;
16mod socket_target;
17mod subnet;
18
19pub use bridge_iface::BridgeIface;
20pub use cli_name::CliName;
21pub use cli_target::CliTarget;
22pub use dns_port::DnsPort;
23pub use domain::Domain;
24pub use socket_target::SocketTarget;
25pub use subnet::Subnet;
26
27pub fn validate_subnet(s: &str) -> Result<(), String> {
28    s.parse::<Subnet>().map(|_| ())
29}
30
31pub fn validate_iface(s: &str) -> Result<(), String> {
32    s.parse::<BridgeIface>().map(|_| ())
33}
34
35pub fn validate_domain(s: &str) -> Result<(), String> {
36    s.parse::<Domain>().map(|_| ())
37}
38
39pub fn validate_port(port: u16) -> Result<(), String> {
40    DnsPort::try_from(port).map(|_| ())
41}
42
43pub fn validate_socket_target(s: &str) -> Result<(), String> {
44    s.parse::<SocketTarget>().map(|_| ())
45}
46
47pub fn validate_cli_name(name: &str) -> Result<(), String> {
48    name.parse::<CliName>().map(|_| ())
49}
50
51pub fn validate_cli_target(target: &str) -> Result<(), String> {
52    target.parse::<CliTarget>().map(|_| ())
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn valid_subnets() {
61        assert!("10.0.0.0/8".parse::<Subnet>().is_ok());
62        assert!("172.16.0.0/12".parse::<Subnet>().is_ok());
63        assert!("192.168.1.0/24".parse::<Subnet>().is_ok());
64        assert!("10.10.10.0/24".parse::<Subnet>().is_ok());
65    }
66
67    #[test]
68    fn invalid_subnets() {
69        assert!("8.8.8.0/24".parse::<Subnet>().is_err());
70        assert!("1.1.1.0/24".parse::<Subnet>().is_err());
71        assert!("not-a-cidr".parse::<Subnet>().is_err());
72        assert!("10.0.0.0".parse::<Subnet>().is_err());
73        assert!("10.0.0.0/33".parse::<Subnet>().is_err());
74    }
75
76    #[test]
77    fn subnet_rejects_nonzero_host_bits() {
78        let r = "10.0.0.5/8".parse::<Subnet>();
79        assert!(r.is_err());
80        assert!(r.unwrap_err().contains("host bits"));
81
82        let r = "192.168.1.1/24".parse::<Subnet>();
83        assert!(r.is_err());
84        assert!(r.unwrap_err().contains("192.168.1.0/24"));
85    }
86
87    #[test]
88    fn subnet_display_roundtrip() {
89        let s = "10.0.0.0/8".parse::<Subnet>().unwrap();
90        assert_eq!(s.to_string(), "10.0.0.0/8");
91    }
92
93    #[test]
94    fn valid_ifaces() {
95        assert!("bridge0".parse::<BridgeIface>().is_ok());
96        assert!("bridge100".parse::<BridgeIface>().is_ok());
97        assert!("bridge999".parse::<BridgeIface>().is_ok());
98    }
99
100    #[test]
101    fn invalid_ifaces() {
102        assert!("eth0".parse::<BridgeIface>().is_err());
103        assert!("bridge".parse::<BridgeIface>().is_err());
104        assert!("bridge100a".parse::<BridgeIface>().is_err());
105        assert!("BRIDGE100".parse::<BridgeIface>().is_err());
106    }
107
108    #[test]
109    fn iface_rejects_overflow() {
110        assert!("bridge99999999999999999".parse::<BridgeIface>().is_err());
111    }
112
113    #[test]
114    fn valid_domains() {
115        assert!("arcbox.local".parse::<Domain>().is_ok());
116        assert!("test.arcbox.local".parse::<Domain>().is_ok());
117        assert!("my-domain.local".parse::<Domain>().is_ok());
118    }
119
120    #[test]
121    fn invalid_domains() {
122        assert!("".parse::<Domain>().is_err());
123        assert!("UPPER.case".parse::<Domain>().is_err());
124        assert!("has space.local".parse::<Domain>().is_err());
125        let long = "a".repeat(254);
126        assert!(long.parse::<Domain>().is_err());
127        assert!(".".parse::<Domain>().is_err());
128        assert!("..".parse::<Domain>().is_err());
129        assert!(".leading".parse::<Domain>().is_err());
130        assert!("trailing.".parse::<Domain>().is_err());
131        assert!("empty..label".parse::<Domain>().is_err());
132    }
133
134    #[test]
135    fn domain_rejects_label_starting_or_ending_with_hyphen() {
136        assert!("-bad.local".parse::<Domain>().is_err());
137        assert!("bad-.local".parse::<Domain>().is_err());
138        assert!("ok.-bad".parse::<Domain>().is_err());
139        assert!("ok.bad-".parse::<Domain>().is_err());
140    }
141
142    #[test]
143    fn domain_rejects_label_exceeding_63_chars() {
144        let long_label = "a".repeat(64);
145        assert!(format!("{long_label}.local").parse::<Domain>().is_err());
146
147        let ok_label = "a".repeat(63);
148        assert!(format!("{ok_label}.local").parse::<Domain>().is_ok());
149    }
150
151    #[test]
152    fn valid_ports() {
153        assert!(DnsPort::try_from(1024_u16).is_ok());
154        assert!(DnsPort::try_from(5553_u16).is_ok());
155        assert!(DnsPort::try_from(65535_u16).is_ok());
156    }
157
158    #[test]
159    fn invalid_ports() {
160        assert!(DnsPort::try_from(0_u16).is_err());
161        assert!(DnsPort::try_from(80_u16).is_err());
162        assert!(DnsPort::try_from(1023_u16).is_err());
163    }
164
165    #[test]
166    fn valid_socket_targets() {
167        assert!(
168            "/Users/alice/.arcbox/run/docker.sock"
169                .parse::<SocketTarget>()
170                .is_ok()
171        );
172        assert!(
173            "/Users/bob/.arcbox/run/arcbox.sock"
174                .parse::<SocketTarget>()
175                .is_ok()
176        );
177    }
178
179    #[test]
180    fn invalid_socket_targets() {
181        assert!("/tmp/docker.sock".parse::<SocketTarget>().is_err());
182        assert!(
183            "/Users//.arcbox/run/docker.sock"
184                .parse::<SocketTarget>()
185                .is_err()
186        );
187        assert!(
188            "/Users/alice/.config/docker.sock"
189                .parse::<SocketTarget>()
190                .is_err()
191        );
192        assert!(
193            "/Users/alice/.arcbox/run/docker.txt"
194                .parse::<SocketTarget>()
195                .is_err()
196        );
197    }
198
199    #[test]
200    fn socket_target_rejects_uppercase_extension() {
201        assert!(
202            "/Users/alice/.arcbox/run/docker.SOCK"
203                .parse::<SocketTarget>()
204                .is_err()
205        );
206        assert!(
207            "/Users/alice/.arcbox/run/docker.Sock"
208                .parse::<SocketTarget>()
209                .is_err()
210        );
211    }
212
213    #[test]
214    fn socket_target_rejects_path_traversal() {
215        let r = "/Users/alice/.arcbox/../../../../var/run/other.sock".parse::<SocketTarget>();
216        assert!(r.is_err());
217        assert!(r.unwrap_err().contains(".."));
218    }
219
220    #[test]
221    fn valid_cli_names() {
222        assert!("docker".parse::<CliName>().is_ok());
223        assert!("docker-buildx".parse::<CliName>().is_ok());
224        assert!("docker-compose".parse::<CliName>().is_ok());
225        assert!("docker-credential-osxkeychain".parse::<CliName>().is_ok());
226    }
227
228    #[test]
229    fn invalid_cli_names() {
230        assert!("".parse::<CliName>().is_err());
231        assert!("curl".parse::<CliName>().is_err());
232        assert!("rm".parse::<CliName>().is_err());
233        assert!("../docker".parse::<CliName>().is_err());
234    }
235
236    #[test]
237    fn valid_cli_targets() {
238        assert!(
239            "/Applications/ArcBox.app/Contents/MacOS/xbin/docker"
240                .parse::<CliTarget>()
241                .is_ok()
242        );
243        assert!(
244            "/Users/test/Apps/ArcBox.app/Contents/MacOS/xbin/docker-compose"
245                .parse::<CliTarget>()
246                .is_ok()
247        );
248    }
249
250    #[test]
251    fn invalid_cli_targets() {
252        assert!("Contents/MacOS/xbin/docker".parse::<CliTarget>().is_err());
253        assert!("/usr/local/bin/docker".parse::<CliTarget>().is_err());
254        assert!(
255            "/Applications/ArcBox.app/Contents/MacOS/xbin/../../evil"
256                .parse::<CliTarget>()
257                .is_err()
258        );
259        assert!(
260            "/tmp/evil.app/Contents/MacOS/xbin/docker"
261                .parse::<CliTarget>()
262                .is_err()
263        );
264    }
265
266    #[test]
267    fn cli_target_allows_nested_app_structure() {
268        assert!(
269            "/Users/evil/not-really.app/Contents/MacOS/xbin/nested.app/Contents/MacOS/xbin/docker"
270                .parse::<CliTarget>()
271                .is_ok()
272        );
273    }
274}