1use std::fmt;
5use std::path::{Component, Path, PathBuf};
6
7use thiserror::Error;
8
9#[derive(Debug, Clone, PartialEq, Eq, Error)]
11pub enum VaultPathError {
12 #[error("path escapes the vault root: {0:?}")]
14 Escapes(String),
15 #[error("path is absolute: {0:?}")]
18 Absolute(String),
19 #[error("path contains a NUL byte: {0:?}")]
22 Nul(String),
23 #[error("path segment {segment:?} is not a plain file name: {path:?}")]
26 InvalidSegment { path: String, segment: String },
27 #[error("path is not valid UTF-8: {0:?}")]
31 NotUtf8(PathBuf),
32 #[error("{path:?} is not under the vault root {root:?}")]
35 NotUnderRoot { path: PathBuf, root: PathBuf },
36}
37
38#[derive(Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
76pub struct VaultPath(String);
77
78impl VaultPath {
79 pub fn root() -> Self {
81 Self(String::new())
82 }
83
84 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 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 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 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 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 pub fn as_str(&self) -> &str {
178 &self.0
179 }
180
181 pub fn is_root(&self) -> bool {
183 self.0.is_empty()
184 }
185
186 pub fn segments(&self) -> impl Iterator<Item = &str> {
188 self.0.split('/').filter(|segment| !segment.is_empty())
189 }
190
191 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 pub fn extension(&self) -> Option<&str> {
203 Path::new(self.file_name()?)
204 .extension()
205 .and_then(|ext| ext.to_str())
206 }
207
208 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 pub fn ancestors(&self) -> impl Iterator<Item = Self> {
221 std::iter::successors(Some(self.clone()), Self::parent)
222 }
223
224 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 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 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 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 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
308fn push_segment(normalized: &mut String, segment: &str) {
310 if !normalized.is_empty() {
311 normalized.push('/');
312 }
313 normalized.push_str(segment);
314}
315
316fn 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
327enum 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
346fn check_segment(segment: &str) -> Result<(), SegmentProblem> {
348 if segment.is_empty() || segment == "." || segment == ".." {
349 return Err(SegmentProblem::Invalid);
350 }
351 if is_bare_drive(segment) {
355 return Err(SegmentProblem::BareDrive);
356 }
357 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
374fn 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 #[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 #[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 #[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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}