revault_lockbox_api/paths/
lockbox_path.rs1use std::borrow::Borrow;
2use std::fmt;
3use std::ops::Deref;
4
5use unicode_normalization::UnicodeNormalization;
6
7use crate::constants::{MAX_COMPONENT_BYTES, MAX_PATH_BYTES, MAX_PATH_DEPTH};
8use crate::{Error, Result};
9
10#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct LockboxPath(String);
17
18impl LockboxPath {
19 pub fn new(path: impl AsRef<str>) -> Result<Self> {
27 Self::from_api(path.as_ref(), true)
28 }
29
30 pub fn as_str(&self) -> &str {
32 &self.0
33 }
34
35 pub fn parent(&self) -> Result<Option<Self>> {
37 let Some(index) = self.0.rfind('/') else {
38 return Err(Error::InvalidPath(self.0.clone()));
39 };
40 if index == 0 {
41 return Ok(None);
42 }
43 Ok(Some(Self::from_api(&self.0[..index], false)?))
44 }
45
46 pub fn is_descendant_of(&self, directory: &Self) -> bool {
48 self != directory && self.0.starts_with(&directory.descendant_prefix())
49 }
50
51 pub fn is_direct_child_of(&self, directory: &Self) -> bool {
53 if !self.is_descendant_of(directory) {
54 return false;
55 }
56 let prefix = directory.descendant_prefix();
57 let remainder = &self.0[prefix.len()..];
58 !remainder.is_empty() && !remainder.contains('/')
59 }
60
61 pub(crate) fn descendant_prefix(&self) -> String {
62 format!("{}/", self.0.trim_end_matches('/'))
63 }
64
65 pub(crate) fn from_api(path: &str, allow_dir: bool) -> Result<Self> {
66 Ok(Self(canonicalize_api_path(path, allow_dir)?))
67 }
68
69 pub(crate) fn from_stored(path: &str, allow_dir: bool) -> Result<Self> {
70 Ok(Self(canonicalize_stored_path(path, allow_dir)?))
71 }
72
73 pub(crate) fn as_file_path(&self) -> Result<&str> {
74 validate_lockbox_path(&self.0, false)?;
75 Ok(&self.0)
76 }
77
78 pub(crate) fn file_path(&self) -> Result<Self> {
79 self.as_file_path()?;
80 Ok(self.clone())
81 }
82
83 #[cfg(test)]
84 pub(crate) fn from_unchecked_for_test(path: impl Into<String>) -> Self {
85 Self(path.into())
86 }
87}
88
89impl Borrow<str> for LockboxPath {
90 fn borrow(&self) -> &str {
91 &self.0
92 }
93}
94
95impl AsRef<str> for LockboxPath {
96 fn as_ref(&self) -> &str {
97 &self.0
98 }
99}
100
101impl fmt::Display for LockboxPath {
102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 f.write_str(&self.0)
104 }
105}
106
107impl Deref for LockboxPath {
108 type Target = str;
109
110 fn deref(&self) -> &Self::Target {
111 &self.0
112 }
113}
114
115impl PartialEq<&str> for LockboxPath {
116 fn eq(&self, other: &&str) -> bool {
117 self.0 == *other
118 }
119}
120
121impl PartialEq<LockboxPath> for &str {
122 fn eq(&self, other: &LockboxPath) -> bool {
123 *self == other.0
124 }
125}
126
127impl TryFrom<&str> for LockboxPath {
128 type Error = Error;
129
130 fn try_from(value: &str) -> Result<Self> {
131 Self::new(value)
132 }
133}
134
135impl TryFrom<String> for LockboxPath {
136 type Error = Error;
137
138 fn try_from(value: String) -> Result<Self> {
139 Self::new(value)
140 }
141}
142
143pub(crate) fn canonicalize_api_path(path: &str, allow_dir: bool) -> Result<String> {
144 if path.is_ascii() {
145 validate_lockbox_path(path, allow_dir)?;
146 return Ok(path.to_string());
147 }
148 let normalized = path.nfc().collect::<String>();
149 validate_lockbox_path(&normalized, allow_dir)?;
150 Ok(normalized)
151}
152
153pub(crate) fn canonicalize_stored_path(path: &str, allow_dir: bool) -> Result<String> {
154 if path.is_ascii() {
155 validate_lockbox_path(path, allow_dir)?;
156 return Ok(path.to_string());
157 }
158 let normalized = path.nfc().collect::<String>();
159 if normalized != path {
160 return Err(Error::InvalidPath(path.to_string()));
161 }
162 validate_lockbox_path(path, allow_dir)?;
163 Ok(normalized)
164}
165
166pub(crate) fn validate_stored_path(path: &str) -> Result<()> {
167 LockboxPath::from_stored(path, false).map(|_| ())
168}
169
170pub(crate) fn validate_symlink_paths(link_path: &str, target_path: &str) -> Result<()> {
171 LockboxPath::from_api(link_path, false)?;
172 LockboxPath::from_api(target_path, false)?;
173 Ok(())
174}
175
176pub(crate) fn validate_glob(pattern: &str) -> Result<String> {
177 if pattern.is_empty()
178 || pattern.len() > MAX_PATH_BYTES
179 || pattern.starts_with('/')
180 || pattern.starts_with("//")
181 || pattern.contains('\\')
182 || pattern.contains('\0')
183 || pattern.contains(':')
184 || pattern.chars().any(is_forbidden_unicode)
185 {
186 return Err(Error::InvalidPath(pattern.to_string()));
187 }
188 for component in pattern.split('/') {
189 if component.is_empty() || component == "." || component == ".." {
190 return Err(Error::InvalidPath(pattern.to_string()));
191 }
192 }
193 if pattern.is_ascii() {
194 Ok(pattern.to_string())
195 } else {
196 Ok(pattern.nfc().collect::<String>())
197 }
198}
199
200pub(crate) fn glob_matches(pattern: &str, text: &str) -> bool {
201 let pattern_parts: Vec<&str> = pattern.split('/').collect();
202 let text_parts: Vec<&str> = text.split('/').collect();
203 glob_match_parts(&pattern_parts, &text_parts)
204}
205
206fn validate_lockbox_path(path: &str, allow_dir: bool) -> Result<()> {
207 if path.is_ascii() {
208 return validate_ascii_lockbox_path(path, allow_dir);
209 }
210
211 let invalid = || Error::InvalidPath(path.to_string());
212 let path = if allow_dir && path.len() > 1 {
213 path.trim_end_matches('/')
214 } else {
215 path
216 };
217
218 if allow_dir && path == "/" {
219 return Ok(());
220 }
221
222 if path.is_empty()
223 || path.len() > MAX_PATH_BYTES
224 || !path.starts_with('/')
225 || path.starts_with("//")
226 || path.contains('\\')
227 || path.contains('\0')
228 || path.chars().any(is_forbidden_unicode)
229 || path.contains(':')
230 {
231 return Err(invalid());
232 }
233
234 if !allow_dir && (path.len() == 1 || path.ends_with('/')) {
235 return Err(invalid());
236 }
237
238 let mut depth = 0usize;
239 for component in path.split('/').skip(1) {
240 if component.is_empty()
241 || component == "."
242 || component == ".."
243 || component.len() > MAX_COMPONENT_BYTES
244 {
245 return Err(invalid());
246 }
247 depth += 1;
248 if depth > MAX_PATH_DEPTH {
249 return Err(invalid());
250 }
251 }
252 Ok(())
253}
254
255fn validate_ascii_lockbox_path(path: &str, allow_dir: bool) -> Result<()> {
256 let invalid = || Error::InvalidPath(path.to_string());
257 let path = if allow_dir && path.len() > 1 {
258 path.trim_end_matches('/')
259 } else {
260 path
261 };
262
263 if allow_dir && path == "/" {
264 return Ok(());
265 }
266
267 if path.is_empty()
268 || path.len() > MAX_PATH_BYTES
269 || !path.starts_with('/')
270 || path.starts_with("//")
271 || path
272 .as_bytes()
273 .iter()
274 .any(|byte| matches!(*byte, 0x00..=0x1f | 0x7f | b'\\' | b':'))
275 {
276 return Err(invalid());
277 }
278
279 if !allow_dir && (path.len() == 1 || path.ends_with('/')) {
280 return Err(invalid());
281 }
282
283 let mut depth = 0usize;
284 for component in path.split('/').skip(1) {
285 if component.is_empty()
286 || component == "."
287 || component == ".."
288 || component.len() > MAX_COMPONENT_BYTES
289 {
290 return Err(invalid());
291 }
292 depth += 1;
293 if depth > MAX_PATH_DEPTH {
294 return Err(invalid());
295 }
296 }
297 Ok(())
298}
299
300fn is_forbidden_unicode(ch: char) -> bool {
301 matches!(
302 ch,
303 '\u{0000}'..='\u{001f}'
304 | '\u{007f}'..='\u{009f}'
305 | '\u{00ad}'
306 | '\u{034f}'
307 | '\u{061c}'
308 | '\u{180e}'
309 | '\u{200b}'..='\u{200f}'
310 | '\u{202a}'..='\u{202e}'
311 | '\u{2060}'..='\u{206f}'
312 | '\u{fe00}'..='\u{fe0f}'
313 | '\u{e0100}'..='\u{e01ef}'
314 )
315}
316
317fn glob_match_parts(pattern: &[&str], text: &[&str]) -> bool {
318 if pattern.is_empty() {
319 return text.is_empty();
320 }
321 if pattern[0] == "**" {
322 return glob_match_parts(&pattern[1..], text)
323 || (!text.is_empty() && glob_match_parts(pattern, &text[1..]));
324 }
325 if text.is_empty() {
326 return false;
327 }
328 glob_match_component(pattern[0], text[0]) && glob_match_parts(&pattern[1..], &text[1..])
329}
330
331fn glob_match_component(pattern: &str, text: &str) -> bool {
332 let pattern: Vec<char> = pattern.chars().collect();
333 let text: Vec<char> = text.chars().collect();
334 let mut p = 0usize;
335 let mut t = 0usize;
336 let mut star = None;
337 let mut star_text = 0usize;
338
339 while t < text.len() {
340 if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
341 p += 1;
342 t += 1;
343 } else if p < pattern.len() && pattern[p] == '*' {
344 star = Some(p);
345 p += 1;
346 star_text = t;
347 } else if let Some(star_pos) = star {
348 p = star_pos + 1;
349 star_text += 1;
350 t = star_text;
351 } else {
352 return false;
353 }
354 }
355
356 while p < pattern.len() && pattern[p] == '*' {
357 p += 1;
358 }
359 p == pattern.len()
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn symlink_validation_requires_lockbox_paths() {
368 assert!(validate_symlink_paths("/links/current", "/docs/current").is_ok());
369
370 for target in [
371 "../outside",
372 "/safe/../outside",
373 "/C:/Users/target",
374 "//server/share/target",
375 "/safe\\target",
376 "/safe/\0target",
377 ] {
378 assert!(
379 matches!(
380 validate_symlink_paths("/links/current", target),
381 Err(Error::InvalidPath(_))
382 ),
383 "target should be rejected: {target:?}"
384 );
385 }
386
387 assert!(matches!(
388 validate_symlink_paths("/links/../current", "/docs/current"),
389 Err(Error::InvalidPath(_))
390 ));
391 }
392}