autumn_plugin_topcoat/
path.rs1use std::fmt;
4use std::str::FromStr;
5
6use crate::error::PathError;
7
8pub const INTERNAL_PREFIX: &str = "/_topcoat";
10
11const 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub struct PathPrefix(Box<str>);
46
47impl PathPrefix {
48 pub fn new(prefix: &str) -> Result<Self, PathError> {
54 validate(prefix)?;
55 Ok(Self(prefix.into()))
56 }
57
58 #[must_use]
60 pub fn as_str(&self) -> &str {
61 &self.0
62 }
63
64 #[must_use]
66 pub fn matches(&self, path: &str) -> bool {
67 is_under(path, &self.0)
68 }
69
70 pub(crate) fn overlaps_internal(&self) -> bool {
72 is_under(&self.0, INTERNAL_PREFIX)
73 }
74
75 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#[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 #[must_use]
136 pub const fn root() -> Self {
137 Self(Repr::Root)
138 }
139
140 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 #[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 #[must_use]
171 pub const fn is_root(&self) -> bool {
172 matches!(self.0, Repr::Root)
173 }
174
175 #[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 #[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 #[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
245pub(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
251fn 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;