Skip to main content

nextcloud_dav/path/
dav_path.rs

1//! A path below one user's file tree.
2
3use std::fmt;
4
5use nextcloud_sdk_core::{ApiPath, UrlError};
6
7use super::validation;
8
9/// Where a resource lives in Nextcloud's WebDAV tree.
10///
11/// The user name is held separately from the path below it because every
12/// endpoint in this crate needs the same `files/{user}` prefix and nothing
13/// else may appear between them. Validation happens once, in
14/// [`DavPath::new`], so a later call cannot send a traversal segment or an
15/// already-prefixed path.
16#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub struct DavPath {
18    user: String,
19    relative: String,
20    api: ApiPath,
21}
22
23impl DavPath {
24    /// The root of `user`'s files.
25    pub fn root(user: impl AsRef<str>) -> Result<Self, UrlError> {
26        Self::new(user, "")
27    }
28
29    /// The resource at `relative` below `user`'s root.
30    ///
31    /// `relative` is expected to be relative: `""` is the root itself,
32    /// `"Photos/img.jpg"` a nested file. Traversal segments, empty segments
33    /// and a leading `/` are rejected with a [`UrlError`].
34    pub fn new(user: impl AsRef<str>, relative: impl AsRef<str>) -> Result<Self, UrlError> {
35        let user = validation::validate_user(user.as_ref())?.to_owned();
36        let relative = validation::validate_relative(relative.as_ref())?;
37        let wire = if relative.is_empty() {
38            format!("files/{user}")
39        } else {
40            format!("files/{user}/{relative}")
41        };
42        Ok(Self {
43            api: ApiPath::dav(wire)?,
44            user,
45            relative,
46        })
47    }
48
49    /// User whose files this path addresses.
50    pub fn user(&self) -> &str {
51        &self.user
52    }
53
54    /// Path below the user's root, without a leading or trailing slash.
55    pub fn relative(&self) -> &str {
56        &self.relative
57    }
58
59    /// Whether this path is the user's root.
60    pub fn is_root(&self) -> bool {
61        self.relative.is_empty()
62    }
63
64    /// Segments of the path below the user root, empty for the root itself.
65    pub fn segments(&self) -> impl Iterator<Item = &str> {
66        self.relative
67            .split('/')
68            .filter(|segment| !segment.is_empty())
69    }
70
71    /// A child of this path, validated like a fresh path.
72    pub fn join(&self, segment: impl AsRef<str>) -> Result<Self, UrlError> {
73        let segment = segment.as_ref();
74        let combined = if self.relative.is_empty() {
75            segment.to_owned()
76        } else {
77            format!("{}/{segment}", self.relative)
78        };
79        Self::new(&self.user, combined)
80    }
81
82    /// Full path including the WebDAV root and the `files` prefix.
83    pub fn as_str(&self) -> &str {
84        self.api.as_str()
85    }
86
87    /// The path as a request target.
88    pub fn to_api_path(&self) -> ApiPath {
89        self.api.clone()
90    }
91}
92
93impl fmt::Display for DavPath {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::DavPath;
102
103    #[test]
104    fn root_covers_the_user_tree() {
105        let path = DavPath::root("alice").unwrap();
106        assert!(path.is_root());
107        assert_eq!(path.as_str(), "/remote.php/dav/files/alice");
108    }
109
110    #[test]
111    fn nested_paths_keep_their_segments() {
112        let path = DavPath::new("alice", "Photos/2024/img.jpg").unwrap();
113        assert_eq!(
114            path.as_str(),
115            "/remote.php/dav/files/alice/Photos/2024/img.jpg"
116        );
117        assert_eq!(path.segments().count(), 3);
118        assert!(!path.is_root());
119    }
120
121    #[test]
122    fn api_path_matches_the_rendered_string() {
123        let path = DavPath::new("alice", "Photos").unwrap();
124        assert_eq!(path.to_api_path().as_str(), path.as_str());
125    }
126
127    #[test]
128    fn rejects_traversal_in_either_part() {
129        assert!(DavPath::new("alice", "../etc/passwd").is_err());
130        assert!(DavPath::new("alice", "Photos/../../etc").is_err());
131        assert!(DavPath::new("..", "Photos").is_err());
132    }
133
134    #[test]
135    fn rejects_an_absolute_relative_path() {
136        assert!(DavPath::new("alice", "/files/alice").is_err());
137    }
138
139    #[test]
140    fn join_appends_one_segment() {
141        let parent = DavPath::new("alice", "Photos").unwrap();
142        assert_eq!(parent.join("img.jpg").unwrap().relative(), "Photos/img.jpg");
143        assert!(parent.join("../secret").is_err());
144        assert_eq!(
145            DavPath::root("alice")
146                .unwrap()
147                .join("a")
148                .unwrap()
149                .relative(),
150            "a"
151        );
152    }
153
154    #[test]
155    fn display_renders_the_wire_path() {
156        let path = DavPath::new("alice", "a.txt").unwrap();
157        assert_eq!(path.to_string(), "/remote.php/dav/files/alice/a.txt");
158    }
159}