Skip to main content

pubky_common/
storage_path.rs

1//! Canonical decoded paths used by Pubky storage and WebDAV operations.
2
3use std::{fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6
7/// Maximum decoded UTF-8 byte length of one path segment.
8pub const MAX_STORAGE_PATH_SEGMENT_LENGTH: usize = 255;
9/// Maximum decoded UTF-8 byte length of a complete canonical path.
10///
11/// This leaves room for the 52-byte z32 tenant key in a 1024-byte storage object key.
12/// This contraint is enforced by the storage backend, so we enforce it here to avoid creating invalid paths.
13pub const MAX_STORAGE_PATH_TOTAL_LENGTH: usize = 972;
14
15/// A canonical decoded Pubky storage path that can be encoded as an HTTP/WebDAV URI path.
16///
17/// Percent signs are literal. URL decoding must happen before construction.
18/// Paths cannot end in whitespace because storage backends may trim it.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct StoragePath(String);
21
22impl StoragePath {
23    /// Parse an already-canonical decoded absolute path.
24    pub fn new(path: &str) -> Result<Self, StoragePathError> {
25        validate_canonical(path)?;
26        Ok(Self(path.to_string()))
27    }
28
29    /// Normalize a decoded absolute HTTP/WebDAV path and validate the result.
30    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    /// Borrow the decoded canonical path.
64    pub fn as_str(&self) -> &str {
65        &self.0
66    }
67
68    /// Return whether this path is the storage root.
69    pub fn is_root(&self) -> bool {
70        self.0 == "/"
71    }
72
73    /// Return whether this path is directory-shaped.
74    pub fn is_directory(&self) -> bool {
75        self.0.ends_with('/')
76    }
77
78    /// Return whether this path is file-shaped.
79    pub fn is_file(&self) -> bool {
80        !self.is_directory()
81    }
82
83    /// Encode this decoded path for use as an HTTP URL path.
84    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/// Error validating or normalizing a [`StoragePath`].
145#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
146pub enum StoragePathError {
147    /// The path does not begin with `/`.
148    #[error("path must be absolute")]
149    NotAbsolute,
150    /// The path contains an empty segment such as `//`.
151    #[error("path contains an empty segment")]
152    EmptySegment,
153    /// The path contains a `.` segment.
154    #[error("path contains a `.` segment")]
155    CurrentDirectorySegment,
156    /// The path contains a `..` segment.
157    #[error("path contains a `..` segment")]
158    ParentDirectorySegment,
159    /// Normalization would traverse above root.
160    #[error("path traverses above root")]
161    TraversalAboveRoot,
162    /// A segment contains a Unicode control character.
163    #[error("path contains a control character")]
164    ControlCharacter,
165    /// A segment contains a backslash, which is a path separator on some platforms.
166    #[error("path must not contain a backslash")]
167    Backslash,
168    /// The complete path ends in Unicode whitespace.
169    #[error("path must not end in whitespace")]
170    TrailingWhitespace,
171    /// A decoded segment exceeds the byte limit.
172    #[error("path segment is {actual} bytes; maximum is {maximum}")]
173    SegmentTooLong {
174        /// Actual decoded UTF-8 byte length.
175        actual: usize,
176        /// Maximum decoded UTF-8 byte length.
177        maximum: usize,
178    },
179    /// The complete decoded path exceeds the byte limit.
180    #[error("path is {actual} bytes; maximum is {maximum}")]
181    PathTooLong {
182        /// Actual decoded UTF-8 byte length.
183        actual: usize,
184        /// Maximum decoded UTF-8 byte length.
185        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}