Skip to main content

autumn_plugin_topcoat/
path.rs

1//! Validated path prefixes and mount paths.
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::error::PathError;
7
8/// The Topcoat namespace for assets and runtime endpoints.
9pub const INTERNAL_PREFIX: &str = "/_topcoat";
10
11/// Namespaces that a mount path must not use, with their owners.
12const RESERVED: &[(&str, &str)] = &[
13    ("/static", "Autumn static files"),
14    ("/_autumn", "Autumn"),
15    ("/__autumn", "Autumn dev tools"),
16    ("/_stories", "Autumn widget stories"),
17    ("/actuator", "Autumn actuator"),
18    ("/health", "Autumn probes"),
19    ("/live", "Autumn probes"),
20    ("/ready", "Autumn probes"),
21    ("/startup", "Autumn probes"),
22    ("/openapi.json", "Autumn OpenAPI"),
23    ("/swagger-ui", "Autumn OpenAPI"),
24    (INTERNAL_PREFIX, "Topcoat"),
25];
26
27/// A validated path prefix, for example `/api`.
28///
29/// A prefix matches a path at segment boundaries: `/api` matches `/api` and
30/// `/api/users`, but not `/apix`. Matching uses the raw path, as axum does.
31///
32/// # Contract
33///
34/// - `new` never panics. It accepts `s` if and only if all these rules are
35///   true:
36///   - `s` starts with `/`, is not `/` and does not end with `/`.
37///   - No segment is empty, `.` or `..`.
38///   - Each character is an ASCII letter, a digit, `-`, `.`, `_` or `~`.
39/// - When `new` refuses `s`, the error is the first failed rule in this order:
40///   `Empty`, `Root`, `MissingLeadingSlash`, `TrailingSlash`, `EmptySegment`,
41///   `DotSegment`, `ForbiddenChar`.
42/// - `new(p.as_str()) == Ok(p)` for each valid `p`.
43/// - `matches(path) == (path == p || path.starts_with(p + "/"))`.
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct PathPrefix(Box<str>);
46
47impl PathPrefix {
48    /// Validates `prefix`.
49    ///
50    /// # Errors
51    ///
52    /// Returns the [`PathError`] for the first rule that `prefix` breaks.
53    pub fn new(prefix: &str) -> Result<Self, PathError> {
54        validate(prefix)?;
55        Ok(Self(prefix.into()))
56    }
57
58    /// Returns the prefix as a string.
59    #[must_use]
60    pub fn as_str(&self) -> &str {
61        &self.0
62    }
63
64    /// Returns `true` if `path` is equal to the prefix or is under it.
65    #[must_use]
66    pub fn matches(&self, path: &str) -> bool {
67        is_under(path, &self.0)
68    }
69
70    /// Returns `true` if the prefix is `/_topcoat` or is under it.
71    pub(crate) fn overlaps_internal(&self) -> bool {
72        is_under(&self.0, INTERNAL_PREFIX)
73    }
74
75    /// Returns `true` if the prefix is strictly under `parent`.
76    pub(crate) fn is_strictly_under(&self, parent: &Self) -> bool {
77        self.0 != parent.0 && is_under(&self.0, &parent.0)
78    }
79}
80
81impl FromStr for PathPrefix {
82    type Err = PathError;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        Self::new(s)
86    }
87}
88
89impl TryFrom<&str> for PathPrefix {
90    type Error = PathError;
91
92    fn try_from(value: &str) -> Result<Self, Self::Error> {
93        Self::new(value)
94    }
95}
96
97impl fmt::Display for PathPrefix {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        f.write_str(&self.0)
100    }
101}
102
103impl AsRef<str> for PathPrefix {
104    fn as_ref(&self) -> &str {
105        &self.0
106    }
107}
108
109/// The place where the plugin mounts Topcoat.
110///
111/// Topcoat always gets the full request path. The plugin never removes the
112/// prefix, so declare the Topcoat pages under the prefix.
113///
114/// # Contract
115///
116/// - `prefix(s)` applies the [`PathPrefix`] rules, then refuses each
117///   reserved namespace `r` (for example `/static`) with
118///   [`PathError::Reserved`] when `s == r` or `s` is under `r`.
119/// - `root().templates() == ["/", "/{*path}"]`.
120/// - `prefix(P).templates() == [P, P + "/", P + "/{*path}", "/_topcoat/{*path}"]`.
121/// - For each raw path `p`, `covers(p)` is `true` if and only if a matchit
122///   0.8.4 router with `templates()` matches `p`.
123#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
124pub struct MountPath(Repr);
125
126#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
127enum Repr {
128    #[default]
129    Root,
130    Prefix(PathPrefix),
131}
132
133impl MountPath {
134    /// Returns the root mount. Topcoat gets each path that Autumn does not route.
135    #[must_use]
136    pub const fn root() -> Self {
137        Self(Repr::Root)
138    }
139
140    /// Returns a mount under `prefix`, for example `/app`.
141    ///
142    /// # Errors
143    ///
144    /// Returns a [`PathError`] when `prefix` is not a valid prefix or is in a
145    /// reserved namespace.
146    pub fn prefix(prefix: &str) -> Result<Self, PathError> {
147        let prefix = PathPrefix::new(prefix)?;
148        if let Some((_, owner)) = RESERVED
149            .iter()
150            .find(|(reserved, _)| is_under(prefix.as_str(), reserved))
151        {
152            return Err(PathError::Reserved {
153                path: prefix.as_str().to_owned(),
154                owner,
155            });
156        }
157        Ok(Self(Repr::Prefix(prefix)))
158    }
159
160    /// Returns `/` for the root mount, else the prefix.
161    #[must_use]
162    pub fn as_str(&self) -> &str {
163        match &self.0 {
164            Repr::Root => "/",
165            Repr::Prefix(prefix) => prefix.as_str(),
166        }
167    }
168
169    /// Returns `true` for the root mount.
170    #[must_use]
171    pub const fn is_root(&self) -> bool {
172        matches!(self.0, Repr::Root)
173    }
174
175    /// Returns the prefix, or `None` for the root mount.
176    #[must_use]
177    pub const fn as_prefix(&self) -> Option<&PathPrefix> {
178        match &self.0 {
179            Repr::Root => None,
180            Repr::Prefix(prefix) => Some(prefix),
181        }
182    }
183
184    /// Returns the axum route templates that the plugin registers, in order.
185    #[must_use]
186    pub fn templates(&self) -> Vec<String> {
187        match &self.0 {
188            Repr::Root => vec!["/".to_owned(), "/{*path}".to_owned()],
189            Repr::Prefix(prefix) => {
190                let p = prefix.as_str();
191                vec![
192                    p.to_owned(),
193                    format!("{p}/"),
194                    format!("{p}/{{*path}}"),
195                    format!("{INTERNAL_PREFIX}/{{*path}}"),
196                ]
197            }
198        }
199    }
200
201    /// Returns `true` if one of the plugin route templates matches the raw
202    /// path `path`. Autumn routes and excluded prefixes can still take the
203    /// request.
204    #[must_use]
205    pub fn covers(&self, path: &str) -> bool {
206        match &self.0 {
207            Repr::Root => path.starts_with('/'),
208            Repr::Prefix(prefix) => {
209                is_under(path, prefix.as_str())
210                    || path
211                        .strip_prefix(INTERNAL_PREFIX)
212                        .and_then(|rest| rest.strip_prefix('/'))
213                        .is_some_and(|rest| !rest.is_empty())
214            }
215        }
216    }
217}
218
219impl FromStr for MountPath {
220    type Err = PathError;
221
222    fn from_str(s: &str) -> Result<Self, Self::Err> {
223        if s == "/" {
224            Ok(Self::root())
225        } else {
226            Self::prefix(s)
227        }
228    }
229}
230
231impl TryFrom<&str> for MountPath {
232    type Error = PathError;
233
234    fn try_from(value: &str) -> Result<Self, Self::Error> {
235        value.parse()
236    }
237}
238
239impl fmt::Display for MountPath {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        f.write_str(self.as_str())
242    }
243}
244
245/// Returns `true` if `path` is `prefix` or is under `prefix` at a segment boundary.
246pub(crate) fn is_under(path: &str, prefix: &str) -> bool {
247    path.strip_prefix(prefix)
248        .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
249}
250
251/// Applies the prefix grammar. See the [`PathPrefix`] contract.
252fn validate(prefix: &str) -> Result<(), PathError> {
253    let owned = || prefix.to_owned();
254    if prefix.is_empty() {
255        return Err(PathError::Empty);
256    }
257    if prefix == "/" {
258        return Err(PathError::Root);
259    }
260    let Some(rest) = prefix.strip_prefix('/') else {
261        return Err(PathError::MissingLeadingSlash { path: owned() });
262    };
263    if rest.ends_with('/') {
264        return Err(PathError::TrailingSlash { path: owned() });
265    }
266    let segments = || rest.split('/');
267    if segments().any(str::is_empty) {
268        return Err(PathError::EmptySegment { path: owned() });
269    }
270    if segments().any(|segment| segment == "." || segment == "..") {
271        return Err(PathError::DotSegment { path: owned() });
272    }
273    if let Some(ch) = rest
274        .chars()
275        .find(|&c| c != '/' && !(c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_' | '~')))
276    {
277        return Err(PathError::ForbiddenChar { path: owned(), ch });
278    }
279    Ok(())
280}
281
282#[cfg(test)]
283mod tests;