eggserve_core/path/
platform.rs1use 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 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}