Skip to main content

textlane_core/helpers/
url.rs

1/// A relative or root-relative URL resolved to the file it names inside a site.
2#[derive(Clone, Debug, PartialEq, Eq)]
3pub struct ResolvedUrl<'u> {
4    /// The file's path inside the site's root, without a leading slash.
5    pub file: String,
6    /// The query and fragment, `?a#b`, as written.
7    pub suffix: &'u str,
8    /// Whether the URL names a directory: it ends in `/`, `.` or `..`.
9    pub directory: bool,
10}
11
12/// Resolve `url`, written in the file at `path`, to the file it names inside `root`; `None`
13/// for a URL with a scheme, protocol-relative, holding a backslash, a control character or
14/// surrounding space, or a fragment alone, or a relative one without a file to resolve it
15/// against or resolving outside `root`.
16pub fn resolve_url<'u>(
17    url: &'u str,
18    path: Option<&str>,
19    root: Option<&str>,
20) -> Option<ResolvedUrl<'u>> {
21    // Browsers read `\` as `/`, drop tabs and line endings and trim spaces, so
22    // `\\evil.dev`, `/\evil.dev` and `/\t/evil.dev` all name another host
23    if url.is_empty()
24        || url.starts_with('#')
25        || url.starts_with("//")
26        || url.contains('\\')
27        || url.chars().any(char::is_control)
28        || url.trim() != url
29        || has_scheme(url)
30    {
31        return None;
32    }
33    let end = url.find(['?', '#']).unwrap_or(url.len());
34    let (target, suffix) = url.split_at(end);
35    if target.is_empty() {
36        return None;
37    }
38    let file = match target.strip_prefix('/') {
39        Some(absolute) => normalize_path(absolute)?,
40        None => {
41            let directory = parent_of(&normalize_path(&slashes(path?))?);
42            let joined = normalize_path(&format!("{directory}/{target}"))?;
43            relative_to(&joined, root)?
44        }
45    };
46    Some(ResolvedUrl {
47        file,
48        suffix,
49        directory: names_directory(target),
50    })
51}
52
53/// `base` once before a root-relative URL: never twice, nothing for `/` or none.
54pub fn prefix_base(url: &str, base: Option<&str>) -> String {
55    let base = base
56        .map(|base| base.trim_end_matches('/'))
57        .unwrap_or_default();
58    if base.is_empty() || url == base || url.starts_with(&format!("{base}/")) {
59        return url.to_owned();
60    }
61    format!("{base}{url}")
62}
63
64// A scheme, `a+b-c.d:`, as URLs and is-absolute-url read one
65fn has_scheme(url: &str) -> bool {
66    let Some(colon) = url.find(':') else {
67        return false;
68    };
69    let scheme = &url.as_bytes()[..colon];
70    scheme.first().is_some_and(u8::is_ascii_alphabetic)
71        && scheme
72            .iter()
73            .all(|byte| byte.is_ascii_alphanumeric() || b"+-.".contains(byte))
74}
75
76// Normalizing drops a trailing slash, which a server reads as a different URL
77fn names_directory(target: &str) -> bool {
78    target.ends_with('/') || matches!(target.rsplit('/').next(), Some("." | ".."))
79}
80
81fn slashes(path: &str) -> String {
82    path.replace('\\', "/")
83}
84
85// `.` and `..` resolved, empty segments dropped; `None` when `..` climbs above the start,
86// whether the path is absolute or not
87fn normalize_path(path: &str) -> Option<String> {
88    let absolute = path.starts_with('/');
89    let mut segments: Vec<&str> = Vec::new();
90    for segment in path.split('/') {
91        match segment {
92            "" | "." => {}
93            ".." => {
94                segments.pop()?;
95            }
96            segment => segments.push(segment),
97        }
98    }
99    let joined = segments.join("/");
100    Some(if absolute {
101        format!("/{joined}")
102    } else {
103        joined
104    })
105}
106
107fn parent_of(path: &str) -> String {
108    match path.rfind('/') {
109        Some(slash) => path[..slash].to_owned(),
110        None => String::new(),
111    }
112}
113
114// The file's path inside `root`, without a leading slash; `None` outside it
115fn relative_to(path: &str, root: Option<&str>) -> Option<String> {
116    let Some(root) = root else {
117        return Some(path.trim_start_matches('/').to_owned());
118    };
119    let root = normalize_path(&slashes(root))?;
120    if root.is_empty() || root == "/" {
121        return Some(path.trim_start_matches('/').to_owned());
122    }
123    let inside = path.strip_prefix(&root)?;
124    match inside.strip_prefix('/') {
125        Some(inside) => Some(inside.to_owned()),
126        None if inside.is_empty() => Some(String::new()),
127        None => None,
128    }
129}
130
131#[cfg(test)]
132#[path = "url_unit.rs"]
133mod unit;