Skip to main content

revault_lockbox_api/paths/
lockbox_path.rs

1use 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/// Canonical path for an directory, file or symlink entry inside a lockbox.
11///
12/// `LockboxPath` is distinct from `std::path::Path`, which represents a host
13/// filesystem path. Lockbox paths always use `/` separators, are stored in
14/// canonical Unicode form, and are validated against the lockbox path rules.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub struct LockboxPath(String);
17
18impl LockboxPath {
19    /// Validate and canonicalize a lockbox path.
20    ///
21    /// The root path `/` and trailing slash directory paths are allowed for
22    /// APIs such as listing. File-specific APIs reject directory-only paths.
23    ///
24    /// Returns `Error::InvalidPath` if the path is relative, contains unsafe
25    /// components, exceeds path limits, or contains unsupported characters.
26    pub fn new(path: impl AsRef<str>) -> Result<Self> {
27        Self::from_api(path.as_ref(), true)
28    }
29
30    /// Return the canonical string form of this lockbox path.
31    pub fn as_str(&self) -> &str {
32        &self.0
33    }
34
35    /// Return the parent lockbox path, or `None` for the lockbox root.
36    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    /// Return true when this path is below `directory`.
47    pub fn is_descendant_of(&self, directory: &Self) -> bool {
48        self != directory && self.0.starts_with(&directory.descendant_prefix())
49    }
50
51    /// Return true when this path is an immediate child of `directory`.
52    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}