Skip to main content

flymcprust_core/
uri_template.rs

1//! RFC 6570 URI templates: the subset needed to match resource URIs.
2//!
3//! Supported expressions:
4//!
5//! | Expression | Matches |
6//! | --- | --- |
7//! | `{var}` | One or more characters other than `/`, `?` and `#` (percent-decoded) |
8//! | `{+var}` | One or more characters other than `?` and `#`, so it may span `/` (percent-decoded) |
9//! | `{/var}` | `/` followed by a `{var}` value |
10//!
11//! Decoded values are checked again, so encoding cannot smuggle characters
12//! past these rules: `{var}`, `{/var}` and query values never contain `/` or
13//! `\` (not even as `%2F` or `%5C`), and no value contains control
14//! characters (such as `%00` or `%0A`). A URI that breaks these rules does
15//! not match. `{+var}` values may contain `/` and `..`: handlers that map them
16//! to files must confine them (see the `security` module of `flymcprust`).
17//! | `{?a,b}`, `{&c}` | Optional query parameters, in any order (only at the end) |
18//!
19//! Matching is deterministic and linear in the URI length: a variable ends at
20//! the first occurrence of the literal that follows it (`{var}`, `{/var}`) or
21//! at the last occurrence (`{+var}`). Templates are checked when the server is
22//! built, so ambiguous shapes (two adjacent variables, two `{+var}`) are
23//! rejected up front.
24
25use std::fmt;
26
27/// URIs longer than this never match a template.
28const MAX_MATCH_LEN: usize = 8 * 1024;
29
30/// A parsed URI template.
31#[derive(Clone, PartialEq, Eq)]
32pub struct UriTemplate {
33    source: String,
34    parts: Vec<Part>,
35    query: Vec<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39enum Part {
40    Literal(String),
41    Var { name: String, op: Op },
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45enum Op {
46    /// `{var}`
47    Simple,
48    /// `{+var}`
49    Reserved,
50    /// `{/var}`
51    Path,
52}
53
54/// A template that cannot be parsed or is not supported.
55#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
56#[error("invalid URI template `{template}`: {reason}")]
57pub struct UriTemplateError {
58    template: String,
59    reason: String,
60}
61
62impl UriTemplate {
63    /// Parses a template.
64    ///
65    /// # Errors
66    ///
67    /// Returns a [`UriTemplateError`] for malformed templates and for RFC 6570
68    /// features outside the supported subset (see the module documentation).
69    pub fn parse(template: &str) -> Result<Self, UriTemplateError> {
70        let fail = |reason: &str| UriTemplateError {
71            template: template.to_owned(),
72            reason: reason.to_owned(),
73        };
74        if template.is_empty() {
75            return Err(fail("the template is empty"));
76        }
77        let mut parts = Vec::new();
78        let mut query: Vec<String> = Vec::new();
79        let mut names: Vec<String> = Vec::new();
80        let mut rest = template;
81        while !rest.is_empty() {
82            if let Some(after_brace) = rest.strip_prefix('{') {
83                let end = after_brace.find('}').ok_or_else(|| fail("unclosed `{`"))?;
84                let (expr, tail) = after_brace.split_at(end);
85                rest = tail.get(1..).unwrap_or_default();
86                let (op, list) = match expr.chars().next() {
87                    Some('+') => (Some(Op::Reserved), expr.get(1..).unwrap_or_default()),
88                    Some('/') => (Some(Op::Path), expr.get(1..).unwrap_or_default()),
89                    Some('?' | '&') => (None, expr.get(1..).unwrap_or_default()),
90                    Some('#' | '.' | ';' | '=' | ',' | '!' | '@' | '|') => {
91                        return Err(fail(
92                            "only `{var}`, `{+var}`, `{/var}` and `{?var}` expressions are supported",
93                        ));
94                    }
95                    _ => (Some(Op::Simple), expr),
96                };
97                let vars: Vec<&str> = list.split(',').collect();
98                for var in &vars {
99                    if var.ends_with('*') || var.contains(':') {
100                        return Err(fail("variable modifiers (`*`, `:n`) are not supported"));
101                    }
102                    if !is_var_name(var) {
103                        return Err(fail(
104                            "variable names must be non-empty and use only A-Z, a-z, 0-9 and `_`",
105                        ));
106                    }
107                    if names.iter().any(|n| n == var) {
108                        return Err(fail("a variable appears twice"));
109                    }
110                    names.push((*var).to_owned());
111                }
112                match op {
113                    None => query.extend(vars.iter().map(|v| (*v).to_owned())),
114                    Some(op) => {
115                        if !query.is_empty() {
116                            return Err(fail("query expressions must come last"));
117                        }
118                        let [name] = vars.as_slice() else {
119                            return Err(fail(
120                                "path expressions take exactly one variable; use one expression per variable",
121                            ));
122                        };
123                        if let Some(Part::Var { op: prev, .. }) = parts.last()
124                            && !(op == Op::Path && *prev != Op::Reserved)
125                        {
126                            return Err(fail(
127                                "two variables must be separated by a literal (or use `{/var}`)",
128                            ));
129                        }
130                        if op == Op::Reserved
131                            && parts.iter().any(|p| {
132                                matches!(
133                                    p,
134                                    Part::Var {
135                                        op: Op::Reserved,
136                                        ..
137                                    }
138                                )
139                            })
140                        {
141                            return Err(fail("at most one `{+var}` expression is supported"));
142                        }
143                        parts.push(Part::Var {
144                            name: (*name).to_owned(),
145                            op,
146                        });
147                    }
148                }
149            } else {
150                let end = rest.find(['{', '}']).unwrap_or(rest.len());
151                if rest.starts_with('}') {
152                    return Err(fail("unmatched `}`"));
153                }
154                let (literal, tail) = rest.split_at(end);
155                if literal.chars().any(|c| c.is_control() || c == ' ') {
156                    return Err(fail("literals cannot contain spaces or control characters"));
157                }
158                if !query.is_empty() {
159                    return Err(fail("query expressions must come last"));
160                }
161                parts.push(Part::Literal(literal.to_owned()));
162                rest = tail;
163            }
164        }
165        if !matches!(parts.first(), Some(Part::Literal(_))) {
166            return Err(fail(
167                "the template must start with a literal scheme, e.g. `file:///{path}`",
168            ));
169        }
170        Ok(Self {
171            source: template.to_owned(),
172            parts,
173            query,
174        })
175    }
176
177    /// The template text.
178    #[must_use]
179    pub fn as_str(&self) -> &str {
180        &self.source
181    }
182
183    /// The variable names, in order of appearance.
184    pub fn variables(&self) -> impl Iterator<Item = &str> {
185        self.parts
186            .iter()
187            .filter_map(|p| match p {
188                Part::Var { name, .. } => Some(name.as_str()),
189                Part::Literal(_) => None,
190            })
191            .chain(self.query.iter().map(String::as_str))
192    }
193
194    /// Matches a URI, returning the (percent-decoded) variable values.
195    #[must_use]
196    pub fn matches(&self, uri: &str) -> Option<UriVars> {
197        if uri.len() > MAX_MATCH_LEN {
198            return None;
199        }
200        let (path, query) = if self.query.is_empty() {
201            (uri, None)
202        } else {
203            match uri.split_once('?') {
204                Some((path, query)) => (path, Some(query)),
205                None => (uri, None),
206            }
207        };
208        let mut vars = Vec::new();
209        let mut rest = path;
210        let mut parts = self.parts.iter().peekable();
211        while let Some(part) = parts.next() {
212            match part {
213                Part::Literal(literal) => rest = rest.strip_prefix(literal.as_str())?,
214                Part::Var { name, op } => {
215                    if *op == Op::Path {
216                        rest = rest.strip_prefix('/')?;
217                    }
218                    let forbidden: &[char] = match op {
219                        Op::Reserved => &['?', '#'],
220                        Op::Simple | Op::Path => &['/', '?', '#'],
221                    };
222                    let end = match parts.peek() {
223                        None => rest.len(),
224                        Some(Part::Literal(next)) => match op {
225                            Op::Reserved => rest.rfind(next.as_str())?,
226                            Op::Simple | Op::Path => rest.find(next.as_str())?,
227                        },
228                        // A `{/var}` follows: stop at the next `/`.
229                        Some(Part::Var { .. }) => rest.find('/').unwrap_or(rest.len()),
230                    };
231                    let (raw, tail) = rest.split_at(end);
232                    if raw.is_empty() || raw.contains(forbidden) {
233                        return None;
234                    }
235                    vars.push((name.clone(), decode_value(raw, *op != Op::Reserved)?));
236                    rest = tail;
237                }
238            }
239        }
240        if !rest.is_empty() {
241            return None;
242        }
243        if let Some(query) = query {
244            for pair in query.split('&').filter(|p| !p.is_empty()) {
245                let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
246                if let Some(name) = self.query.iter().find(|n| n.as_str() == key)
247                    && !vars.iter().any(|(n, _)| n == name)
248                {
249                    vars.push((name.clone(), decode_value(value, true)?));
250                }
251            }
252        }
253        Some(UriVars { vars })
254    }
255}
256
257impl fmt::Debug for UriTemplate {
258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
259        f.debug_tuple("UriTemplate").field(&self.source).finish()
260    }
261}
262
263impl fmt::Display for UriTemplate {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        f.write_str(&self.source)
266    }
267}
268
269/// Variable values extracted from a URI by [`UriTemplate::matches`].
270#[derive(Debug, Clone, Default, PartialEq, Eq)]
271pub struct UriVars {
272    vars: Vec<(String, String)>,
273}
274
275impl UriVars {
276    /// The value of a variable. Query variables absent from the URI are `None`.
277    #[must_use]
278    pub fn get(&self, name: &str) -> Option<&str> {
279        self.vars
280            .iter()
281            .find(|(n, _)| n == name)
282            .map(|(_, v)| v.as_str())
283    }
284
285    /// All `(name, value)` pairs.
286    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
287        self.vars.iter().map(|(n, v)| (n.as_str(), v.as_str()))
288    }
289}
290
291fn is_var_name(name: &str) -> bool {
292    !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
293}
294
295/// Percent-decodes a variable value and re-checks it: no control
296/// characters, and no path separators when `single_segment`.
297fn decode_value(raw: &str, single_segment: bool) -> Option<String> {
298    let value = percent_decode(raw)?;
299    let forbidden = |c: char| c.is_control() || (single_segment && matches!(c, '/' | '\\'));
300    (!value.contains(forbidden)).then_some(value)
301}
302
303/// Decodes `%XX` escapes; `None` for malformed escapes or invalid UTF-8.
304fn percent_decode(input: &str) -> Option<String> {
305    if !input.contains('%') {
306        return Some(input.to_owned());
307    }
308    let bytes = input.as_bytes();
309    let mut out = Vec::with_capacity(bytes.len());
310    let mut i = 0;
311    while let Some(&b) = bytes.get(i) {
312        if b == b'%' {
313            let hi = hex(*bytes.get(i + 1)?)?;
314            let lo = hex(*bytes.get(i + 2)?)?;
315            out.push(hi << 4 | lo);
316            i += 3;
317        } else {
318            out.push(b);
319            i += 1;
320        }
321    }
322    String::from_utf8(out).ok()
323}
324
325fn hex(b: u8) -> Option<u8> {
326    match b {
327        b'0'..=b'9' => Some(b - b'0'),
328        b'a'..=b'f' => Some(b - b'a' + 10),
329        b'A'..=b'F' => Some(b - b'A' + 10),
330        _ => None,
331    }
332}
333
334/// Whether `uri` looks like an absolute URI (`scheme:rest`, RFC 3986 scheme
335/// syntax) without whitespace or control characters.
336pub(crate) fn is_absolute_uri(uri: &str) -> bool {
337    let Some((scheme, rest)) = uri.split_once(':') else {
338        return false;
339    };
340    let mut chars = scheme.chars();
341    chars.next().is_some_and(|c| c.is_ascii_alphabetic())
342        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
343        && !rest.is_empty()
344        && !uri.chars().any(|c| c.is_control() || c.is_whitespace())
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn vars(template: &str, uri: &str) -> Option<Vec<(String, String)>> {
352        UriTemplate::parse(template)
353            .unwrap()
354            .matches(uri)
355            .map(|v| v.vars)
356    }
357
358    fn pairs(list: &[(&str, &str)]) -> Option<Vec<(String, String)>> {
359        Some(
360            list.iter()
361                .map(|(a, b)| ((*a).to_owned(), (*b).to_owned()))
362                .collect(),
363        )
364    }
365
366    #[test]
367    fn simple_variables() {
368        assert_eq!(
369            vars("test://template/{id}/data", "test://template/123/data"),
370            pairs(&[("id", "123")])
371        );
372        assert_eq!(
373            vars("test://template/{id}/data", "test://template//data"),
374            None
375        );
376        assert_eq!(
377            vars("test://template/{id}/data", "test://template/1/2/data"),
378            None
379        );
380        assert_eq!(
381            vars("db://{table}/{id}", "db://users/a%20b"),
382            pairs(&[("table", "users"), ("id", "a b")])
383        );
384        assert_eq!(vars("db://{table}/{id}", "db://users/%zz"), None);
385        assert_eq!(
386            vars("file:///{name}.txt", "file:///a.b.txt"),
387            pairs(&[("name", "a.b")])
388        );
389        assert_eq!(
390            vars("file:///{name}.txt", "file:///a.txt.txt"),
391            None,
392            "a simple variable ends at the first occurrence of the next literal"
393        );
394    }
395
396    #[test]
397    fn reserved_and_path_variables() {
398        assert_eq!(
399            vars("file:///{+path}", "file:///src/main.rs"),
400            pairs(&[("path", "src/main.rs")])
401        );
402        assert_eq!(
403            vars("file:///{+path}.meta", "file:///a.b/c.meta"),
404            pairs(&[("path", "a.b/c")])
405        );
406        assert_eq!(
407            vars("repo://{owner}{/name}", "repo://rust-lang/rust"),
408            pairs(&[("owner", "rust-lang"), ("name", "rust")])
409        );
410    }
411
412    #[test]
413    fn query_variables_are_optional_and_unordered() {
414        let t = "search://items{?q,limit}";
415        assert_eq!(
416            vars(t, "search://items?limit=5&q=a%2Bb&other=1"),
417            pairs(&[("limit", "5"), ("q", "a+b")])
418        );
419        assert_eq!(vars(t, "search://items"), pairs(&[]));
420        let parsed = UriTemplate::parse(t).unwrap();
421        assert_eq!(parsed.variables().collect::<Vec<_>>(), ["q", "limit"]);
422    }
423
424    #[test]
425    fn unsupported_templates_are_rejected() {
426        for bad in [
427            "",
428            "{x}",
429            "a://{x",
430            "a://x}",
431            "a://{x}{y}",
432            "a://{+x}/{+y}",
433            "a://{x*}",
434            "a://{x:3}",
435            "a://{#x}",
436            "a://{x,y}",
437            "a://{x}/{x}",
438            "a://{?q}/tail",
439            "a://{bad-name}",
440            "a:// {x}",
441        ] {
442            assert!(
443                UriTemplate::parse(bad).is_err(),
444                "{bad:?} should be rejected"
445            );
446        }
447    }
448
449    #[test]
450    fn decoded_values_are_rechecked() {
451        let t = "file:///docs/{name}";
452        for uri in [
453            "file:///docs/..%2Fsecret",
454            "file:///docs/..%5Csecret",
455            "file:///docs/..\\secret",
456            "file:///docs/a%00b",
457            "file:///docs/a%0Ab",
458        ] {
459            assert_eq!(vars(t, uri), None, "{uri}");
460        }
461        assert_eq!(vars(t, "file:///docs/a%20b"), pairs(&[("name", "a b")]));
462        assert_eq!(vars("q://x{?q}", "q://x?q=a%2Fb"), None);
463        // `{+var}` may span segments, but never carries control characters.
464        assert_eq!(
465            vars("file:///{+path}", "file:///a%2Fb/c"),
466            pairs(&[("path", "a/b/c")])
467        );
468        assert_eq!(vars("file:///{+path}", "file:///a%00"), None);
469    }
470
471    #[test]
472    fn long_uris_never_match() {
473        let uri = format!("file:///{}", "a".repeat(MAX_MATCH_LEN));
474        assert_eq!(vars("file:///{+path}", &uri), None);
475    }
476
477    #[test]
478    fn absolute_uris() {
479        assert!(is_absolute_uri("test://static-text"));
480        assert!(is_absolute_uri("urn:isbn:0451450523"));
481        assert!(!is_absolute_uri("no-scheme"));
482        assert!(!is_absolute_uri("1http://x"));
483        assert!(!is_absolute_uri("http://a b"));
484        assert!(!is_absolute_uri("http:"));
485    }
486}