aster_forge_webdav/
path.rs

1//! Canonical `WebDAV` path handling.
2
3use std::str;
4
5use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
6
7const DAV_HREF_PATH_SET: &AsciiSet = &CONTROLS
8    .add(b' ')
9    .add(b'"')
10    .add(b'#')
11    .add(b'<')
12    .add(b'>')
13    .add(b'?')
14    .add(b'`')
15    .add(b'{')
16    .add(b'}')
17    .add(b'&')
18    .add(b'\'')
19    .add(b'+')
20    .add(b'%');
21
22/// A normalized path relative to a `WebDAV` mount.
23#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct DavPath {
25    canonical: String,
26}
27
28/// Parses a mount-relative request path and returns its canonical decoded representation.
29///
30/// # Errors
31///
32/// Returns [`DavPathError`] when percent-decoding fails or dot segments escape the mount.
33pub fn decode_relative_path(relative: &str) -> Result<DavPath, DavPathError> {
34    DavPath::new(relative)
35}
36
37/// Percent-encodes a DAV href while preserving path separators.
38#[must_use]
39pub fn encode_href(path: &str) -> String {
40    utf8_percent_encode(path, DAV_HREF_PATH_SET).to_string()
41}
42
43/// Builds an encoded href from a mount prefix and decoded relative path.
44#[must_use]
45pub fn href_for_relative(prefix: &str, relative: &str) -> String {
46    let href = if relative == "/" {
47        format!("{prefix}/")
48    } else {
49        format!("{prefix}{relative}")
50    };
51    encode_href(&href)
52}
53
54/// Builds an encoded href from a mount prefix and canonical DAV path.
55#[must_use]
56pub fn href_for_dav_path(prefix: &str, path: &DavPath) -> String {
57    href_for_relative(prefix, path.as_str())
58}
59
60/// Returns a child path with collection trailing-slash semantics.
61///
62/// # Errors
63///
64/// Returns [`DavPathError`] when the child name is invalid or escapes the parent path.
65pub fn child_relative_path(
66    parent: &str,
67    name: &[u8],
68    is_collection: bool,
69) -> Result<String, DavPathError> {
70    let name = str::from_utf8(name).map_err(|_| DavPathError::InvalidEncoding)?;
71    if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\']) {
72        return Err(DavPathError::InvalidChildName);
73    }
74    let mut relative = if parent == "/" {
75        format!("/{name}")
76    } else if parent.ends_with('/') {
77        format!("{parent}{name}")
78    } else {
79        format!("{parent}/{name}")
80    };
81    if is_collection && !relative.ends_with('/') {
82        relative.push('/');
83    }
84    Ok(relative)
85}
86
87/// Returns the canonical parent collection path.
88#[must_use]
89pub fn parent_relative_path(relative: &str) -> Option<String> {
90    if relative == "/" {
91        return None;
92    }
93    let trimmed = relative.trim_end_matches('/');
94    let mut segments = trimmed
95        .split('/')
96        .filter(|segment| !segment.is_empty())
97        .collect::<Vec<_>>();
98    if segments.len() <= 1 {
99        return Some("/".to_string());
100    }
101    segments.pop();
102    Some(format!("/{}/", segments.join("/")))
103}
104
105/// Returns the final decoded segment for DAV display-name generation.
106#[must_use]
107pub fn display_name(relative: &str) -> &str {
108    if relative == "/" {
109        ""
110    } else {
111        relative
112            .trim_end_matches('/')
113            .rsplit('/')
114            .next()
115            .unwrap_or("")
116    }
117}
118
119/// Errors produced while canonicalizing a `WebDAV` path.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
121pub enum DavPathError {
122    /// The path contains malformed percent encoding.
123    #[error("invalid WebDAV path encoding")]
124    InvalidEncoding,
125    /// Dot-segment normalization would escape the `WebDAV` mount root.
126    #[error("WebDAV path escapes the mount root")]
127    PathEscape,
128    /// A backend child name is empty, a dot segment, or contains a path separator.
129    #[error("invalid WebDAV child name")]
130    InvalidChildName,
131}
132
133impl DavPath {
134    /// Percent-decodes and canonicalizes a path without allowing root escape.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`DavPathError`] when the mount path is invalid or the URI escapes that mount.
139    pub fn new(path: &str) -> Result<Self, DavPathError> {
140        let encoded = ensure_leading_slash(path);
141        if contains_encoded_path_separator(&encoded) {
142            return Err(DavPathError::InvalidEncoding);
143        }
144        let decoded = percent_decode_str(&encoded)
145            .decode_utf8()
146            .map_err(|_| DavPathError::InvalidEncoding)?;
147        let canonical = clean_decoded_path(&decoded)?;
148        Ok(Self { canonical })
149    }
150
151    /// Returns the `WebDAV` mount root.
152    #[must_use]
153    pub fn root() -> Self {
154        Self {
155            canonical: "/".to_string(),
156        }
157    }
158
159    /// Returns the decoded canonical path bytes.
160    #[must_use]
161    pub fn as_bytes(&self) -> &[u8] {
162        self.canonical.as_bytes()
163    }
164
165    /// Returns the decoded canonical UTF-8 path.
166    #[must_use]
167    pub fn as_str(&self) -> &str {
168        &self.canonical
169    }
170
171    /// Returns whether the path denotes a collection alias.
172    #[must_use]
173    pub fn is_collection(&self) -> bool {
174        self.canonical == "/" || self.canonical.ends_with('/')
175    }
176
177    /// Returns the canonical parent collection without reparsing decoded path data.
178    ///
179    /// Returns `None` when this path is the `WebDAV` mount root.
180    #[must_use]
181    pub fn parent(&self) -> Option<Self> {
182        parent_relative_path(&self.canonical).map(|canonical| Self { canonical })
183    }
184
185    /// Joins one decoded backend child name without treating literal percent bytes as URI input.
186    ///
187    /// # Errors
188    ///
189    /// Returns [`DavPathError`] when the decoded child name is not UTF-8, is empty, is a dot
190    /// segment, or contains a path separator.
191    pub fn join_child(
192        &self,
193        decoded_name: &[u8],
194        is_collection: bool,
195    ) -> Result<Self, DavPathError> {
196        let canonical = child_relative_path(&self.canonical, decoded_name, is_collection)?;
197        Ok(Self { canonical })
198    }
199}
200
201fn contains_encoded_path_separator(path: &str) -> bool {
202    path.as_bytes().windows(3).any(|window| {
203        let high = window[1].to_ascii_lowercase();
204        let low = window[2].to_ascii_lowercase();
205        window[0] == b'%' && matches!((high, low), (b'2', b'f') | (b'5', b'c'))
206    })
207}
208
209fn ensure_leading_slash(path: &str) -> String {
210    if path.is_empty() || path == "/" {
211        return "/".to_string();
212    }
213
214    let mut normalized = path.to_string();
215    if !normalized.starts_with('/') {
216        normalized.insert(0, '/');
217    }
218    normalized
219}
220
221fn clean_decoded_path(path: &str) -> Result<String, DavPathError> {
222    let mut segments = Vec::new();
223    let mut is_collection = false;
224
225    for (index, segment) in path.split('/').enumerate() {
226        match segment {
227            "" => {
228                if index > 0 {
229                    is_collection = true;
230                }
231            }
232            "." => is_collection = true,
233            ".." => {
234                if segments.pop().is_none() {
235                    return Err(DavPathError::PathEscape);
236                }
237                is_collection = true;
238            }
239            segment => {
240                segments.push(segment);
241                is_collection = false;
242            }
243        }
244    }
245
246    if segments.is_empty() {
247        return Ok("/".to_string());
248    }
249
250    let mut cleaned = format!("/{}", segments.join("/"));
251    if is_collection && !cleaned.ends_with('/') {
252        cleaned.push('/');
253    }
254    Ok(cleaned)
255}