nextcloud_dav/path/
dav_path.rs1use std::fmt;
4
5use nextcloud_sdk_core::{ApiPath, UrlError};
6
7use super::validation;
8
9#[derive(Clone, Debug, PartialEq, Eq, Hash)]
17pub struct DavPath {
18 user: String,
19 relative: String,
20 api: ApiPath,
21}
22
23impl DavPath {
24 pub fn root(user: impl AsRef<str>) -> Result<Self, UrlError> {
26 Self::new(user, "")
27 }
28
29 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 pub fn user(&self) -> &str {
51 &self.user
52 }
53
54 pub fn relative(&self) -> &str {
56 &self.relative
57 }
58
59 pub fn is_root(&self) -> bool {
61 self.relative.is_empty()
62 }
63
64 pub fn segments(&self) -> impl Iterator<Item = &str> {
66 self.relative
67 .split('/')
68 .filter(|segment| !segment.is_empty())
69 }
70
71 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 pub fn as_str(&self) -> &str {
84 self.api.as_str()
85 }
86
87 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}