aster_forge_webdav/
path.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
24pub struct DavPath {
25 canonical: String,
26}
27
28pub fn decode_relative_path(relative: &str) -> Result<DavPath, DavPathError> {
34 DavPath::new(relative)
35}
36
37#[must_use]
39pub fn encode_href(path: &str) -> String {
40 utf8_percent_encode(path, DAV_HREF_PATH_SET).to_string()
41}
42
43#[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#[must_use]
56pub fn href_for_dav_path(prefix: &str, path: &DavPath) -> String {
57 href_for_relative(prefix, path.as_str())
58}
59
60pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
121pub enum DavPathError {
122 #[error("invalid WebDAV path encoding")]
124 InvalidEncoding,
125 #[error("WebDAV path escapes the mount root")]
127 PathEscape,
128 #[error("invalid WebDAV child name")]
130 InvalidChildName,
131}
132
133impl DavPath {
134 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 #[must_use]
153 pub fn root() -> Self {
154 Self {
155 canonical: "/".to_string(),
156 }
157 }
158
159 #[must_use]
161 pub fn as_bytes(&self) -> &[u8] {
162 self.canonical.as_bytes()
163 }
164
165 #[must_use]
167 pub fn as_str(&self) -> &str {
168 &self.canonical
169 }
170
171 #[must_use]
173 pub fn is_collection(&self) -> bool {
174 self.canonical == "/" || self.canonical.ends_with('/')
175 }
176
177 #[must_use]
181 pub fn parent(&self) -> Option<Self> {
182 parent_relative_path(&self.canonical).map(|canonical| Self { canonical })
183 }
184
185 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}