notedthat_core/
object_path.rs1use crate::error::Error;
4use serde::{Deserialize, Deserializer, Serialize};
5use std::fmt;
6
7pub fn is_internal_path(path: &str) -> bool {
13 path == ".notedthat" || path.starts_with(".notedthat/")
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
29pub struct ObjectPath(String);
30
31impl ObjectPath {
32 pub fn try_from_str(input: &str) -> Result<Self, Error> {
34 let s = input.strip_prefix('/').unwrap_or(input);
35
36 if s.is_empty() {
37 return Err(Error::InvalidInput {
38 message: "path must not be empty".into(),
39 });
40 }
41 if s.contains('\\') {
42 return Err(Error::InvalidInput {
43 message: "path must not contain backslash".into(),
44 });
45 }
46 if s.contains('\0') {
47 return Err(Error::InvalidInput {
48 message: "path must not contain NUL byte".into(),
49 });
50 }
51 for segment in s.split('/') {
52 if segment.is_empty() {
53 return Err(Error::InvalidInput {
54 message:
55 "path must not contain empty segments (double slashes or trailing slash)"
56 .into(),
57 });
58 }
59 if segment == "." || segment == ".." {
60 return Err(Error::InvalidInput {
61 message: "path must not contain '.' or '..' segments".into(),
62 });
63 }
64 }
65 Ok(Self(s.to_string()))
66 }
67
68 pub fn as_str(&self) -> &str {
70 &self.0
71 }
72}
73
74impl TryFrom<&str> for ObjectPath {
75 type Error = Error;
76 fn try_from(value: &str) -> Result<Self, Self::Error> {
77 Self::try_from_str(value)
78 }
79}
80
81impl TryFrom<String> for ObjectPath {
82 type Error = Error;
83 fn try_from(value: String) -> Result<Self, Self::Error> {
84 Self::try_from_str(&value)
85 }
86}
87
88impl AsRef<str> for ObjectPath {
89 fn as_ref(&self) -> &str {
90 &self.0
91 }
92}
93
94impl fmt::Display for ObjectPath {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 self.0.fmt(f)
97 }
98}
99
100impl<'de> Deserialize<'de> for ObjectPath {
101 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
102 let s = String::deserialize(deserializer)?;
103 Self::try_from_str(&s).map_err(serde::de::Error::custom)
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 #[test]
112 fn internal_path_when_root_namespace_or_descendant() {
113 for path in [
114 ".notedthat",
115 ".notedthat/",
116 ".notedthat/config.json",
117 ".notedthat/nested/file",
118 ] {
119 assert!(is_internal_path(path), "{path}");
120 }
121 }
122
123 #[test]
124 fn public_path_when_outside_root_namespace() {
125 for path in [
126 "",
127 "/",
128 ".notedthat-other",
129 ".notedthat.md",
130 "notes/.notedthat/file",
131 ".NotedThat/config",
132 "notes/readme.md",
133 ] {
134 assert!(!is_internal_path(path), "{path}");
135 }
136 }
137
138 #[test]
139 fn test_try_from_simple_no_leading_slash() {
140 let p = ObjectPath::try_from("foo/bar.md").unwrap();
141 assert_eq!(p.as_ref(), "foo/bar.md");
142 }
143
144 #[test]
145 fn test_try_from_strips_one_leading_slash() {
146 let p = ObjectPath::try_from("/foo/bar.md").unwrap();
147 assert_eq!(p.as_ref(), "foo/bar.md");
148 }
149
150 #[test]
151 fn test_try_from_case_preserved() {
152 let p = ObjectPath::try_from("FooBar/BAZ.md").unwrap();
153 assert_eq!(p.as_ref(), "FooBar/BAZ.md");
154 }
155
156 #[test]
157 fn test_try_from_unicode_preserved() {
158 let p = ObjectPath::try_from("русский.md").unwrap();
159 assert_eq!(p.as_ref(), "русский.md");
160 }
161
162 #[test]
163 fn test_try_from_spaces_valid() {
164 let p = ObjectPath::try_from("hello world.md").unwrap();
165 assert_eq!(p.as_ref(), "hello world.md");
166 }
167
168 #[test]
169 fn test_try_from_err_double_leading_slash() {
170 assert!(ObjectPath::try_from("//foo/bar.md").is_err());
171 }
172
173 #[test]
174 fn test_try_from_err_empty() {
175 assert!(ObjectPath::try_from("").is_err());
176 }
177
178 #[test]
179 fn test_try_from_err_slash_only_empty_after_strip() {
180 assert!(ObjectPath::try_from("/").is_err());
181 }
182
183 #[test]
184 fn test_try_from_err_trailing_slash_empty_segment() {
185 assert!(ObjectPath::try_from("foo/").is_err());
186 }
187
188 #[test]
189 fn test_try_from_err_double_slash_middle() {
190 assert!(ObjectPath::try_from("foo//bar").is_err());
191 }
192
193 #[test]
194 fn test_try_from_err_dot_segment_single() {
195 assert!(ObjectPath::try_from(".").is_err());
196 }
197
198 #[test]
199 fn test_try_from_err_dot_segment_prefix() {
200 assert!(ObjectPath::try_from("./foo").is_err());
201 }
202
203 #[test]
204 fn test_try_from_err_double_dot_segment() {
205 assert!(ObjectPath::try_from("..").is_err());
206 }
207
208 #[test]
209 fn test_try_from_err_double_dot_prefix() {
210 assert!(ObjectPath::try_from("../foo").is_err());
211 }
212
213 #[test]
214 fn test_try_from_err_double_dot_middle() {
215 assert!(ObjectPath::try_from("foo/../bar").is_err());
216 }
217
218 #[test]
219 fn test_try_from_err_backslash() {
220 assert!(ObjectPath::try_from("foo\\bar").is_err());
221 }
222
223 #[test]
224 fn test_try_from_err_nul_byte() {
225 assert!(ObjectPath::try_from("foo\x00bar").is_err());
226 }
227
228 #[test]
229 fn test_as_ref_gives_normalized_no_slash() {
230 let p = ObjectPath::try_from("/some/path.md").unwrap();
231 let s: &str = p.as_ref();
232 assert!(!s.starts_with('/'));
233 assert_eq!(s, "some/path.md");
234 }
235
236 #[test]
237 fn test_display_gives_normalized_form() {
238 let p = ObjectPath::try_from("/foo/bar.md").unwrap();
239 assert_eq!(p.to_string(), "foo/bar.md");
240 }
241
242 #[test]
243 fn test_try_from_owned_string() {
244 let s = String::from("foo/bar.md");
245 let p = ObjectPath::try_from(s).unwrap();
246 assert_eq!(p.as_ref(), "foo/bar.md");
247 }
248}