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> {
30 DavPath::new(relative)
31}
32
33#[must_use]
35pub fn encode_href(path: &str) -> String {
36 utf8_percent_encode(path, DAV_HREF_PATH_SET).to_string()
37}
38
39#[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#[must_use]
52pub fn href_for_dav_path(prefix: &str, path: &DavPath) -> String {
53 href_for_relative(prefix, path.as_str())
54}
55
56pub 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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
113pub enum DavPathError {
114 #[error("invalid WebDAV path encoding")]
116 InvalidEncoding,
117 #[error("WebDAV path escapes the mount root")]
119 PathEscape,
120 #[error("WebDAV child name contains a path separator")]
122 InvalidChildName,
123}
124
125impl DavPath {
126 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 #[must_use]
141 pub fn root() -> Self {
142 Self {
143 canonical: "/".to_string(),
144 }
145 }
146
147 #[must_use]
149 pub fn as_bytes(&self) -> &[u8] {
150 self.canonical.as_bytes()
151 }
152
153 #[must_use]
155 pub fn as_str(&self) -> &str {
156 &self.canonical
157 }
158
159 #[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}