1const MAX_DECODE_ROUNDS: usize = 4;
34
35const fn hex_value(byte: u8) -> Option<u8> {
37 match byte {
38 b'0'..=b'9' => Some(byte - b'0'),
39 b'a'..=b'f' => Some(byte - b'a' + 10),
40 b'A'..=b'F' => Some(byte - b'A' + 10),
41 _ => None,
42 }
43}
44
45fn escape_at(bytes: &[u8]) -> Option<u8> {
47 match bytes {
48 [b'%', high, low, ..] => Some(hex_value(*high)? << 4 | hex_value(*low)?),
49 _ => None,
50 }
51}
52
53fn decode_percent_once(value: &str) -> Option<String> {
57 let bytes = value.as_bytes();
58 let mut out = String::with_capacity(value.len());
59 let mut index = 0;
60 while index < bytes.len() {
61 if escape_at(&bytes[index..]).is_none() {
62 let ch = value[index..].chars().next()?;
65 out.push(ch);
66 index += ch.len_utf8();
67 continue;
68 }
69 let mut run = Vec::new();
70 while let Some(byte) = escape_at(&bytes[index..]) {
71 run.push(byte);
72 index += 3;
73 }
74 out.push_str(&String::from_utf8(run).ok()?);
75 }
76 Some(out)
77}
78
79fn has_escape(value: &str) -> bool {
81 let bytes = value.as_bytes();
82 (0..bytes.len()).any(|index| escape_at(&bytes[index..]).is_some())
83}
84
85fn decode_percent_recursive(raw: &str) -> Option<String> {
87 let mut decoded = raw.to_owned();
88 for _ in 0..MAX_DECODE_ROUNDS {
89 decoded = decode_percent_once(&decoded)?;
90 }
91 (!has_escape(&decoded)).then_some(decoded)
92}
93
94fn fold_dot_segment_params(segment: &str) -> &str {
96 let base = segment.split(';').next().unwrap_or(segment);
97 if base == "." || base == ".." {
98 base
99 } else {
100 segment
101 }
102}
103
104fn collapse_dot_segments(decoded: &str) -> String {
106 let mut segments: Vec<&str> = Vec::new();
107 for raw_segment in decoded.split(['/', '\\']) {
108 match fold_dot_segment_params(raw_segment) {
109 "" | "." => {}
110 ".." => {
111 segments.pop();
112 }
113 segment => segments.push(segment),
114 }
115 }
116 format!("/{}", segments.join("/"))
117}
118
119#[must_use]
122pub fn normalize_url_path(raw_path: &str) -> Option<String> {
123 decode_percent_recursive(raw_path).map(|decoded| collapse_dot_segments(&decoded))
124}
125
126#[must_use]
128pub fn normalize_exclude_paths<I, S>(exclude_paths: I) -> Vec<String>
129where
130 I: IntoIterator<Item = S>,
131 S: AsRef<str>,
132{
133 exclude_paths
134 .into_iter()
135 .filter_map(|entry| normalize_url_path(entry.as_ref()))
136 .collect()
137}
138
139fn is_subtree_or_equal(path: &str, excluded: &str) -> bool {
141 excluded == "/"
142 || path == excluded
143 || path
144 .strip_prefix(excluded)
145 .is_some_and(|rest| rest.starts_with('/'))
146}
147
148#[must_use]
151pub fn path_matches_exclusions(normalized_path: &str, normalized_exclusions: &[String]) -> bool {
152 normalized_exclusions
153 .iter()
154 .any(|excluded| is_subtree_or_equal(normalized_path, excluded))
155}
156
157#[must_use]
159pub fn path_is_excluded<S: AsRef<str>>(url_path: &str, exclude_paths: &[S]) -> bool {
160 normalize_url_path(url_path)
161 .is_some_and(|path| path_matches_exclusions(&path, &normalize_exclude_paths(exclude_paths)))
162}
163
164#[derive(Debug, Clone, Default, PartialEq, Eq)]
166pub struct PathExclusions {
167 normalized: Vec<String>,
168}
169
170impl PathExclusions {
171 pub fn new<I, S>(exclude_paths: I) -> Self
173 where
174 I: IntoIterator<Item = S>,
175 S: AsRef<str>,
176 {
177 Self {
178 normalized: normalize_exclude_paths(exclude_paths),
179 }
180 }
181
182 #[must_use]
184 pub fn normalized(&self) -> &[String] {
185 &self.normalized
186 }
187
188 #[must_use]
190 pub fn is_excluded(&self, url_path: &str) -> bool {
191 normalize_url_path(url_path)
192 .is_some_and(|path| path_matches_exclusions(&path, &self.normalized))
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 const DEFAULTS: [&str; 6] = [
201 "/docs",
202 "/redoc",
203 "/openapi.json",
204 "/openapi.yaml",
205 "/favicon.ico",
206 "/static",
207 ];
208
209 #[allow(clippy::type_complexity)]
213 const REFERENCE: &[(&str, Option<&str>, bool, bool, bool, bool)] = &[
214 (
215 "/static/app.js",
216 Some("/static/app.js"),
217 true,
218 true,
219 true,
220 true,
221 ),
222 ("/static", Some("/static"), true, true, true, true),
223 ("/static/", Some("/static"), true, true, true, true),
224 (
225 "/static/../admin",
226 Some("/admin"),
227 false,
228 true,
229 false,
230 false,
231 ),
232 ("/%2e%2e/admin", Some("/admin"), false, true, false, false),
233 (
234 "/static/%2e%2e/admin",
235 Some("/admin"),
236 false,
237 true,
238 false,
239 false,
240 ),
241 ("/docs", Some("/docs"), true, true, false, false),
242 ("/docs2", Some("/docs2"), false, true, false, false),
243 ("/docsx/y", Some("/docsx/y"), false, true, false, false),
244 (
245 "/STATIC/app.js",
246 Some("/STATIC/app.js"),
247 false,
248 true,
249 false,
250 false,
251 ),
252 ("/a//b/./c", Some("/a/b/c"), false, true, false, false),
253 ("/a/b/..", Some("/a"), false, true, false, false),
254 ("/a;x=1/../b", Some("/b"), false, true, false, false),
255 ("/%252e%252e/x", Some("/x"), false, true, false, false),
256 ("/%2525252e", Some("/"), false, true, false, false),
257 ("/%ZZ", Some("/%ZZ"), false, true, false, false),
258 ("/%ff", None, false, false, false, false),
259 (
260 "/%e2%82%ac/x",
261 Some("/\u{20ac}/x"),
262 false,
263 true,
264 false,
265 false,
266 ),
267 ("/a\\b", Some("/a/b"), false, true, false, false),
268 ("", Some("/"), false, true, false, false),
269 ("/", Some("/"), false, true, false, false),
270 ("//static//x", Some("/static/x"), true, true, true, true),
271 (
272 "/static;a=b/x",
273 Some("/static;a=b/x"),
274 false,
275 true,
276 false,
277 false,
278 ),
279 ("/%00", Some("/\u{0}"), false, true, false, false),
280 ("/%2F..%2Fadmin", Some("/admin"), false, true, false, false),
281 ("/%252fstatic", Some("/static"), true, true, true, true),
282 ("/x/..;/static/y", Some("/static/y"), true, true, true, true),
283 ("/static/..;/y", Some("/y"), false, true, false, false),
284 ("/%5c..%5cstatic", Some("/static"), true, true, true, true),
285 ];
286
287 #[test]
288 fn normalization_and_matching_follow_the_reference_table() {
289 for (raw, normalized, defaults, root, trailing, encoded) in REFERENCE {
290 assert_eq!(
291 normalize_url_path(raw).as_deref(),
292 *normalized,
293 "normalize {raw:?}"
294 );
295 assert_eq!(
296 path_is_excluded(raw, &DEFAULTS),
297 *defaults,
298 "defaults {raw:?}"
299 );
300 assert_eq!(path_is_excluded(raw, &["/"]), *root, "root {raw:?}");
301 assert_eq!(
302 path_is_excluded(raw, &["/static/", "/a/./b/../c"]),
303 *trailing,
304 "trailing {raw:?}"
305 );
306 assert_eq!(
307 path_is_excluded(raw, &["/%73tatic"]),
308 *encoded,
309 "encoded {raw:?}"
310 );
311 }
312 }
313
314 #[test]
315 fn exclusions_normalize_and_drop_the_undecodable() {
316 assert_eq!(
317 normalize_exclude_paths(["/a/", "//b", "/%ff", "", "c/../d"]),
318 ["/a", "/b", "/", "/d"]
319 );
320 }
321
322 #[test]
323 fn the_default_static_exclusion_covers_its_subtree() {
324 let exclusions = PathExclusions::new(DEFAULTS);
325 assert!(exclusions.is_excluded("/static/app.js"));
326 assert!(!exclusions.is_excluded("/staticx/app.js"));
327 assert!(!exclusions.is_excluded("/%ff"));
328 assert_eq!(exclusions.normalized().len(), DEFAULTS.len());
329 }
330}