Skip to main content

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.
29pub fn decode_relative_path(relative: &str) -> Result<DavPath, DavPathError> {
30    DavPath::new(relative)
31}
32
33/// Percent-encodes a DAV href while preserving path separators.
34#[must_use]
35pub fn encode_href(path: &str) -> String {
36    utf8_percent_encode(path, DAV_HREF_PATH_SET).to_string()
37}
38
39/// Builds an encoded href from a mount prefix and decoded relative path.
40#[must_use]
41pub fn href_for_relative(prefix: &str, relative: &str) -> String {
42    let href = if relative == "/" {
43        format!("{prefix}/")
44    } else {
45        format!("{prefix}{relative}")
46    };
47    encode_href(&href)
48}
49
50/// Builds an encoded href from a mount prefix and canonical DAV path.
51#[must_use]
52pub fn href_for_dav_path(prefix: &str, path: &DavPath) -> String {
53    href_for_relative(prefix, path.as_str())
54}
55
56/// Returns a child path with collection trailing-slash semantics.
57pub fn child_relative_path(
58    parent: &str,
59    name: &[u8],
60    is_collection: bool,
61) -> Result<String, DavPathError> {
62    let name = str::from_utf8(name).map_err(|_| DavPathError::InvalidEncoding)?;
63    if name.contains(['/', '\\']) {
64        return Err(DavPathError::InvalidChildName);
65    }
66    let mut relative = if parent == "/" {
67        format!("/{name}")
68    } else if parent.ends_with('/') {
69        format!("{parent}{name}")
70    } else {
71        format!("{parent}/{name}")
72    };
73    if is_collection && !relative.ends_with('/') {
74        relative.push('/');
75    }
76    Ok(relative)
77}
78
79/// Returns the canonical parent collection path.
80#[must_use]
81pub fn parent_relative_path(relative: &str) -> Option<String> {
82    if relative == "/" {
83        return None;
84    }
85    let trimmed = relative.trim_end_matches('/');
86    let mut segments = trimmed
87        .split('/')
88        .filter(|segment| !segment.is_empty())
89        .collect::<Vec<_>>();
90    if segments.len() <= 1 {
91        return Some("/".to_string());
92    }
93    segments.pop();
94    Some(format!("/{}/", segments.join("/")))
95}
96
97/// Returns the final decoded segment for DAV display-name generation.
98#[must_use]
99pub fn display_name(relative: &str) -> &str {
100    if relative == "/" {
101        ""
102    } else {
103        relative
104            .trim_end_matches('/')
105            .rsplit('/')
106            .next()
107            .unwrap_or("")
108    }
109}
110
111/// Errors produced while canonicalizing a WebDAV path.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
113pub enum DavPathError {
114    /// The path contains malformed percent encoding.
115    #[error("invalid WebDAV path encoding")]
116    InvalidEncoding,
117    /// Dot-segment normalization would escape the WebDAV mount root.
118    #[error("WebDAV path escapes the mount root")]
119    PathEscape,
120    /// A backend child name contains a path separator.
121    #[error("WebDAV child name contains a path separator")]
122    InvalidChildName,
123}
124
125impl DavPath {
126    /// Percent-decodes and canonicalizes a path without allowing root escape.
127    pub fn new(path: &str) -> Result<Self, DavPathError> {
128        let encoded = ensure_leading_slash(path);
129        if contains_encoded_path_separator(&encoded) {
130            return Err(DavPathError::InvalidEncoding);
131        }
132        let decoded = percent_decode_str(&encoded)
133            .decode_utf8()
134            .map_err(|_| DavPathError::InvalidEncoding)?;
135        let canonical = clean_decoded_path(&decoded)?;
136        Ok(Self { canonical })
137    }
138
139    /// Returns the WebDAV mount root.
140    #[must_use]
141    pub fn root() -> Self {
142        Self {
143            canonical: "/".to_string(),
144        }
145    }
146
147    /// Returns the decoded canonical path bytes.
148    #[must_use]
149    pub fn as_bytes(&self) -> &[u8] {
150        self.canonical.as_bytes()
151    }
152
153    /// Returns the decoded canonical UTF-8 path.
154    #[must_use]
155    pub fn as_str(&self) -> &str {
156        &self.canonical
157    }
158
159    /// Returns whether the path denotes a collection alias.
160    #[must_use]
161    pub fn is_collection(&self) -> bool {
162        self.canonical == "/" || self.canonical.ends_with('/')
163    }
164}
165
166fn contains_encoded_path_separator(path: &str) -> bool {
167    path.as_bytes().windows(3).any(|window| {
168        let high = window[1].to_ascii_lowercase();
169        let low = window[2].to_ascii_lowercase();
170        window[0] == b'%' && matches!((high, low), (b'2', b'f') | (b'5', b'c'))
171    })
172}
173
174fn ensure_leading_slash(path: &str) -> String {
175    if path.is_empty() || path == "/" {
176        return "/".to_string();
177    }
178
179    let mut normalized = path.to_string();
180    if !normalized.starts_with('/') {
181        normalized.insert(0, '/');
182    }
183    normalized
184}
185
186fn clean_decoded_path(path: &str) -> Result<String, DavPathError> {
187    let mut segments = Vec::new();
188    let mut is_collection = false;
189
190    for (index, segment) in path.split('/').enumerate() {
191        match segment {
192            "" => {
193                if index > 0 {
194                    is_collection = true;
195                }
196            }
197            "." => is_collection = true,
198            ".." => {
199                if segments.pop().is_none() {
200                    return Err(DavPathError::PathEscape);
201                }
202                is_collection = true;
203            }
204            segment => {
205                segments.push(segment);
206                is_collection = false;
207            }
208        }
209    }
210
211    if segments.is_empty() {
212        return Ok("/".to_string());
213    }
214
215    let mut cleaned = format!("/{}", segments.join("/"));
216    if is_collection && !cleaned.ends_with('/') {
217        cleaned.push('/');
218    }
219    Ok(cleaned)
220}