Skip to main content

fuchsia_url/
resource.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5pub const MAX_RESOURCE_PATH_SEGMENT_BYTES: usize = 255;
6
7/// Fuchsia package resource paths are Fuchsia object relative paths without the limit on maximum
8/// path length.
9/// https://fuchsia.dev/fuchsia-src/concepts/packages/package_url#resource-path
10#[derive(Debug, PartialEq, Eq, Clone, PartialOrd, Ord, Hash)]
11pub struct Resource(String);
12
13impl Resource {
14    /// Percent encode the path for inclusion in URLs. Escapes ' ', '"', '<', '>', and '`'.
15    /// https://url.spec.whatwg.org/#fragment-percent-encode-set
16    pub fn percent_encode(&self) -> impl std::fmt::Display {
17        percent_encoding::utf8_percent_encode(&self.0, crate::FRAGMENT)
18    }
19
20    /// Checks if `input` is a valid resource path for a Fuchsia Package URL.
21    /// Fuchsia package resource paths are Fuchsia object relative paths without the limit on
22    /// maximum path length.
23    /// https://fuchsia.dev/fuchsia-src/concepts/packages/package_url#resource-path
24    ///
25    /// Percent decoding should be performed before calling this, if necessary.
26    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            // TODO(https://fxbug.dev/42096516) allow newline once meta/contents supports it in blob
53            // paths.
54            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    // TODO(https://fxbug.dev/42096516) allow newline once meta/contents supports it in blob paths
124    #[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    // Tests for invalid paths
135    #[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    // Tests for valid paths
210    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            // TODO(https://fxbug.dev/42096516) allow newline once meta/contents supports it in blob
252            // paths.
253            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}