use alloc::vec::Vec;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UriTemplate<'a> {
literals: Vec<&'a str>,
leading_var: bool,
trailing_var: bool,
}
impl<'a> UriTemplate<'a> {
#[must_use]
pub fn parse(template: &'a str) -> Self {
let mut literals = Vec::new();
let mut leading_var = false;
let mut trailing_var = false;
let mut rest = template;
let mut first = true;
while let Some(open) = rest.find('{') {
let Some(close_offset) = rest[open..].find('}') else {
break;
};
let literal = &rest[..open];
if first && literal.is_empty() {
leading_var = true;
} else if !literal.is_empty() {
literals.push(literal);
}
first = false;
rest = &rest[open + close_offset + 1..];
trailing_var = true;
}
if !rest.is_empty() {
literals.push(rest);
trailing_var = false;
}
Self {
literals,
leading_var,
trailing_var,
}
}
#[must_use]
pub fn is_concrete(&self) -> bool {
!self.leading_var && !self.trailing_var && self.literals.len() <= 1
}
#[must_use]
pub fn matches(&self, uri: &str) -> bool {
self.walk(uri, |_| {})
}
#[must_use]
pub fn captures<'u>(&self, uri: &'u str) -> Option<Vec<&'u str>> {
let mut values = Vec::new();
self.walk(uri, |value| values.push(value)).then_some(values)
}
fn walk<'u>(&self, uri: &'u str, mut value: impl FnMut(&'u str)) -> bool {
if self.is_concrete() {
return self.literals.first().copied().unwrap_or("") == uri;
}
let mut rest = uri;
let mut literals = self.literals.iter();
if self.leading_var {
match literals.next() {
Some(literal) => {
let Some(index) = rest.find(literal).filter(|index| *index > 0) else {
return false;
};
if !is_valid_variable(&rest[..index]) {
return false;
}
value(&rest[..index]);
rest = &rest[index + literal.len()..];
}
None => {
value(rest);
return is_valid_trailing_variable(rest);
}
}
} else {
let Some(literal) = literals.next() else {
return false;
};
let Some(stripped) = rest.strip_prefix(literal) else {
return false;
};
rest = stripped;
}
for literal in literals {
let Some(index) = rest.find(literal).filter(|index| *index > 0) else {
return false;
};
if !is_valid_variable(&rest[..index]) {
return false;
}
value(&rest[..index]);
rest = &rest[index + literal.len()..];
}
if self.trailing_var {
value(rest);
is_valid_trailing_variable(rest)
} else {
rest.is_empty()
}
}
}
fn is_valid_variable(value: &str) -> bool {
!value.contains('/') && is_valid_trailing_variable(value)
}
fn is_valid_trailing_variable(value: &str) -> bool {
if value.is_empty() {
return false;
}
let Some(decoded) = percent_decode(value) else {
return false;
};
!decoded.contains(&0)
&& !decoded
.split(|&b| b == b'/')
.any(|segment| segment == b"..")
}
#[must_use]
pub fn decode(value: &str) -> Option<alloc::string::String> {
alloc::string::String::from_utf8(percent_decode(value)?).ok()
}
fn percent_decode(value: &str) -> Option<Vec<u8>> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = bytes.get(i + 1..i + 3)?;
let hi = char::from(hex[0]).to_digit(16)?;
let lo = char::from(hex[1]).to_digit(16)?;
decoded.push((hi * 16 + lo) as u8);
i += 3;
} else {
decoded.push(bytes[i]);
i += 1;
}
}
Some(decoded)
}
#[must_use]
pub fn matches(template: &str, uri: &str) -> bool {
UriTemplate::parse(template).matches(uri)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_concrete_template_matches_only_itself() {
assert!(matches("config://app", "config://app"));
assert!(!matches("config://app", "config://app/extra"));
assert!(!matches("config://app", "config://ap"));
}
#[test]
fn a_uri_the_template_cannot_produce_does_not_match() {
let template = "db://{table}/rows/{id}.json";
assert!(matches(template, "db://users/rows/7.json"));
assert!(!matches(template, "db://totally/unrelated/path.json"));
assert!(!matches(template, "db://x.json"));
assert!(!matches(template, "db://.json"));
assert!(!matches(template, "db://users/rows/.json"));
}
#[test]
fn templates_sharing_a_prefix_and_suffix_stay_distinct() {
let rows = "db://{table}/rows/{id}.json";
let meta = "db://{table}/meta.json";
assert!(matches(rows, "db://users/rows/7.json"));
assert!(!matches(meta, "db://users/rows/7.json"));
assert!(matches(meta, "db://users/meta.json"));
assert!(!matches(rows, "db://users/meta.json"));
}
#[test]
fn an_interior_variable_does_not_span_path_segments() {
assert!(matches("file:///{name}.txt", "file:///notes.txt"));
assert!(!matches("file:///{name}.txt", "file:///deep/notes.txt"));
}
#[test]
fn a_trailing_variable_spans_path_segments() {
assert!(matches("file:///{path}", "file:///notes.txt"));
assert!(matches("file:///{path}", "file:///deep/nested/notes.txt"));
assert!(matches(
"apple-doc://{topic}",
"apple-doc://swift/StringProtocol"
));
assert!(!matches("file:///{path}", "other:///notes.txt"));
assert!(!matches("file:///{path}", "file:///"));
}
#[test]
fn a_variable_must_be_non_empty() {
assert!(!matches("file:///{name}.txt", "file:///.txt"));
assert!(!matches("{whole}", ""));
assert!(matches("{whole}", "anything"));
}
#[test]
fn traversal_shapes_are_refused() {
for (template, hostile) in [
("file:///{path}", "file:///../secret.txt"),
("file:///{path}", "file:///docs/../../secret.txt"),
("file:///{path}", "file:///%2e%2e/secret.txt"),
("file:///{path}", "file:///docs/%2E%2E%2Fsecret.txt"),
("file:///{name}.txt", "file:///a%00b.txt"),
("file:///{name}.txt", "file:///bad%zz.txt"),
("file:///{name}.txt", "file:///trunc%2.txt"),
] {
assert!(!matches(template, hostile), "{hostile} should not route");
}
}
#[test]
fn percent_encoded_values_route() {
assert!(matches("file:///{path}", "file:///My%20Docs/a.txt"));
assert!(matches("db://{table}/rows", "db://order%20items/rows"));
assert!(matches("git://{range}", "git://v1..v2"));
}
#[test]
fn a_leading_variable_is_matched() {
assert!(matches("{host}/status", "example/status"));
assert!(!matches("{host}/status", "/status"));
assert!(!matches("{host}/status", "example/other"));
}
#[test]
fn a_trailing_variable_is_matched() {
assert!(matches("db://{table}", "db://users"));
assert!(!matches("db://{table}", "db://"));
assert!(!matches("db://{table}", "other://users"));
}
#[test]
fn an_unclosed_brace_is_treated_as_literal() {
assert!(matches("db://{table", "db://{table"));
assert!(!matches("db://{table", "db://anything"));
}
#[test]
fn captures_returns_each_variable_in_order() {
let template = UriTemplate::parse("db://{table}/rows/{id}.json");
assert_eq!(
template.captures("db://users/rows/7.json"),
Some(alloc::vec!["users", "7"])
);
assert_eq!(template.captures("db://totally/unrelated/path.json"), None);
assert_eq!(
UriTemplate::parse("file:///{path}").captures("file:///deep/notes.txt"),
Some(alloc::vec!["deep/notes.txt"])
);
assert_eq!(
UriTemplate::parse("{host}/status").captures("example/status"),
Some(alloc::vec!["example"])
);
assert_eq!(
UriTemplate::parse("config://app").captures("config://app"),
Some(alloc::vec![])
);
assert_eq!(
UriTemplate::parse("file:///{path}").captures("file:///../x"),
None
);
}
#[test]
fn is_concrete_distinguishes_templates_from_plain_uris() {
assert!(UriTemplate::parse("config://app").is_concrete());
assert!(!UriTemplate::parse("db://{table}").is_concrete());
assert!(!UriTemplate::parse("{whole}").is_concrete());
}
}