1use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6
7pub const MAX_STORAGE_PATH_SEGMENT_LENGTH: usize = 255;
9pub const MAX_STORAGE_PATH_TOTAL_LENGTH: usize = 972;
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct StoragePath(String);
21
22impl StoragePath {
23 pub fn new(path: &str) -> Result<Self, StoragePathError> {
25 validate_canonical(path)?;
26 Ok(Self(path.to_string()))
27 }
28
29 pub fn normalize(path: &str) -> Result<Self, StoragePathError> {
31 if !path.starts_with('/') {
32 return Err(StoragePathError::NotAbsolute);
33 }
34
35 let directory_shaped =
36 path.ends_with('/') || matches!(path.rsplit('/').next(), Some(".") | Some(".."));
37 let mut segments = Vec::new();
38
39 for segment in path.split('/').skip(1) {
40 match segment {
41 "" | "." => {}
42 ".." => {
43 segments.pop().ok_or(StoragePathError::TraversalAboveRoot)?;
44 }
45 _ => {
46 validate_segment(segment)?;
47 segments.push(segment);
48 }
49 }
50 }
51
52 let mut canonical = String::from("/");
53 canonical.push_str(&segments.join("/"));
54 if directory_shaped && canonical != "/" {
55 canonical.push('/');
56 }
57
58 validate_total_length(&canonical)?;
59 validate_trailing_whitespace(&canonical)?;
60 Ok(Self(canonical))
61 }
62
63 pub fn as_str(&self) -> &str {
65 &self.0
66 }
67
68 pub fn is_root(&self) -> bool {
70 self.0 == "/"
71 }
72
73 pub fn is_directory(&self) -> bool {
75 self.0.ends_with('/')
76 }
77
78 pub fn is_file(&self) -> bool {
80 !self.is_directory()
81 }
82
83 pub fn url_encode(&self) -> String {
85 percent_encoding::utf8_percent_encode(self.as_str(), PATH_ENCODE_SET).to_string()
86 }
87}
88
89impl AsRef<str> for StoragePath {
90 fn as_ref(&self) -> &str {
91 self.as_str()
92 }
93}
94
95impl fmt::Display for StoragePath {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.write_str(self.as_str())
98 }
99}
100
101impl FromStr for StoragePath {
102 type Err = StoragePathError;
103
104 fn from_str(path: &str) -> Result<Self, Self::Err> {
105 Self::new(path)
106 }
107}
108
109impl TryFrom<&str> for StoragePath {
110 type Error = StoragePathError;
111
112 fn try_from(path: &str) -> Result<Self, Self::Error> {
113 Self::new(path)
114 }
115}
116
117impl TryFrom<String> for StoragePath {
118 type Error = StoragePathError;
119
120 fn try_from(path: String) -> Result<Self, Self::Error> {
121 Self::new(&path)
122 }
123}
124
125impl Serialize for StoragePath {
126 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
127 where
128 S: serde::Serializer,
129 {
130 serializer.serialize_str(self.as_str())
131 }
132}
133
134impl<'de> Deserialize<'de> for StoragePath {
135 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
136 where
137 D: serde::Deserializer<'de>,
138 {
139 let path = String::deserialize(deserializer)?;
140 Self::new(&path).map_err(serde::de::Error::custom)
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
146pub enum StoragePathError {
147 #[error("path must be absolute")]
149 NotAbsolute,
150 #[error("path contains an empty segment")]
152 EmptySegment,
153 #[error("path contains a `.` segment")]
155 CurrentDirectorySegment,
156 #[error("path contains a `..` segment")]
158 ParentDirectorySegment,
159 #[error("path traverses above root")]
161 TraversalAboveRoot,
162 #[error("path contains a control character")]
164 ControlCharacter,
165 #[error("path must not contain a backslash")]
167 Backslash,
168 #[error("path must not end in whitespace")]
170 TrailingWhitespace,
171 #[error("path segment is {actual} bytes; maximum is {maximum}")]
173 SegmentTooLong {
174 actual: usize,
176 maximum: usize,
178 },
179 #[error("path is {actual} bytes; maximum is {maximum}")]
181 PathTooLong {
182 actual: usize,
184 maximum: usize,
186 },
187}
188
189const PATH_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC
190 .remove(b'-')
191 .remove(b'_')
192 .remove(b'.')
193 .remove(b'~')
194 .remove(b'/');
195
196fn validate_canonical(path: &str) -> Result<(), StoragePathError> {
197 if !path.starts_with('/') {
198 return Err(StoragePathError::NotAbsolute);
199 }
200 validate_total_length(path)?;
201 if path == "/" {
202 return Ok(());
203 }
204
205 let without_root = &path[1..];
206 let segments = without_root.split('/').collect::<Vec<_>>();
207 let last_index = segments.len() - 1;
208
209 for (index, segment) in segments.into_iter().enumerate() {
210 if segment.is_empty() {
211 if index == last_index {
212 continue;
213 }
214 return Err(StoragePathError::EmptySegment);
215 }
216 match segment {
217 "." => return Err(StoragePathError::CurrentDirectorySegment),
218 ".." => return Err(StoragePathError::ParentDirectorySegment),
219 _ => validate_segment(segment)?,
220 }
221 }
222
223 validate_trailing_whitespace(path)?;
224 Ok(())
225}
226
227fn validate_segment(segment: &str) -> Result<(), StoragePathError> {
228 if segment.len() > MAX_STORAGE_PATH_SEGMENT_LENGTH {
229 return Err(StoragePathError::SegmentTooLong {
230 actual: segment.len(),
231 maximum: MAX_STORAGE_PATH_SEGMENT_LENGTH,
232 });
233 }
234 if segment.chars().any(char::is_control) {
235 return Err(StoragePathError::ControlCharacter);
236 }
237 if segment.contains('\\') {
238 return Err(StoragePathError::Backslash);
239 }
240 Ok(())
241}
242
243fn validate_total_length(path: &str) -> Result<(), StoragePathError> {
244 if path.len() > MAX_STORAGE_PATH_TOTAL_LENGTH {
245 return Err(StoragePathError::PathTooLong {
246 actual: path.len(),
247 maximum: MAX_STORAGE_PATH_TOTAL_LENGTH,
248 });
249 }
250 Ok(())
251}
252
253fn validate_trailing_whitespace(path: &str) -> Result<(), StoragePathError> {
254 if path.chars().last().is_some_and(char::is_whitespace) {
255 return Err(StoragePathError::TrailingWhitespace);
256 }
257 Ok(())
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn strict_parser_accepts_canonical_decoded_paths() {
266 for path in [
267 "/",
268 "/pub/file.txt",
269 "/priv/app/",
270 "/pub/My File/über",
271 "/pub/My%20File/%C3%BCber",
272 "/pub/a:b,c",
273 "/a..",
274 "/.hidden",
275 "/...",
276 "/%2E/%2E%2E/%2F",
277 ] {
278 assert_eq!(StoragePath::new(path).unwrap().as_str(), path);
279 }
280 }
281
282 #[test]
283 fn strict_parser_reports_exact_noncanonical_error() {
284 for (path, expected) in [
285 ("", StoragePathError::NotAbsolute),
286 ("relative", StoragePathError::NotAbsolute),
287 ("//", StoragePathError::EmptySegment),
288 ("/a//b", StoragePathError::EmptySegment),
289 ("/a//", StoragePathError::EmptySegment),
290 ("///", StoragePathError::EmptySegment),
291 ("/.", StoragePathError::CurrentDirectorySegment),
292 ("/a/./", StoragePathError::CurrentDirectorySegment),
293 ("/..", StoragePathError::ParentDirectorySegment),
294 ("/a/../b", StoragePathError::ParentDirectorySegment),
295 ] {
296 assert_eq!(StoragePath::new(path), Err(expected), "{path}");
297 }
298 }
299
300 #[test]
301 fn normalizer_handles_webdav_aliases() {
302 for (input, expected) in [
303 ("/", "/"),
304 ("//", "/"),
305 ("/a//b", "/a/b"),
306 ("/a///b/", "/a/b/"),
307 ("/.", "/"),
308 ("/a/.", "/a/"),
309 ("/a/b/..", "/a/"),
310 ("/a/b/../c", "/a/c"),
311 ("/a/..", "/"),
312 ("/a/../", "/"),
313 ("/a/b/../..", "/"),
314 ("/a/..//b", "/b"),
315 ("/a/name..", "/a/name.."),
316 ("/.hidden/...", "/.hidden/..."),
317 ("/a/%2E%2E/b", "/a/%2E%2E/b"),
318 ("///", "/"),
319 ] {
320 let normalized = StoragePath::normalize(input).unwrap();
321 assert_eq!(normalized.as_str(), expected, "{input}");
322 assert_eq!(
323 StoragePath::new(normalized.as_str()),
324 Ok(normalized.clone())
325 );
326 assert_eq!(StoragePath::normalize(normalized.as_str()), Ok(normalized));
327 }
328 }
329
330 #[test]
331 fn normalizer_reports_input_and_traversal_errors() {
332 for path in ["", "relative"] {
333 assert_eq!(
334 StoragePath::normalize(path),
335 Err(StoragePathError::NotAbsolute)
336 );
337 }
338
339 for path in ["/..", "/./..", "/a/../..", "/a/../../b"] {
340 assert_eq!(
341 StoragePath::normalize(path),
342 Err(StoragePathError::TraversalAboveRoot)
343 );
344 }
345 }
346
347 #[test]
348 fn rejects_ascii_and_unicode_controls() {
349 for path in ["/a\nb", "/a\0b", "/a\u{85}b"] {
350 assert_eq!(
351 StoragePath::new(path),
352 Err(StoragePathError::ControlCharacter)
353 );
354 assert_eq!(
355 StoragePath::normalize(path),
356 Err(StoragePathError::ControlCharacter)
357 );
358 }
359
360 assert_eq!(
361 StoragePath::normalize("/a\nb/.."),
362 Err(StoragePathError::ControlCharacter)
363 );
364 }
365
366 #[test]
367 fn rejects_backslashes() {
368 for path in ["/a\\b", "/pub/app/\\..\\secret", "/a\\b/.."] {
369 assert_eq!(
370 StoragePath::new(path),
371 Err(StoragePathError::Backslash),
372 "{path:?}"
373 );
374 assert_eq!(
375 StoragePath::normalize(path),
376 Err(StoragePathError::Backslash),
377 "{path:?}"
378 );
379 }
380 }
381
382 #[test]
383 fn rejects_trailing_unicode_whitespace() {
384 for path in ["/a ", "/a\u{a0}", "/a\u{3000}"] {
385 assert_eq!(
386 StoragePath::new(path),
387 Err(StoragePathError::TrailingWhitespace),
388 "{path:?}"
389 );
390 assert_eq!(
391 StoragePath::normalize(path),
392 Err(StoragePathError::TrailingWhitespace),
393 "{path:?}"
394 );
395 }
396
397 for path in ["/My File", "/ leading", "/directory /file"] {
398 assert!(StoragePath::new(path).is_ok(), "{path:?}");
399 assert!(StoragePath::normalize(path).is_ok(), "{path:?}");
400 }
401 }
402
403 #[test]
404 fn enforces_decoded_segment_byte_limit() {
405 let ascii_maximum = format!("/{}", "a".repeat(MAX_STORAGE_PATH_SEGMENT_LENGTH));
406 assert!(StoragePath::new(&ascii_maximum).is_ok());
407 assert!(StoragePath::normalize(&ascii_maximum).is_ok());
408
409 let ascii_oversized = format!("{ascii_maximum}a");
410 let expected = Err(StoragePathError::SegmentTooLong {
411 actual: MAX_STORAGE_PATH_SEGMENT_LENGTH + 1,
412 maximum: MAX_STORAGE_PATH_SEGMENT_LENGTH,
413 });
414 assert_eq!(StoragePath::new(&ascii_oversized), expected);
415 assert_eq!(StoragePath::normalize(&ascii_oversized), expected);
416 assert_eq!(
417 StoragePath::normalize(&format!("{ascii_oversized}/..")),
418 expected
419 );
420
421 let multibyte_limit = format!("/{}a", "é".repeat(127));
422 assert_eq!(multibyte_limit.len(), 256);
423 assert!(StoragePath::new(&multibyte_limit).is_ok());
424 assert!(StoragePath::normalize(&multibyte_limit).is_ok());
425
426 let multibyte_oversized = format!("/{}", "é".repeat(128));
427 assert_eq!(StoragePath::new(&multibyte_oversized), expected);
428 assert_eq!(StoragePath::normalize(&multibyte_oversized), expected);
429 }
430
431 #[test]
432 fn enforces_total_decoded_byte_limit() {
433 let maximum = "/a".repeat(MAX_STORAGE_PATH_TOTAL_LENGTH / 2);
434 assert_eq!(maximum.len(), MAX_STORAGE_PATH_TOTAL_LENGTH);
435 assert!(StoragePath::new(&maximum).is_ok());
436 assert!(StoragePath::normalize(&maximum).is_ok());
437
438 let oversized = format!("{maximum}b");
439 let expected = Err(StoragePathError::PathTooLong {
440 actual: MAX_STORAGE_PATH_TOTAL_LENGTH + 1,
441 maximum: MAX_STORAGE_PATH_TOTAL_LENGTH,
442 });
443 assert_eq!(StoragePath::new(&oversized), expected);
444 assert_eq!(StoragePath::normalize(&oversized), expected);
445 }
446
447 #[test]
448 fn strict_parser_reports_errors_in_validation_order() {
449 let oversized_with_empty_segment =
450 format!("//{}", "a".repeat(MAX_STORAGE_PATH_TOTAL_LENGTH));
451 assert_eq!(
452 oversized_with_empty_segment.len(),
453 MAX_STORAGE_PATH_TOTAL_LENGTH + 2
454 );
455 assert_eq!(
456 StoragePath::new(&oversized_with_empty_segment),
457 Err(StoragePathError::PathTooLong {
458 actual: MAX_STORAGE_PATH_TOTAL_LENGTH + 2,
459 maximum: MAX_STORAGE_PATH_TOTAL_LENGTH,
460 })
461 );
462
463 let oversized_segment_with_control = format!("/{}\n", "a".repeat(255));
464 assert_eq!(
465 StoragePath::new(&oversized_segment_with_control),
466 Err(StoragePathError::SegmentTooLong {
467 actual: 256,
468 maximum: MAX_STORAGE_PATH_SEGMENT_LENGTH,
469 })
470 );
471 }
472
473 #[test]
474 fn classifies_root_file_and_directory_paths() {
475 for (value, is_root, is_directory, is_file) in [
476 ("/", true, true, false),
477 ("/a", false, false, true),
478 ("/a/", false, true, false),
479 ] {
480 let path = StoragePath::new(value).unwrap();
481 assert_eq!(path.is_root(), is_root, "{value}");
482 assert_eq!(path.is_directory(), is_directory, "{value}");
483 assert_eq!(path.is_file(), is_file, "{value}");
484 assert_eq!(path.as_ref(), value);
485 assert_eq!(path.to_string(), value);
486 }
487 }
488
489 #[test]
490 fn url_encoding_preserves_literal_percent_semantics() {
491 let decoded = StoragePath::new("/pub/My%20 File/über").unwrap();
492 assert_eq!(decoded.url_encode(), "/pub/My%2520%20File/%C3%BCber");
493 }
494
495 #[test]
496 fn url_encoding_preserves_only_path_separators_and_unreserved_characters() {
497 let decoded = StoragePath::new("/AZaz09-._~/:,?#@/%").unwrap();
498 assert_eq!(decoded.url_encode(), "/AZaz09-._~/%3A%2C%3F%23%40/%25");
499 assert_eq!(StoragePath::new("/").unwrap().url_encode(), "/");
500 assert_eq!(StoragePath::new("/a/").unwrap().url_encode(), "/a/");
501 }
502
503 #[test]
504 fn conversion_traits_are_strict() {
505 let expected = StoragePath::new("/a/").unwrap();
506 assert_eq!("/a/".parse::<StoragePath>(), Ok(expected.clone()));
507 assert_eq!(StoragePath::try_from("/a/"), Ok(expected.clone()));
508 assert_eq!(StoragePath::try_from(String::from("/a/")), Ok(expected));
509
510 for result in [
511 "/a//b".parse::<StoragePath>(),
512 StoragePath::try_from("/a//b"),
513 StoragePath::try_from(String::from("/a//b")),
514 ] {
515 assert_eq!(result, Err(StoragePathError::EmptySegment));
516 }
517 }
518
519 #[test]
520 fn serde_is_exact_strict_and_round_trips() {
521 let path = StoragePath::new("/pub/über%20").unwrap();
522 let json = serde_json::to_string(&path).unwrap();
523 assert_eq!(json, r#""/pub/über%20""#);
524 assert_eq!(serde_json::from_str::<StoragePath>(&json).unwrap(), path);
525
526 let escaped = StoragePath::new("/a\"b").unwrap();
527 let json = serde_json::to_string(&escaped).unwrap();
528 assert_eq!(json, r#""/a\"b""#);
529 assert_eq!(serde_json::from_str::<StoragePath>(&json).unwrap(), escaped);
530
531 for invalid in [
532 r#""/pub//file""#,
533 r#""/pub/./file""#,
534 r#""/pub/a\\b""#,
535 r#""relative""#,
536 ] {
537 assert!(serde_json::from_str::<StoragePath>(invalid).is_err());
538 }
539 for non_string in ["null", "123", "[]", "{}"] {
540 assert!(serde_json::from_str::<StoragePath>(non_string).is_err());
541 }
542 }
543}