1pub const MAX_RESOURCE_PATH_SEGMENT_BYTES: usize = 255;
6
7#[derive(Debug, PartialEq, Eq, Clone, PartialOrd, Ord, Hash)]
11pub struct Resource(String);
12
13impl Resource {
14 pub fn percent_encode(&self) -> impl std::fmt::Display {
17 percent_encoding::utf8_percent_encode(&self.0, crate::FRAGMENT)
18 }
19
20 pub fn validate_str(input: &str) -> Result<(), ResourcePathError> {
27 if input.is_empty() {
28 return Err(ResourcePathError::PathIsEmpty);
29 }
30 if input.starts_with('/') {
31 return Err(ResourcePathError::PathStartsWithSlash);
32 }
33 if input.ends_with('/') {
34 return Err(ResourcePathError::PathEndsWithSlash);
35 }
36 for segment in input.split('/') {
37 if segment.contains('\0') {
38 return Err(ResourcePathError::NameContainsNull);
39 }
40 if segment == "." {
41 return Err(ResourcePathError::NameIsDot);
42 }
43 if segment == ".." {
44 return Err(ResourcePathError::NameIsDotDot);
45 }
46 if segment.is_empty() {
47 return Err(ResourcePathError::NameEmpty);
48 }
49 if segment.len() > MAX_RESOURCE_PATH_SEGMENT_BYTES {
50 return Err(ResourcePathError::NameTooLong);
51 }
52 if segment.contains('\n') {
55 return Err(ResourcePathError::NameContainsNewline);
56 }
57 }
58 Ok(())
59 }
60}
61
62impl TryFrom<String> for Resource {
63 type Error = ResourcePathError;
64 fn try_from(value: String) -> Result<Self, Self::Error> {
65 let () = Self::validate_str(&value)?;
66 Ok(Self(value))
67 }
68}
69
70impl std::str::FromStr for Resource {
71 type Err = ResourcePathError;
72 fn from_str(s: &str) -> Result<Self, Self::Err> {
73 let () = Self::validate_str(s)?;
74 Ok(Self(s.to_owned()))
75 }
76}
77
78impl std::fmt::Display for Resource {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 self.0.fmt(f)
81 }
82}
83
84impl AsRef<str> for Resource {
85 fn as_ref(&self) -> &str {
86 &self.0
87 }
88}
89
90impl std::ops::Deref for Resource {
91 type Target = str;
92 fn deref(&self) -> &Self::Target {
93 self.0.as_str()
94 }
95}
96
97#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
98pub enum ResourcePathError {
99 #[error("object names must be at least 1 byte")]
100 NameEmpty,
101
102 #[error("object names must be at most {} bytes", MAX_RESOURCE_PATH_SEGMENT_BYTES)]
103 NameTooLong,
104
105 #[error("object names cannot contain the NULL byte")]
106 NameContainsNull,
107
108 #[error("object names cannot be '.'")]
109 NameIsDot,
110
111 #[error("object names cannot be '..'")]
112 NameIsDotDot,
113
114 #[error("object paths cannot start with '/'")]
115 PathStartsWithSlash,
116
117 #[error("object paths cannot end with '/'")]
118 PathEndsWithSlash,
119
120 #[error("object paths must be at least 1 byte")]
121 PathIsEmpty,
122
123 #[error(r"object names cannot contain the newline character '\n'")]
125 NameContainsNewline,
126}
127
128#[cfg(test)]
129mod test {
130 use super::*;
131 use crate::test::*;
132 use proptest::prelude::*;
133
134 #[test]
136 fn test_empty_string() {
137 assert_eq!(Resource::validate_str(""), Err(ResourcePathError::PathIsEmpty));
138 }
139
140 proptest! {
141 #![proptest_config(ProptestConfig{
142 failure_persistence: None,
143 ..Default::default()
144 })]
145
146 #[test]
147 fn test_reject_empty_object_name(
148 ref s in random_resource_path_with_regex_segment_str(5, "")) {
149 prop_assume!(!s.starts_with('/') && !s.ends_with('/'));
150 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::NameEmpty));
151 }
152
153 #[test]
154 fn test_reject_long_object_name(
155 ref s in random_resource_path_with_regex_segment_str(
156 5,
157 r"[[[:ascii:]]--\.--/--\x00]{256}"
158 )) {
159 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::NameTooLong));
160 }
161
162 #[test]
163 fn test_reject_contains_null(
164 ref s in random_resource_path_with_regex_segment_string(
165 5, format!(r"{}{{0,3}}\x00{}{{0,3}}",
166 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
167 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE))) {
168 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::NameContainsNull));
169 }
170
171 #[test]
172 fn test_reject_name_is_dot(
173 ref s in random_resource_path_with_regex_segment_str(5, r"\.")) {
174 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::NameIsDot));
175 }
176
177 #[test]
178 fn test_reject_name_is_dot_dot(
179 ref s in random_resource_path_with_regex_segment_str(5, r"\.\.")) {
180 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::NameIsDotDot));
181 }
182
183 #[test]
184 fn test_reject_starts_with_slash(
185 ref s in format!(
186 "/{}{{1,5}}",
187 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE).as_str()) {
188 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::PathStartsWithSlash));
189 }
190
191 #[test]
192 fn test_reject_ends_with_slash(
193 ref s in format!(
194 "{}{{1,5}}/",
195 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE).as_str()) {
196 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::PathEndsWithSlash));
197 }
198
199 #[test]
200 fn test_reject_contains_newline(
201 ref s in random_resource_path_with_regex_segment_string(
202 5, format!(r"{}{{0,3}}\x0a{}{{0,3}}",
203 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
204 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE))) {
205 prop_assert_eq!(Resource::validate_str(s), Err(ResourcePathError::NameContainsNewline));
206 }
207 }
208
209 proptest! {
211 #![proptest_config(ProptestConfig{
212 failure_persistence: None,
213 ..Default::default()
214 })]
215
216 #[test]
217 fn test_name_contains_dot(
218 ref s in random_resource_path_with_regex_segment_string(
219 5, format!(r"{}{{1,4}}\.{}{{1,4}}",
220 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
221 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE)))
222 {
223 prop_assert_eq!(Resource::validate_str(s), Ok(()));
224 }
225
226 #[test]
227 fn test_name_contains_dot_dot(
228 ref s in random_resource_path_with_regex_segment_string(
229 5, format!(r"{}{{1,4}}\.\.{}{{1,4}}",
230 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
231 ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE)))
232 {
233 prop_assert_eq!(Resource::validate_str(s), Ok(()));
234 }
235
236 #[test]
237 fn test_single_segment(ref s in always_valid_resource_path_chars(1, 4)) {
238 prop_assert_eq!(Resource::validate_str(s), Ok(()));
239 }
240
241 #[test]
242 fn test_multi_segment(
243 ref s in prop::collection::vec(always_valid_resource_path_chars(1, 4), 1..5))
244 {
245 let path = s.join("/");
246 prop_assert_eq!(Resource::validate_str(&path), Ok(()));
247 }
248
249 #[test]
250 fn test_long_name(
251 ref s in random_resource_path_with_regex_segment_str(
254 5, "[[[:ascii:]]--\0--/--\n]{255}"))
255 {
256 prop_assert_eq!(Resource::validate_str(s), Ok(()));
257 }
258 }
259}