Skip to main content

notedthat_core/
object_path.rs

1//! `ObjectPath` — normalized object storage key with D40 validation rules.
2
3use crate::error::Error;
4use serde::{Deserialize, Deserializer, Serialize};
5use std::fmt;
6
7/// Returns whether a decoded, knowledge-base-relative path is in the internal namespace.
8///
9/// The path must have no leading slash. The exact `.notedthat` segment and its
10/// descendants are internal; similarly named or nested segments are not.
11/// This predicate does not normalize, decode, or validate paths.
12pub fn is_internal_path(path: &str) -> bool {
13    path == ".notedthat" || path.starts_with(".notedthat/")
14}
15
16/// A normalized object path within a knowledge-base bucket.
17///
18/// Rules (D40, §6.12 path normalization):
19/// - One leading `/` is stripped if present.
20/// - Empty paths and paths that are only `/` are rejected.
21/// - Empty segments (from `//` or trailing `/`) are rejected.
22/// - `.` and `..` segments are rejected (no resolution — just rejection).
23/// - Backslash (`\`) and NUL (`\0`) characters are rejected.
24/// - Case and Unicode are preserved verbatim.
25/// - Spaces are valid (S3 permits them).
26///
27/// The stored form has no leading slash and uses `/` as the separator.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
29pub struct ObjectPath(String);
30
31impl ObjectPath {
32    /// Validate and construct an [`ObjectPath`] from a string slice.
33    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    /// Returns the normalized path as a `&str` (no leading slash).
69    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}