churust_core/path.rs
1//! Percent-decoding for URL path segments.
2//!
3//! Deliberately separate from the query-string decoder in [`crate::call`]. Two
4//! differences matter, and both are defects if carried over:
5//!
6//! - `+` is a literal plus in a path. Only `application/x-www-form-urlencoded`
7//! query strings treat it as a space.
8//! - Invalid UTF-8 is an error, not `U+FFFD`. Replacement characters collapse
9//! distinct byte sequences into the same string, which is unsound input to a
10//! path-safety decision.
11//!
12//! Callers must split on `/` **before** decoding. Decoding first would let
13//! `%2F` manufacture separators and forge extra path segments.
14
15/// Percent-decode one path segment.
16///
17/// Returns `None` when the input contains a malformed escape (`%zz`, a
18/// truncated `%4`) or decodes to bytes that are not valid UTF-8. Callers turn
19/// `None` into `400 Bad Request`.
20pub(crate) fn decode_path_segment(raw: &str) -> Option<String> {
21 // Overwhelmingly the common case, and worth not allocating for.
22 if !raw.contains('%') {
23 return Some(raw.to_string());
24 }
25
26 let bytes = raw.as_bytes();
27 let mut out = Vec::with_capacity(bytes.len());
28 let mut i = 0;
29 while i < bytes.len() {
30 if bytes[i] == b'%' {
31 // A complete escape needs two more bytes.
32 if i + 2 >= bytes.len() {
33 return None;
34 }
35 let hi = (bytes[i + 1] as char).to_digit(16)?;
36 let lo = (bytes[i + 2] as char).to_digit(16)?;
37 out.push((hi * 16 + lo) as u8);
38 i += 3;
39 } else {
40 out.push(bytes[i]);
41 i += 1;
42 }
43 }
44
45 // Reject rather than replace. U+FFFD would map distinct byte sequences onto
46 // the same string, and that string then feeds a path-safety decision.
47 String::from_utf8(out).ok()
48}
49
50/// What to do when a request path is a non-canonical spelling of a route.
51///
52/// Interior empty segments — `//a`, `/a//b` — were once collapsed silently,
53/// which gave every resource several URLs. That is not a traversal problem, but
54/// it is an aliasing one, and aliases have teeth:
55///
56/// - Middleware, guards and proxy rules that key on a literal prefix
57/// (`path.starts_with("/admin")`) are bypassable with `//admin`.
58/// - Caches key on the URL, so one resource occupies several entries and an
59/// intermediary can disagree with the origin about identity.
60///
61/// A **trailing** slash is not treated as an alias — see
62/// [`canonical_path`] for why removing it would break directory listings.
63///
64/// Whatever the policy, `%2F` is never decoded into a separator: an encoded
65/// slash is data inside one segment, and decoding it early is how a
66/// normalisation change turns into a traversal bug.
67///
68/// ```
69/// use churust_core::{Churust, Call, PathPolicy, TestClient};
70/// # tokio::runtime::Runtime::new().unwrap().block_on(async {
71/// let app = Churust::server()
72/// .path_policy(PathPolicy::Redirect)
73/// .routing(|r| { r.get("/a", |_c: Call| async { "a" }); })
74/// .build();
75/// let res = TestClient::new(app).get("//a").send().await;
76/// assert_eq!(res.status(), http::StatusCode::PERMANENT_REDIRECT);
77/// assert_eq!(res.header("location"), Some("/a"));
78/// # });
79/// ```
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
81#[serde(rename_all = "lowercase")]
82pub enum PathPolicy {
83 /// Refuse an alias: `//a` is not `/a`, and gets `404`.
84 ///
85 /// The default. Fewest surprises and the strongest cache identity — one
86 /// resource, one URL.
87 #[default]
88 Strict,
89 /// Redirect an alias to its canonical form with `308 Permanent Redirect`.
90 ///
91 /// Kind to hand-typed and hand-written URLs. `308` rather than `301` so a
92 /// `POST` stays a `POST`; `301` permits a client to retry as `GET`, which
93 /// silently drops the body.
94 Redirect,
95 /// Collapse aliases silently, serving them as if canonical.
96 ///
97 /// The pre-`PathPolicy` behaviour, kept so an application with
98 /// alias-shaped links has somewhere to stand while it fixes them. It
99 /// creates URL aliases by design; prefer `Strict` or `Redirect`.
100 ///
101 /// The collapsing happens in the router, for *matching* only: the URI is
102 /// not rewritten, so `call.path()` still reports the spelling the client
103 /// sent — `//admin/secret`, not `/admin/secret` — in middleware and in the
104 /// handler alike. That is what makes the prefix-check bypass above live
105 /// under this policy and only under this policy. Under `Strict` and
106 /// `Redirect` a middleware also sees the raw spelling, but the request is
107 /// refused or redirected before any handler runs, so nothing is served.
108 Collapse,
109}
110
111/// The canonical spelling of `path`, or `None` if it is already canonical.
112///
113/// Canonical means no *interior* empty segments — no repeated slashes.
114///
115/// **A trailing slash is deliberately left alone.** It is not merely
116/// cosmetic: it distinguishes a directory from a file. A listing served at
117/// `/files/` emits bare relative links (`<a href="a.txt">`), which a browser
118/// resolves against the trailing slash; the same markup at `/files` would
119/// resolve to `/a.txt`. Rather than pick a spelling here, `StaticFiles`
120/// redirects a directory URL that lacks the slash (`308`), so there is still
121/// exactly one URL per directory — this policy just is not the thing enforcing
122/// it.
123///
124/// Interior slashes carry no such meaning, and they are the ones with teeth:
125/// `//admin` is what slips past a `path.starts_with("/admin")` check.
126///
127/// ```
128/// use churust_core::path::canonical_path;
129///
130/// assert_eq!(canonical_path("/a/b"), None); // already canonical
131/// assert_eq!(canonical_path("/"), None); // the root
132/// assert_eq!(canonical_path("/a/"), None); // trailing slash is significant
133/// assert_eq!(canonical_path("//a//b").as_deref(), Some("/a/b"));
134/// assert_eq!(canonical_path("//a//b/").as_deref(), Some("/a/b/"));
135/// ```
136pub fn canonical_path(path: &str) -> Option<String> {
137 // The overwhelmingly common case, checked first so ordinary requests never
138 // allocate.
139 if !path.contains("//") {
140 return None;
141 }
142 let trailing = path.len() > 1 && path.ends_with('/');
143 let joined: String = path
144 .split('/')
145 .filter(|s| !s.is_empty())
146 .collect::<Vec<_>>()
147 .join("/");
148 Some(match (joined.is_empty(), trailing) {
149 // `//` and `///` all mean the root.
150 (true, _) => "/".to_string(),
151 (false, true) => format!("/{joined}/"),
152 (false, false) => format!("/{joined}"),
153 })
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn decodes_percent_escapes() {
162 assert_eq!(decode_path_segment("John%20Doe").unwrap(), "John Doe");
163 assert_eq!(decode_path_segment("a%2Eb").unwrap(), "a.b");
164 assert_eq!(decode_path_segment("%E2%9C%93").unwrap(), "✓");
165 }
166
167 #[test]
168 fn leaves_plus_alone() {
169 assert_eq!(
170 decode_path_segment("a+b").unwrap(),
171 "a+b",
172 "`+` is a literal in a path; only query strings map it to a space"
173 );
174 }
175
176 #[test]
177 fn passes_through_plain_text() {
178 assert_eq!(decode_path_segment("users").unwrap(), "users");
179 assert_eq!(decode_path_segment("").unwrap(), "");
180 }
181
182 #[test]
183 fn decodes_dot_segments_without_interpreting_them() {
184 // Decoding produces "..". Rejecting it is the caller's job.
185 assert_eq!(decode_path_segment("%2e%2e").unwrap(), "..");
186 assert_eq!(decode_path_segment("%2E%2E").unwrap(), "..");
187 }
188
189 #[test]
190 fn decodes_once_only() {
191 // %252e is "%2e" after one pass, not ".". Double decoding is how
192 // traversal filters get bypassed.
193 assert_eq!(decode_path_segment("%252e").unwrap(), "%2e");
194 assert_eq!(decode_path_segment("%252e%252e").unwrap(), "%2e%2e");
195 }
196
197 #[test]
198 fn rejects_malformed_escapes() {
199 assert!(decode_path_segment("%zz").is_none());
200 assert!(decode_path_segment("%4").is_none());
201 assert!(decode_path_segment("%").is_none());
202 assert!(decode_path_segment("ok%").is_none());
203 assert!(decode_path_segment("%g0").is_none());
204 }
205
206 #[test]
207 fn rejects_invalid_utf8() {
208 // 0xFF is not valid UTF-8 in any position.
209 assert!(decode_path_segment("%FF").is_none());
210 // A lone continuation byte.
211 assert!(decode_path_segment("%80").is_none());
212 // A truncated multi-byte sequence.
213 assert!(decode_path_segment("%E2%9C").is_none());
214 }
215
216 #[test]
217 fn decodes_an_encoded_separator_to_a_literal() {
218 // The decoder decodes. Refusing separators is the caller's decision,
219 // because it differs between the router and StaticFiles.
220 assert_eq!(decode_path_segment("a%2Fb").unwrap(), "a/b");
221 assert_eq!(decode_path_segment("a%5Cb").unwrap(), "a\\b");
222 }
223}