1pub mod components;
4pub mod decode;
5pub mod platform;
6pub mod policy;
7pub mod rejected;
8pub mod request_target;
9
10pub use policy::{DotfilePolicy, PathPolicy};
11pub use rejected::PathRejection;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConfinedPath {
15 decoded: String,
16 components: Vec<String>,
17 path_policy: PathPolicy,
18}
19
20impl ConfinedPath {
21 pub fn parse(raw: &str, policy: &PathPolicy) -> Result<Self, PathRejection> {
22 if raw.len() > 8192 {
23 return Err(PathRejection::TooLong);
24 }
25 let path = request_target::parse_origin_form(raw)?;
26
27 let decoded = decode::percent_decode(path)?;
28
29 let normalized = components::normalize_path(&decoded);
30
31 let parts = components::split_components(&normalized);
32
33 components::validate_components(&parts, policy)?;
34
35 Ok(Self {
36 decoded,
37 components: parts,
38 path_policy: policy.clone(),
39 })
40 }
41
42 #[allow(dead_code)]
43 pub fn as_str(&self) -> &str {
44 &self.decoded
45 }
46
47 pub fn components(&self) -> &[String] {
48 &self.components
49 }
50
51 pub fn path_policy(&self) -> &PathPolicy {
52 &self.path_policy
53 }
54}
55
56#[cfg(test)]
57mod tests {
58 use super::*;
59
60 fn default_policy() -> PathPolicy {
61 PathPolicy::default()
62 }
63
64 #[test]
65 fn simple_path() {
66 let p = ConfinedPath::parse("/foo/bar", &default_policy()).unwrap();
67 assert_eq!(p.as_str(), "/foo/bar");
68 assert_eq!(p.components(), &["foo", "bar"]);
69 }
70
71 #[test]
72 fn root_path() {
73 let p = ConfinedPath::parse("/", &default_policy()).unwrap();
74 assert_eq!(p.as_str(), "/");
75 assert_eq!(p.components().len(), 0);
76 }
77
78 #[test]
79 fn path_with_query_stripped() {
80 let p = ConfinedPath::parse("/foo?bar=baz", &default_policy()).unwrap();
81 assert_eq!(p.as_str(), "/foo");
82 assert_eq!(p.components(), &["foo"]);
83 }
84
85 #[test]
86 fn reject_empty() {
87 assert_eq!(
88 ConfinedPath::parse("", &default_policy()).unwrap_err(),
89 PathRejection::Empty
90 );
91 }
92
93 #[test]
94 fn reject_absolute_form() {
95 assert_eq!(
96 ConfinedPath::parse("http://example.com/path", &default_policy()).unwrap_err(),
97 PathRejection::UnsupportedUriForm
98 );
99 }
100
101 #[test]
102 fn reject_asterisk_form() {
103 assert_eq!(
104 ConfinedPath::parse("*", &default_policy()).unwrap_err(),
105 PathRejection::UnsupportedUriForm
106 );
107 }
108
109 #[test]
110 fn reject_authority_form() {
111 assert_eq!(
112 ConfinedPath::parse("example.com:443", &default_policy()).unwrap_err(),
113 PathRejection::UnsupportedUriForm
114 );
115 }
116
117 #[test]
118 fn normalize_consecutive_slashes() {
119 let p = ConfinedPath::parse("/foo//bar", &default_policy()).unwrap();
120 assert_eq!(p.components(), &["foo", "bar"]);
121 }
122
123 #[test]
124 fn reject_dot_component() {
125 assert_eq!(
126 ConfinedPath::parse("/foo/./bar", &default_policy()).unwrap_err(),
127 PathRejection::CurrentComponent
128 );
129 }
130
131 #[test]
132 fn reject_dotdot_component() {
133 assert_eq!(
134 ConfinedPath::parse("/../etc/passwd", &default_policy()).unwrap_err(),
135 PathRejection::ParentComponent
136 );
137 }
138
139 #[test]
140 fn reject_percent_encoded_dotdot() {
141 assert_eq!(
142 ConfinedPath::parse("/%2e%2e/etc/passwd", &default_policy()).unwrap_err(),
143 PathRejection::ParentComponent
144 );
145 }
146
147 #[test]
148 fn reject_uppercase_percent_encoded_dotdot() {
149 assert_eq!(
150 ConfinedPath::parse("/%2E%2E/etc/passwd", &default_policy()).unwrap_err(),
151 PathRejection::ParentComponent
152 );
153 }
154
155 #[test]
156 fn reject_double_encoded_dotdot() {
157 assert_eq!(
158 ConfinedPath::parse("/%252e%252e/etc/passwd", &default_policy()).unwrap_err(),
159 PathRejection::ParentComponent
160 );
161 }
162
163 #[test]
164 fn reject_dotdot_in_path() {
165 assert_eq!(
166 ConfinedPath::parse("/foo/../../bar", &default_policy()).unwrap_err(),
167 PathRejection::ParentComponent
168 );
169 }
170
171 #[test]
172 fn reject_percent_encoded_dotdot_in_path() {
173 assert_eq!(
174 ConfinedPath::parse("/foo/%2e%2e/bar", &default_policy()).unwrap_err(),
175 PathRejection::ParentComponent
176 );
177 }
178
179 #[test]
180 fn reject_backslash() {
181 assert_eq!(
182 ConfinedPath::parse("/foo\\bar", &default_policy()).unwrap_err(),
183 PathRejection::SeparatorAmbiguity
184 );
185 }
186
187 #[test]
188 fn reject_percent_encoded_backslash() {
189 assert_eq!(
190 ConfinedPath::parse("/%5cetc%5cpasswd", &default_policy()).unwrap_err(),
191 PathRejection::SeparatorAmbiguity
192 );
193 }
194
195 #[test]
196 fn reject_windows_drive_prefix() {
197 assert_eq!(
198 ConfinedPath::parse("/C:/Windows/System32", &default_policy()).unwrap_err(),
199 PathRejection::WindowsPrefixDenied
200 );
201 }
202
203 #[test]
204 fn reject_percent_encoded_windows_drive() {
205 assert_eq!(
206 ConfinedPath::parse("/c%3a/Windows/System32", &default_policy()).unwrap_err(),
207 PathRejection::WindowsPrefixDenied
208 );
209 }
210
211 #[test]
212 fn reject_dotfile() {
213 assert_eq!(
214 ConfinedPath::parse("/.env", &default_policy()).unwrap_err(),
215 PathRejection::DotfileDenied
216 );
217 }
218
219 #[test]
220 fn reject_dotfile_git_config() {
221 assert_eq!(
222 ConfinedPath::parse("/.git/config", &default_policy()).unwrap_err(),
223 PathRejection::DotfileDenied
224 );
225 }
226
227 #[test]
228 fn reject_dotfile_in_subdir() {
229 assert_eq!(
230 ConfinedPath::parse("/foo/.secret", &default_policy()).unwrap_err(),
231 PathRejection::DotfileDenied
232 );
233 }
234
235 #[test]
236 fn reject_windows_reserved_con() {
237 assert_eq!(
238 ConfinedPath::parse("/CON", &default_policy()).unwrap_err(),
239 PathRejection::WindowsReservedNameDenied
240 );
241 }
242
243 #[test]
244 fn reject_windows_reserved_aux() {
245 assert_eq!(
246 ConfinedPath::parse("/AUX.txt", &default_policy()).unwrap_err(),
247 PathRejection::WindowsReservedNameDenied
248 );
249 }
250
251 #[test]
252 fn reject_windows_reserved_com1() {
253 assert_eq!(
254 ConfinedPath::parse("/COM1", &default_policy()).unwrap_err(),
255 PathRejection::WindowsReservedNameDenied
256 );
257 }
258
259 #[test]
260 fn reject_windows_ads() {
261 assert_eq!(
262 ConfinedPath::parse("/file.txt:stream", &default_policy()).unwrap_err(),
263 PathRejection::WindowsAlternateStreamDenied
264 );
265 }
266
267 #[test]
268 fn reject_nul() {
269 assert_eq!(
270 ConfinedPath::parse("/%00", &default_policy()).unwrap_err(),
271 PathRejection::NulByte
272 );
273 }
274
275 #[test]
276 fn reject_malformed_percent() {
277 assert_eq!(
278 ConfinedPath::parse("/%ZZ", &default_policy()).unwrap_err(),
279 PathRejection::MalformedPercentEncoding
280 );
281 }
282
283 #[test]
284 fn allow_dotfile_when_policy_permits() {
285 let policy = PathPolicy {
286 dotfiles: DotfilePolicy::Allow,
287 ..PathPolicy::default()
288 };
289 let p = ConfinedPath::parse("/.env", &policy).unwrap();
290 assert_eq!(p.as_str(), "/.env");
291 }
292
293 #[test]
294 fn allow_backslash_when_policy_permits() {
295 let policy = PathPolicy {
296 reject_backslash: false,
297 ..PathPolicy::default()
298 };
299 let p = ConfinedPath::parse("/foo\\bar", &policy).unwrap();
300 assert_eq!(p.as_str(), "/foo\\bar");
301 }
302
303 #[test]
304 fn reject_double_slash_root() {
305 let p = ConfinedPath::parse("//", &default_policy()).unwrap();
306 assert_eq!(p.components().len(), 0);
307 }
308
309 #[test]
310 fn reject_triple_slash() {
311 let p = ConfinedPath::parse("///", &default_policy()).unwrap();
312 assert_eq!(p.components().len(), 0);
313 }
314
315 #[test]
316 fn path_policy_returns_parsed_policy() {
317 let policy = PathPolicy {
318 dotfiles: DotfilePolicy::Allow,
319 ..PathPolicy::default()
320 };
321 let p = ConfinedPath::parse("/.env", &policy).unwrap();
322 assert_eq!(p.path_policy(), &policy);
323 }
324
325 #[test]
326 fn path_policy_default_returns_default() {
327 let p = ConfinedPath::parse("/foo", &default_policy()).unwrap();
328 assert_eq!(p.path_policy(), &default_policy());
329 }
330
331 #[test]
332 fn reject_too_long() {
333 let long = format!("/{}", "a".repeat(8192));
334 assert_eq!(
335 ConfinedPath::parse(&long, &default_policy()).unwrap_err(),
336 PathRejection::TooLong
337 );
338 }
339
340 #[test]
341 fn allow_max_length() {
342 let max_len = format!("/{}", "a".repeat(8191));
343 assert!(ConfinedPath::parse(&max_len, &default_policy()).is_ok());
344 }
345}