Skip to main content

eggserve_core/path/
platform.rs

1use super::rejected::PathRejection;
2
3pub fn check_component(component: &str) -> Result<(), PathRejection> {
4    if has_windows_drive_prefix(component) {
5        return Err(PathRejection::WindowsPrefixDenied);
6    }
7
8    if component.contains(':') {
9        return Err(PathRejection::WindowsAlternateStreamDenied);
10    }
11
12    if is_windows_reserved_name(component) {
13        return Err(PathRejection::WindowsReservedNameDenied);
14    }
15
16    // Windows trims trailing dots and spaces when resolving names. Reject
17    // them so a request cannot alias a different normalized component.
18    if component.ends_with('.') || component.ends_with(' ') {
19        return Err(PathRejection::WindowsReservedNameDenied);
20    }
21
22    Ok(())
23}
24
25pub fn has_windows_drive_prefix(component: &str) -> bool {
26    let bytes = component.as_bytes();
27    if bytes.len() < 2 {
28        return false;
29    }
30    bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
31}
32
33pub fn is_windows_reserved_name(component: &str) -> bool {
34    let base = component.split('.').next().unwrap_or("");
35    let name = strip_trailing_dots(base);
36    if name.is_empty() {
37        return false;
38    }
39    matches!(
40        name.to_ascii_uppercase().as_str(),
41        "CON"
42            | "PRN"
43            | "AUX"
44            | "NUL"
45            | "COM1"
46            | "COM2"
47            | "COM3"
48            | "COM4"
49            | "COM5"
50            | "COM6"
51            | "COM7"
52            | "COM8"
53            | "COM9"
54            | "LPT1"
55            | "LPT2"
56            | "LPT3"
57            | "LPT4"
58            | "LPT5"
59            | "LPT6"
60            | "LPT7"
61            | "LPT8"
62            | "LPT9"
63    )
64}
65
66fn strip_trailing_dots(s: &str) -> &str {
67    s.trim_end_matches('.')
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73    use proptest::prelude::*;
74
75    #[test]
76    fn reserved_con() {
77        assert!(is_windows_reserved_name("CON"));
78        assert!(is_windows_reserved_name("con"));
79        assert!(is_windows_reserved_name("Con"));
80    }
81
82    #[test]
83    fn reserved_nul() {
84        assert!(is_windows_reserved_name("NUL"));
85        assert!(is_windows_reserved_name("nul"));
86    }
87
88    #[test]
89    fn reserved_com1() {
90        assert!(is_windows_reserved_name("COM1"));
91        assert!(is_windows_reserved_name("com1"));
92    }
93
94    #[test]
95    fn reserved_lpt1() {
96        assert!(is_windows_reserved_name("LPT1"));
97        assert!(is_windows_reserved_name("lpt1"));
98    }
99
100    #[test]
101    fn not_reserved() {
102        assert!(!is_windows_reserved_name("foo"));
103        assert!(!is_windows_reserved_name("AUX2"));
104        assert!(!is_windows_reserved_name("COM0"));
105        assert!(!is_windows_reserved_name("LPT0"));
106    }
107
108    #[test]
109    fn reserved_with_trailing_dots() {
110        assert!(is_windows_reserved_name("CON."));
111        assert!(is_windows_reserved_name("NUL..."));
112    }
113
114    #[test]
115    fn reserved_with_extension() {
116        assert!(is_windows_reserved_name("AUX.txt"));
117        assert!(is_windows_reserved_name("CON.log"));
118        assert!(is_windows_reserved_name("nul.bak"));
119    }
120
121    #[test]
122    fn drive_prefix() {
123        assert!(has_windows_drive_prefix("C:"));
124        assert!(has_windows_drive_prefix("c:"));
125        assert!(has_windows_drive_prefix("Z:/path"));
126    }
127
128    #[test]
129    fn not_drive_prefix() {
130        assert!(!has_windows_drive_prefix(":"));
131        assert!(!has_windows_drive_prefix("/C:"));
132        assert!(!has_windows_drive_prefix("CC:"));
133        assert!(!has_windows_drive_prefix("1:"));
134    }
135
136    #[test]
137    fn ads_denied() {
138        assert_eq!(
139            check_component("file.txt:stream").unwrap_err(),
140            PathRejection::WindowsAlternateStreamDenied
141        );
142    }
143
144    #[test]
145    fn drive_denied() {
146        assert_eq!(
147            check_component("C:").unwrap_err(),
148            PathRejection::WindowsPrefixDenied
149        );
150    }
151
152    #[test]
153    fn reserved_denied() {
154        assert_eq!(
155            check_component("CON").unwrap_err(),
156            PathRejection::WindowsReservedNameDenied
157        );
158    }
159
160    #[test]
161    fn reserved_with_ext_denied() {
162        assert_eq!(
163            check_component("AUX.txt").unwrap_err(),
164            PathRejection::WindowsReservedNameDenied
165        );
166    }
167
168    #[test]
169    fn ok_component() {
170        assert!(check_component("foo").is_ok());
171        assert!(check_component("bar.txt").is_ok());
172        assert!(check_component("a1").is_ok());
173    }
174
175    proptest::proptest! {
176        #[test]
177        fn has_windows_drive_prefix_never_panics(s in ".*") {
178            let _ = has_windows_drive_prefix(&s);
179        }
180
181        #[test]
182        fn is_windows_reserved_name_never_panics(s in ".*") {
183            let _ = is_windows_reserved_name(&s);
184        }
185
186        #[test]
187        fn check_component_never_panics(s in ".*") {
188            let _ = check_component(&s);
189        }
190
191        #[test]
192        fn reserved_name_is_always_case_insensitive(s in "[A-Za-z]{1,4}") {
193            let upper = s.to_uppercase();
194            let lower = s.to_lowercase();
195            prop_assert_eq!(is_windows_reserved_name(&upper), is_windows_reserved_name(&lower));
196        }
197
198        #[test]
199        fn drive_prefix_requires_two_bytes(s in ".*") {
200            if has_windows_drive_prefix(&s) {
201                prop_assert!(s.len() >= 2);
202                prop_assert!(s.as_bytes()[0].is_ascii_alphabetic());
203                prop_assert_eq!(s.as_bytes()[1], b':');
204            }
205        }
206
207        #[test]
208        fn check_component_drive_takes_precedence(s in "[A-Za-z]:(.*)") {
209            if let Err(e) = check_component(&s) {
210                prop_assert_eq!(e, PathRejection::WindowsPrefixDenied);
211            }
212        }
213
214        #[test]
215        fn check_component_no_false_positive(s in "[a-zA-Z0-9_-]+") {
216            prop_assert!(check_component(&s).is_ok());
217        }
218    }
219}