Skip to main content

mbr_core/vault/
path.rs

1//! [`VaultPath`]: a normalized, root-relative path that cannot name anything
2//! outside its vault.
3
4use std::fmt;
5use std::path::{Component, Path, PathBuf};
6
7use thiserror::Error;
8
9/// Why a string or native path is not a valid [`VaultPath`].
10#[derive(Debug, Clone, PartialEq, Eq, Error)]
11pub enum VaultPathError {
12    /// A `..` would climb above the vault root.
13    #[error("path escapes the vault root: {0:?}")]
14    Escapes(String),
15    /// The path is absolute: a leading `/` or `\`, a UNC share, a drive
16    /// (`C:`), or a native path with a root or prefix component.
17    #[error("path is absolute: {0:?}")]
18    Absolute(String),
19    /// The path contains a NUL byte, which no filesystem accepts and which
20    /// truncates the path at the C boundary.
21    #[error("path contains a NUL byte: {0:?}")]
22    Nul(String),
23    /// A segment is not a plain file name on this host — on Windows, a segment
24    /// such as `Q:x` parses as a drive-relative prefix.
25    #[error("path segment {segment:?} is not a plain file name: {path:?}")]
26    InvalidSegment { path: String, segment: String },
27    /// A native path component is not valid UTF-8. Vault paths are strings so
28    /// that they mean the same thing on every platform and over every storage
29    /// backend (Android SAF and sync libraries have no `OsStr`).
30    #[error("path is not valid UTF-8: {0:?}")]
31    NotUtf8(PathBuf),
32    /// An absolute path handed to [`VaultPath::from_absolute`] is not under the
33    /// given root.
34    #[error("{path:?} is not under the vault root {root:?}")]
35    NotUnderRoot { path: PathBuf, root: PathBuf },
36}
37
38/// A path inside a vault: root-relative, `/`-separated and normalized.
39///
40/// # Invariants
41///
42/// Every value of this type, however constructed, satisfies all of:
43///
44/// - it is either empty (the vault root itself) or a sequence of segments
45///   joined by a single `/`, with no leading or trailing `/`;
46/// - no segment is empty, `.` or `..`;
47/// - no segment contains `/`, `\` or NUL;
48/// - no segment is a bare drive designator (`C:`), on any platform;
49/// - every segment is a single plain file-name component on the host
50///   ([`std::path::Component::Normal`]), so [`VaultPath::to_native`] can only
51///   ever produce a path *under* the root it is given.
52///
53/// Together these mean a `VaultPath` cannot name anything outside its vault
54/// **lexically**. Symlinks are a separate question, answered where untrusted
55/// input enters: [`super::Vault::canonicalize`] resolves them and reports
56/// [`super::VaultError::OutsideRoot`] for a link that leaves the vault. Reads
57/// do not re-check, for the reason the desktop scanner never has: it reads
58/// every file in the repository, and a `realpath` per read would cost more than
59/// the read.
60///
61/// # Separators
62///
63/// Parsing treats both `/` and `\` as separators on every platform. A request
64/// such as `a\..\..\etc` therefore normalizes (and is refused) identically on
65/// Unix and Windows; the cost is that a Unix file whose name contains a
66/// backslash is not addressable through a vault, which no portable repository
67/// can contain anyway.
68///
69/// # Percent-encoding
70///
71/// None is decoded here. URL paths reach the resolver already decoded by axum,
72/// and authored hrefs go through
73/// [`crate::path_resolver::normalize_link_target`] first; decoding again would
74/// turn a literal `%2e%2e` file name into a traversal.
75#[derive(Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
76pub struct VaultPath(String);
77
78impl VaultPath {
79    /// The vault root itself (the empty path).
80    pub fn root() -> Self {
81        Self(String::new())
82    }
83
84    /// Parses a root-relative path, normalizing it lexically.
85    ///
86    /// Accepts `/`- or `\`-separated input. `.` segments and repeated or
87    /// trailing separators are dropped; `..` pops the previous segment and is
88    /// refused when there is nothing left to pop. A leading separator, a UNC
89    /// share or a drive designator is refused as [`VaultPathError::Absolute`]:
90    /// the callers of this function (request paths, repo-relative paths) are
91    /// relative by construction, so an absolute value is never a spelling of
92    /// something inside the vault.
93    pub fn new(path: &str) -> Result<Self, VaultPathError> {
94        if path.contains('\0') {
95            return Err(VaultPathError::Nul(path.to_string()));
96        }
97        if path.starts_with(['/', '\\']) {
98            return Err(VaultPathError::Absolute(path.to_string()));
99        }
100
101        // Built in place: this runs per request and per resolved path, so it
102        // allocates once and never per segment.
103        let mut normalized = String::with_capacity(path.len());
104        for segment in path.split(['/', '\\']) {
105            match segment {
106                "" | "." => {}
107                ".." => {
108                    if !pop_segment(&mut normalized) {
109                        return Err(VaultPathError::Escapes(path.to_string()));
110                    }
111                }
112                _ => {
113                    check_segment(segment).map_err(|problem| problem.error(path, segment))?;
114                    push_segment(&mut normalized, segment);
115                }
116            }
117        }
118        Ok(Self(normalized))
119    }
120
121    /// Converts a repo-relative **native** path (such as
122    /// [`crate::repo::MarkdownInfo::raw_path`]) into a vault path.
123    ///
124    /// `.` is dropped and `..` pops, as in [`Self::new`]; a root or prefix
125    /// component, or a non-UTF-8 component, is refused.
126    pub fn from_relative_native(path: &Path) -> Result<Self, VaultPathError> {
127        let mut normalized = String::with_capacity(path.as_os_str().len());
128        for component in path.components() {
129            match component {
130                Component::CurDir => {}
131                Component::ParentDir => {
132                    if !pop_segment(&mut normalized) {
133                        return Err(VaultPathError::Escapes(path.display().to_string()));
134                    }
135                }
136                Component::Normal(name) => {
137                    let name = name
138                        .to_str()
139                        .ok_or_else(|| VaultPathError::NotUtf8(path.to_path_buf()))?;
140                    // A native component can still hold a `\` on Unix, which
141                    // would become a separator on the way back out.
142                    if name.contains('\\') {
143                        return Err(VaultPathError::InvalidSegment {
144                            path: path.display().to_string(),
145                            segment: name.to_string(),
146                        });
147                    }
148                    check_segment(name)
149                        .map_err(|problem| problem.error(&path.display().to_string(), name))?;
150                    push_segment(&mut normalized, name);
151                }
152                Component::RootDir | Component::Prefix(_) => {
153                    return Err(VaultPathError::Absolute(path.display().to_string()));
154                }
155            }
156        }
157        Ok(Self(normalized))
158    }
159
160    /// Converts an absolute native path under `root` into a vault path.
161    ///
162    /// Containment is component-wise ([`Path::strip_prefix`]), never textual,
163    /// and purely lexical: `root` and `path` must be spelled in the same base —
164    /// both canonical, in the vault's case, which is what
165    /// [`super::LocalVault`] guarantees for the paths it produces.
166    pub fn from_absolute(root: &Path, path: &Path) -> Result<Self, VaultPathError> {
167        let relative = path
168            .strip_prefix(root)
169            .map_err(|_| VaultPathError::NotUnderRoot {
170                path: path.to_path_buf(),
171                root: root.to_path_buf(),
172            })?;
173        Self::from_relative_native(relative)
174    }
175
176    /// The path as a `/`-separated string; empty for the root.
177    pub fn as_str(&self) -> &str {
178        &self.0
179    }
180
181    /// Whether this is the vault root.
182    pub fn is_root(&self) -> bool {
183        self.0.is_empty()
184    }
185
186    /// The segments, outermost first. Empty for the root.
187    pub fn segments(&self) -> impl Iterator<Item = &str> {
188        self.0.split('/').filter(|segment| !segment.is_empty())
189    }
190
191    /// The last segment, or `None` for the root.
192    pub fn file_name(&self) -> Option<&str> {
193        if self.is_root() {
194            None
195        } else {
196            self.0.rsplit('/').next()
197        }
198    }
199
200    /// The final extension of [`Self::file_name`], as [`Path::extension`]
201    /// defines it (`archive.tar.gz` → `gz`; `.hidden` → none).
202    pub fn extension(&self) -> Option<&str> {
203        Path::new(self.file_name()?)
204            .extension()
205            .and_then(|ext| ext.to_str())
206    }
207
208    /// The containing directory, or `None` for the root.
209    pub fn parent(&self) -> Option<Self> {
210        if self.is_root() {
211            return None;
212        }
213        Some(match self.0.rfind('/') {
214            Some(idx) => Self(self.0[..idx].to_string()),
215            None => Self::root(),
216        })
217    }
218
219    /// This path and each ancestor, innermost first, ending at the root.
220    pub fn ancestors(&self) -> impl Iterator<Item = Self> {
221        std::iter::successors(Some(self.clone()), Self::parent)
222    }
223
224    /// Appends a relative path (parsed with [`Self::new`], so it may hold
225    /// several segments or `..`). A `..` may climb back out of `self` but never
226    /// past the vault root.
227    pub fn join(&self, relative: &str) -> Result<Self, VaultPathError> {
228        if self.is_root() {
229            return Self::new(relative);
230        }
231        if relative.starts_with(['/', '\\']) {
232            return Err(VaultPathError::Absolute(relative.to_string()));
233        }
234        Self::new(&format!("{}/{relative}", self.0))
235    }
236
237    /// Appends another vault path. Infallible: both sides already satisfy the
238    /// invariants, and concatenating valid segments cannot break them.
239    pub fn join_path(&self, other: &Self) -> Self {
240        match (self.is_root(), other.is_root()) {
241            (_, true) => self.clone(),
242            (true, false) => other.clone(),
243            (false, false) => Self(format!("{}/{}", self.0, other.0)),
244        }
245    }
246
247    /// Appends exactly one segment — a directory entry's own name. Unlike
248    /// [`Self::join`], separators are refused rather than split on, so a Unix
249    /// file called `a\b` cannot become the two segments `a/b`.
250    pub fn child(&self, name: &str) -> Result<Self, VaultPathError> {
251        let full = if self.is_root() {
252            name.to_string()
253        } else {
254            format!("{}/{name}", self.0)
255        };
256        if name.contains('\0') {
257            return Err(VaultPathError::Nul(full));
258        }
259        if name.contains(['/', '\\']) {
260            return Err(VaultPathError::InvalidSegment {
261                path: full,
262                segment: name.to_string(),
263            });
264        }
265        check_segment(name).map_err(|problem| problem.error(&full, name))?;
266        Ok(Self(full))
267    }
268
269    /// Whether `prefix` is this path or one of its ancestors (segment-wise:
270    /// `a/bc` does not start with `a/b`).
271    pub fn starts_with(&self, prefix: &Self) -> bool {
272        prefix.is_root()
273            || self.0 == prefix.0
274            || (self.0.starts_with(&prefix.0)
275                && self.0.as_bytes().get(prefix.0.len()) == Some(&b'/'))
276    }
277
278    /// The native path for this vault path under `root`.
279    ///
280    /// Always `root` itself or a descendant of it: no segment can be a root, a
281    /// prefix or a `..` (see the type's invariants), so appending the segments
282    /// with the platform separator is exactly what pushing them one at a time
283    /// would produce — built in one allocation, because every index key and
284    /// every vault I/O goes through here.
285    pub fn to_native(&self, root: &Path) -> PathBuf {
286        if self.is_root() {
287            return root.to_path_buf();
288        }
289        let root = root.as_os_str();
290        let mut native = std::ffi::OsString::with_capacity(root.len() + 1 + self.0.len());
291        native.push(root);
292        let ends_with_separator = root
293            .as_encoded_bytes()
294            .last()
295            .is_some_and(|&b| b == b'/' || b == std::path::MAIN_SEPARATOR as u8);
296        if !root.is_empty() && !ends_with_separator {
297            native.push(std::path::MAIN_SEPARATOR_STR);
298        }
299        if std::path::MAIN_SEPARATOR == '/' {
300            native.push(&self.0);
301        } else {
302            native.push(self.0.replace('/', std::path::MAIN_SEPARATOR_STR));
303        }
304        PathBuf::from(native)
305    }
306}
307
308/// Appends one segment to a normalized path under construction.
309fn push_segment(normalized: &mut String, segment: &str) {
310    if !normalized.is_empty() {
311        normalized.push('/');
312    }
313    normalized.push_str(segment);
314}
315
316/// Drops the last segment of a normalized path under construction; `false`
317/// when there was none (a `..` at the root).
318fn pop_segment(normalized: &mut String) -> bool {
319    if normalized.is_empty() {
320        return false;
321    }
322    let cut = normalized.rfind('/').unwrap_or(0);
323    normalized.truncate(cut);
324    true
325}
326
327/// What is wrong with a segment, before the error naming it is built — so the
328/// hot path never formats a message it does not need.
329enum SegmentProblem {
330    Invalid,
331    BareDrive,
332}
333
334impl SegmentProblem {
335    fn error(self, path: &str, segment: &str) -> VaultPathError {
336        match self {
337            Self::Invalid => VaultPathError::InvalidSegment {
338                path: path.to_string(),
339                segment: segment.to_string(),
340            },
341            Self::BareDrive => VaultPathError::Absolute(path.to_string()),
342        }
343    }
344}
345
346/// Validates one non-empty, non-`.`/`..` segment.
347fn check_segment(segment: &str) -> Result<(), SegmentProblem> {
348    if segment.is_empty() || segment == "." || segment == ".." {
349        return Err(SegmentProblem::Invalid);
350    }
351    // A bare drive designator is refused on every platform, so `C:/Windows`
352    // means the same thing (nothing) everywhere. Only the *bare* form: a Unix
353    // note called `Q: what next.md` is an ordinary file name there.
354    if is_bare_drive(segment) {
355        return Err(SegmentProblem::BareDrive);
356    }
357    // What the host itself would parse the segment as. On Windows `Q:x` is a
358    // drive-relative prefix and `PathBuf::push` would *replace* the root with
359    // it; on Unix it is a plain name. Either way only a single `Normal`
360    // component can be pushed under a root without leaving it. Once
361    // separators, `.` and `..` are excluded, a drive letter is the only shape
362    // any host parses as anything else, so the parse runs for those alone.
363    let bytes = segment.as_bytes();
364    if bytes.len() >= 2 && bytes[1] == b':' {
365        let mut components = Path::new(segment).components();
366        return match (components.next(), components.next()) {
367            (Some(Component::Normal(name)), None) if name == segment => Ok(()),
368            _ => Err(SegmentProblem::Invalid),
369        };
370    }
371    Ok(())
372}
373
374/// `C:` — an ASCII letter followed by a colon, and nothing else.
375fn is_bare_drive(segment: &str) -> bool {
376    let bytes = segment.as_bytes();
377    bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
378}
379
380impl fmt::Debug for VaultPath {
381    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382        write!(f, "VaultPath({:?})", self.0)
383    }
384}
385
386impl fmt::Display for VaultPath {
387    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
388        f.write_str(&self.0)
389    }
390}
391
392impl AsRef<str> for VaultPath {
393    fn as_ref(&self) -> &str {
394        &self.0
395    }
396}
397
398impl serde::Serialize for VaultPath {
399    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
400        serializer.serialize_str(&self.0)
401    }
402}
403
404impl TryFrom<&str> for VaultPath {
405    type Error = VaultPathError;
406
407    fn try_from(value: &str) -> Result<Self, Self::Error> {
408        Self::new(value)
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use proptest::prelude::*;
416
417    fn vp(s: &str) -> VaultPath {
418        VaultPath::new(s).unwrap_or_else(|e| panic!("{s:?}: {e}"))
419    }
420
421    #[test]
422    fn normalizes_separators_dots_and_trailing_slashes() {
423        assert_eq!(vp("a/b/c.md").as_str(), "a/b/c.md");
424        assert_eq!(vp("./a//b/./c.md/").as_str(), "a/b/c.md");
425        assert_eq!(vp("a\\b\\c.md").as_str(), "a/b/c.md");
426        assert_eq!(vp("a/x/../b").as_str(), "a/b");
427        assert_eq!(vp("").as_str(), "");
428        assert_eq!(vp(".").as_str(), "");
429        assert_eq!(vp("a/..").as_str(), "");
430        assert!(vp("").is_root());
431    }
432
433    #[test]
434    fn refuses_traversal_above_the_root() {
435        for input in [
436            "..",
437            "../x",
438            "a/../../x",
439            "a/b/../../../etc/passwd",
440            "..\\x",
441            "a\\..\\..\\x",
442            "docs/./../../x",
443        ] {
444            assert!(
445                matches!(VaultPath::new(input), Err(VaultPathError::Escapes(_))),
446                "{input:?} should escape"
447            );
448        }
449    }
450
451    #[test]
452    fn refuses_absolute_unc_and_drive_forms_on_every_platform() {
453        for input in [
454            "/etc/passwd",
455            "\\etc\\passwd",
456            "//server/share/x",
457            "\\\\server\\share\\x",
458            "\\\\?\\C:\\x",
459            "C:",
460            "C:/Windows/system32",
461            "C:\\Windows",
462            "docs/C:/x",
463            "z:",
464        ] {
465            assert!(
466                matches!(VaultPath::new(input), Err(VaultPathError::Absolute(_))),
467                "{input:?} should be absolute, got {:?}",
468                VaultPath::new(input)
469            );
470        }
471    }
472
473    #[test]
474    fn refuses_nul() {
475        assert!(matches!(
476            VaultPath::new("a\0b"),
477            Err(VaultPathError::Nul(_))
478        ));
479    }
480
481    /// Percent-encoded traversal is *not* decoded: `%2e%2e` is a literal file
482    /// name, contained like any other.
483    #[test]
484    fn encoded_traversal_is_a_literal_name_that_stays_inside() {
485        let p = vp("%2e%2e/%2e%2e/etc/passwd");
486        assert_eq!(p.as_str(), "%2e%2e/%2e%2e/etc/passwd");
487        let root = Path::new("/vault");
488        assert!(p.to_native(root).starts_with(root));
489        let p = vp("..%2f..%2fetc");
490        assert_eq!(p.segments().count(), 1);
491    }
492
493    /// The decoded form of an encoded traversal, which is what
494    /// `normalize_link_target` hands the resolver, is refused.
495    #[test]
496    fn decoded_traversal_is_refused() {
497        let decoded = crate::path_resolver::normalize_link_target("/%2e%2e/%2e%2e/etc/passwd");
498        assert!(VaultPath::new(&decoded).is_err(), "{decoded}");
499        let decoded = crate::path_resolver::normalize_link_target("/a/%2e%2e%2f%2e%2e%2fb");
500        assert!(VaultPath::new(&decoded).is_err(), "{decoded}");
501    }
502
503    /// A colon is an ordinary file-name character wherever the host says so.
504    #[test]
505    fn colon_names_follow_the_host() {
506        let parsed = VaultPath::new("Q: what next.md");
507        if cfg!(windows) {
508            assert!(parsed.is_err());
509        } else {
510            assert_eq!(parsed.unwrap().as_str(), "Q: what next.md");
511        }
512        // Not drive-shaped anywhere.
513        assert_eq!(vp("Re: notes.md").as_str(), "Re: notes.md");
514    }
515
516    #[test]
517    fn accessors() {
518        let p = vp("docs/guide/archive.tar.gz");
519        assert_eq!(p.file_name(), Some("archive.tar.gz"));
520        assert_eq!(p.extension(), Some("gz"));
521        assert_eq!(p.parent(), Some(vp("docs/guide")));
522        assert_eq!(vp("top.md").parent(), Some(VaultPath::root()));
523        assert_eq!(VaultPath::root().parent(), None);
524        assert_eq!(VaultPath::root().file_name(), None);
525        assert_eq!(vp(".hidden").extension(), None);
526        let ancestors: Vec<String> = p.ancestors().map(|a| a.to_string()).collect();
527        assert_eq!(
528            ancestors,
529            vec!["docs/guide/archive.tar.gz", "docs/guide", "docs", ""]
530        );
531    }
532
533    #[test]
534    fn child_appends_exactly_one_segment() {
535        assert_eq!(vp("a").child("b.md").unwrap(), vp("a/b.md"));
536        assert_eq!(VaultPath::root().child("b.md").unwrap(), vp("b.md"));
537        for bad in ["", ".", "..", "x/y", "x\\y", "C:", "nul\0"] {
538            assert!(vp("a").child(bad).is_err(), "{bad:?}");
539        }
540        assert_eq!(vp("a").join_path(&vp("b/c")), vp("a/b/c"));
541        assert_eq!(VaultPath::root().join_path(&vp("b")), vp("b"));
542        assert_eq!(vp("a").join_path(&VaultPath::root()), vp("a"));
543    }
544
545    #[test]
546    fn starts_with_is_segment_wise() {
547        assert!(vp("a/b/c").starts_with(&vp("a/b")));
548        assert!(vp("a/b").starts_with(&vp("a/b")));
549        assert!(vp("a/b").starts_with(&VaultPath::root()));
550        assert!(!vp("a/bc").starts_with(&vp("a/b")));
551        assert!(!vp("a").starts_with(&vp("a/b")));
552    }
553
554    #[test]
555    fn join_normalizes_but_never_escapes() {
556        assert_eq!(vp("a/b").join("c.md").unwrap(), vp("a/b/c.md"));
557        assert_eq!(vp("a/b").join("../c.md").unwrap(), vp("a/c.md"));
558        assert_eq!(VaultPath::root().join("x").unwrap(), vp("x"));
559        assert!(vp("a").join("../../x").is_err());
560        assert!(vp("a").join("/etc").is_err());
561    }
562
563    #[test]
564    fn native_round_trip() {
565        let root = std::env::temp_dir().join("vault-root");
566        let p = vp("docs/sub dir/note.md");
567        let native = p.to_native(&root);
568        assert!(native.starts_with(&root));
569        assert_eq!(VaultPath::from_absolute(&root, &native).unwrap(), p);
570        let relative = native.strip_prefix(&root).unwrap();
571        assert_eq!(VaultPath::from_relative_native(relative).unwrap(), p);
572        assert_eq!(VaultPath::root().to_native(&root), root);
573    }
574
575    #[test]
576    fn from_absolute_refuses_paths_outside_the_root() {
577        let root = std::env::temp_dir().join("vault-root");
578        let sibling = std::env::temp_dir().join("vault-root-other").join("x.md");
579        assert!(matches!(
580            VaultPath::from_absolute(&root, &sibling),
581            Err(VaultPathError::NotUnderRoot { .. })
582        ));
583        let climbing = root.join("..").join("x.md");
584        assert!(VaultPath::from_absolute(&root, &climbing).is_err());
585    }
586
587    #[test]
588    fn from_relative_native_refuses_rooted_paths() {
589        let absolute = std::env::temp_dir().join("x.md");
590        assert!(matches!(
591            VaultPath::from_relative_native(&absolute),
592            Err(VaultPathError::Absolute(_))
593        ));
594        assert!(VaultPath::from_relative_native(Path::new("../x.md")).is_err());
595        assert_eq!(
596            VaultPath::from_relative_native(Path::new("./a/../b.md")).unwrap(),
597            vp("b.md")
598        );
599    }
600
601    #[test]
602    fn serializes_as_its_string() {
603        assert_eq!(serde_json::to_string(&vp("a/b.md")).unwrap(), "\"a/b.md\"");
604    }
605
606    /// Arbitrary path-ish input over an alphabet weighted towards the
607    /// characters that matter: separators, dots, colons and drive letters.
608    fn pathish() -> impl Strategy<Value = String> {
609        proptest::collection::vec(
610            prop_oneof![
611                Just("/".to_string()),
612                Just("\\".to_string()),
613                Just(".".to_string()),
614                Just("..".to_string()),
615                Just(":".to_string()),
616                Just("C:".to_string()),
617                Just("%2e".to_string()),
618                Just(" ".to_string()),
619                "[a-zA-Z0-9_-]{1,6}",
620                "\\PC{1,3}",
621            ],
622            0..12,
623        )
624        .prop_map(|parts| parts.concat())
625    }
626
627    proptest! {
628        /// Normalization is idempotent: a parsed path re-parses to itself.
629        #[test]
630        fn prop_normalization_is_idempotent(input in pathish()) {
631            if let Ok(p) = VaultPath::new(&input) {
632                prop_assert_eq!(VaultPath::new(p.as_str()).unwrap(), p);
633            }
634        }
635
636        /// Whatever is accepted stays under the root once made native, and has
637        /// no component that could climb, re-root or re-prefix it.
638        #[test]
639        fn prop_never_escapes_the_root(input in pathish()) {
640            let root = std::env::temp_dir().join("vault-prop-root");
641            if let Ok(p) = VaultPath::new(&input) {
642                let native = p.to_native(&root);
643                prop_assert!(native.starts_with(&root), "{:?} -> {:?}", input, native);
644                let relative = native.strip_prefix(&root).unwrap();
645                prop_assert!(relative.components().all(|c| matches!(c, Component::Normal(_))));
646                prop_assert_eq!(relative.components().count(), p.segments().count());
647                // The string form never carries anything a later parse would
648                // read as traversal or as absolute.
649                prop_assert!(!p.as_str().starts_with('/'));
650                prop_assert!(!p.as_str().contains('\\'));
651                prop_assert!(p.segments().all(|s| s != ".." && s != "." && !s.is_empty()));
652            }
653        }
654
655        /// `to_native` equals pushing the segments one at a time, under a root
656        /// with and without a trailing separator.
657        #[test]
658        fn prop_to_native_matches_segment_pushes(input in pathish(), trailing in any::<bool>()) {
659            let base = std::env::temp_dir().join("vault-prop-root");
660            let root = if trailing {
661                PathBuf::from(format!("{}{}", base.display(), std::path::MAIN_SEPARATOR))
662            } else {
663                base
664            };
665            if let Ok(p) = VaultPath::new(&input) {
666                let mut pushed = root.clone();
667                for segment in p.segments() {
668                    pushed.push(segment);
669                }
670                prop_assert_eq!(p.to_native(&root), pushed);
671            }
672        }
673
674        /// Native round trip: under any root, `to_native` then `from_absolute`
675        /// is the identity.
676        #[test]
677        fn prop_round_trips_through_native(input in pathish()) {
678            let root = std::env::temp_dir().join("vault-prop-root");
679            if let Ok(p) = VaultPath::new(&input) {
680                let native = p.to_native(&root);
681                prop_assert_eq!(VaultPath::from_absolute(&root, &native).unwrap(), p);
682            }
683        }
684
685        /// Any input carrying a `..` deeper than everything before it is
686        /// refused, however it is separated.
687        #[test]
688        fn prop_rejects_leading_traversal(
689            tail in "[a-z]{1,5}(/[a-z]{1,5}){0,3}",
690            sep in prop_oneof![Just("/"), Just("\\")],
691            depth in 1usize..4,
692        ) {
693            let input = format!("{}{tail}", format!("..{sep}").repeat(depth));
694            prop_assert!(VaultPath::new(&input).is_err());
695        }
696
697        /// A `..` that only pops segments it was given is fine and lands on
698        /// the lexical answer.
699        #[test]
700        fn prop_balanced_traversal_cancels(
701            head in "[a-z]{1,5}(/[a-z]{1,5}){0,3}",
702            name in "[a-z]{1,5}",
703        ) {
704            let depth = head.split('/').count();
705            let input = format!("{head}/{}{name}", "../".repeat(depth));
706            let parsed = VaultPath::new(&input).unwrap();
707            prop_assert_eq!(parsed.as_str(), name.as_str());
708        }
709
710        /// Leading separators are absolute, never silently rooted inside.
711        #[test]
712        fn prop_rejects_absolute(
713            tail in "[a-z]{0,5}(/[a-z]{1,5}){0,3}",
714            lead in prop_oneof![Just("/"), Just("\\"), Just("//"), Just("\\\\")],
715        ) {
716            let absolute = format!("{lead}{tail}");
717            prop_assert!(matches!(
718                VaultPath::new(&absolute),
719                Err(VaultPathError::Absolute(_))
720            ));
721        }
722
723        /// `starts_with` agrees with `ancestors`.
724        #[test]
725        fn prop_ancestors_are_prefixes(input in "[a-z]{1,4}(/[a-z]{1,4}){0,5}") {
726            let p = VaultPath::new(&input).unwrap();
727            for ancestor in p.ancestors() {
728                prop_assert!(p.starts_with(&ancestor));
729            }
730        }
731    }
732}