Skip to main content

fs_transaction/
path.rs

1//! Lexical path handling — normalization, and the guard that keeps a staged op
2//! inside the root it was applied against.
3//!
4//! Purely lexical: nothing here touches the filesystem, so it holds for a path
5//! naming something that does not exist yet, and it is the same answer on every
6//! backend. Symlinks are consequently *not* resolved — a link inside the root
7//! pointing out of it is not something this can see. A backend that must defend
8//! against that has to refuse symlinks itself.
9
10use std::path::{Component, Path, PathBuf};
11
12/// Lexically normalize a relative path: drop `.` components and fold
13/// `parent/..` pairs. Leading `..` components (escaping the root) are kept —
14/// the caller decides whether that is an error, which is what
15/// [`escapes_root`] is for.
16pub fn normalize(path: impl AsRef<Path>) -> PathBuf {
17    let mut out: Vec<Component> = Vec::new();
18    for component in path.as_ref().components() {
19        match component {
20            Component::CurDir => {}
21            Component::ParentDir => match out.last() {
22                Some(Component::Normal(_)) => {
23                    out.pop();
24                }
25                _ => out.push(component),
26            },
27            other => out.push(other),
28        }
29    }
30    out.iter().collect()
31}
32
33/// Whether `path`, resolved against a root, would land *outside* it.
34///
35/// Two ways a root-relative path can escape the tree it is joined onto: an
36/// **absolute** path (or a Windows drive prefix), which `root.join(path)` jumps
37/// to wholesale, ignoring the root entirely; and one whose [`normalize`]d form
38/// still leads with `..`, a climb above the root that the `parent/..` folding
39/// could not cancel.
40///
41/// [`ChangeSet::apply`](crate::ChangeSet::apply) refuses either before it
42/// writes or journals anything, so a set assembled from untrusted input — a
43/// link target authored by whoever wrote the document, a path out of a config
44/// file — can never name a file outside the tree it was pointed at.
45///
46/// A path that stays within the root (`notes/a.md`, or `../sibling/b.md` where
47/// the leading climb is cancelled by what precedes it) returns `false`.
48pub fn escapes_root(path: impl AsRef<Path>) -> bool {
49    matches!(
50        normalize(path).components().next(),
51        Some(Component::ParentDir | Component::RootDir | Component::Prefix(_))
52    )
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn folds_dot_and_parent_components() {
61        assert_eq!(normalize("a/./b"), PathBuf::from("a/b"));
62        assert_eq!(normalize("a/b/../c"), PathBuf::from("a/c"));
63        assert_eq!(normalize("a/../../b"), PathBuf::from("../b"));
64    }
65
66    #[test]
67    fn escapes_only_when_the_climb_survives_folding() {
68        assert!(!escapes_root("notes/a.md"));
69        assert!(!escapes_root("notes/../a.md"));
70        assert!(escapes_root("../a.md"));
71        assert!(escapes_root("a/../../etc/passwd"));
72        assert!(escapes_root("/etc/passwd"));
73    }
74}