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