Skip to main content

fuchsia_url/
parse.rs

1// Copyright 2018 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
5use crate::errors::{PackagePathSegmentError, ResourcePathError};
6use serde::{Deserialize, Serialize};
7use std::convert::TryInto as _;
8
9pub const MAX_PACKAGE_PATH_SEGMENT_BYTES: usize = 255;
10pub const MAX_RESOURCE_PATH_SEGMENT_BYTES: usize = 255;
11
12/// Check if a string conforms to r"^[0-9a-z\-\._]{1,255}$" and is neither "." nor ".."
13pub fn validate_package_path_segment(string: &str) -> Result<(), PackagePathSegmentError> {
14    if string.is_empty() {
15        return Err(PackagePathSegmentError::Empty);
16    }
17    if string.len() > MAX_PACKAGE_PATH_SEGMENT_BYTES {
18        return Err(PackagePathSegmentError::TooLong(string.len()));
19    }
20    if let Some(invalid_byte) = string.bytes().find(|&b| {
21        !(b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'.' || b == b'_')
22    }) {
23        return Err(PackagePathSegmentError::InvalidCharacter { character: invalid_byte.into() });
24    }
25    if string == "." {
26        return Err(PackagePathSegmentError::DotSegment);
27    }
28    if string == ".." {
29        return Err(PackagePathSegmentError::DotDotSegment);
30    }
31
32    Ok(())
33}
34
35/// A Fuchsia Package Name. Package names are the first segment of the path.
36/// https://fuchsia.dev/fuchsia-src/concepts/packages/package_url#package-name
37#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Hash, Serialize)]
38pub struct PackageName(String);
39
40impl std::str::FromStr for PackageName {
41    type Err = PackagePathSegmentError;
42    fn from_str(s: &str) -> Result<Self, Self::Err> {
43        let () = validate_package_path_segment(s)?;
44        Ok(Self(s.into()))
45    }
46}
47
48impl TryFrom<String> for PackageName {
49    type Error = PackagePathSegmentError;
50    fn try_from(value: String) -> Result<Self, Self::Error> {
51        let () = validate_package_path_segment(&value)?;
52        Ok(Self(value))
53    }
54}
55
56impl From<PackageName> for String {
57    fn from(name: PackageName) -> Self {
58        name.0
59    }
60}
61
62impl From<&PackageName> for String {
63    fn from(name: &PackageName) -> Self {
64        name.0.clone()
65    }
66}
67
68impl std::fmt::Display for PackageName {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        write!(f, "{}", self.0)
71    }
72}
73
74impl AsRef<str> for PackageName {
75    fn as_ref(&self) -> &str {
76        &self.0
77    }
78}
79
80impl<'de> Deserialize<'de> for PackageName {
81    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82    where
83        D: serde::Deserializer<'de>,
84    {
85        let value = String::deserialize(deserializer)?;
86        value
87            .try_into()
88            .map_err(|e| serde::de::Error::custom(format!("invalid package name: {}", e)))
89    }
90}
91
92/// A Fuchsia Package Variant. Package variants are the optional second segment of the path.
93#[derive(PartialEq, Eq, PartialOrd, Ord, Debug, Clone, Hash, Serialize)]
94pub struct PackageVariant(String);
95
96impl PackageVariant {
97    /// The string representation, "0", of the zero package variant.
98    pub const ZERO_STR: &str = "0";
99
100    /// Create a `PackageVariant` of "0".
101    pub fn zero() -> Self {
102        Self::ZERO_STR.parse().expect("\"0\" is a valid variant")
103    }
104
105    /// Returns true iff the variant is "0".
106    pub fn is_zero(&self) -> bool {
107        self.0 == Self::ZERO_STR
108    }
109}
110
111impl std::str::FromStr for PackageVariant {
112    type Err = PackagePathSegmentError;
113    fn from_str(s: &str) -> Result<Self, Self::Err> {
114        let () = validate_package_path_segment(s)?;
115        Ok(Self(s.into()))
116    }
117}
118
119impl TryFrom<String> for PackageVariant {
120    type Error = PackagePathSegmentError;
121    fn try_from(value: String) -> Result<Self, Self::Error> {
122        let () = validate_package_path_segment(&value)?;
123        Ok(Self(value))
124    }
125}
126
127impl std::fmt::Display for PackageVariant {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        write!(f, "{}", self.0)
130    }
131}
132
133impl AsRef<str> for PackageVariant {
134    fn as_ref(&self) -> &str {
135        &self.0
136    }
137}
138
139impl<'de> Deserialize<'de> for PackageVariant {
140    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
141    where
142        D: serde::Deserializer<'de>,
143    {
144        let value = String::deserialize(deserializer)?;
145        value
146            .try_into()
147            .map_err(|e| serde::de::Error::custom(format!("invalid package variant {}", e)))
148    }
149}
150
151/// Checks if `input` is a valid resource path for a Fuchsia Package URL.
152/// Fuchsia package resource paths are Fuchsia object relative paths without
153/// the limit on maximum path length.
154/// https://fuchsia.dev/fuchsia-src/concepts/packages/package_url#resource-path
155pub fn validate_resource_path(input: &str) -> Result<(), ResourcePathError> {
156    if input.is_empty() {
157        return Err(ResourcePathError::PathIsEmpty);
158    }
159    if input.starts_with('/') {
160        return Err(ResourcePathError::PathStartsWithSlash);
161    }
162    if input.ends_with('/') {
163        return Err(ResourcePathError::PathEndsWithSlash);
164    }
165    for segment in input.split('/') {
166        if segment.contains('\0') {
167            return Err(ResourcePathError::NameContainsNull);
168        }
169        if segment == "." {
170            return Err(ResourcePathError::NameIsDot);
171        }
172        if segment == ".." {
173            return Err(ResourcePathError::NameIsDotDot);
174        }
175        if segment.is_empty() {
176            return Err(ResourcePathError::NameEmpty);
177        }
178        if segment.len() > MAX_RESOURCE_PATH_SEGMENT_BYTES {
179            return Err(ResourcePathError::NameTooLong);
180        }
181        // TODO(https://fxbug.dev/42096516) allow newline once meta/contents supports it in blob paths
182        if segment.contains('\n') {
183            return Err(ResourcePathError::NameContainsNewline);
184        }
185    }
186    Ok(())
187}
188
189#[cfg(test)]
190mod test_validate_package_path_segment {
191    use super::*;
192    use crate::test::random_package_segment;
193    use proptest::prelude::*;
194
195    #[test]
196    fn reject_empty_segment() {
197        assert_eq!(validate_package_path_segment(""), Err(PackagePathSegmentError::Empty));
198    }
199
200    #[test]
201    fn reject_dot_segment() {
202        assert_eq!(validate_package_path_segment("."), Err(PackagePathSegmentError::DotSegment));
203    }
204
205    #[test]
206    fn reject_dot_dot_segment() {
207        assert_eq!(
208            validate_package_path_segment(".."),
209            Err(PackagePathSegmentError::DotDotSegment)
210        );
211    }
212
213    proptest! {
214        #![proptest_config(ProptestConfig{
215            failure_persistence: None,
216            ..Default::default()
217        })]
218
219        #[test]
220        fn reject_segment_too_long(ref s in r"[-_0-9a-z\.]{256, 300}")
221        {
222            prop_assert_eq!(
223                validate_package_path_segment(s),
224                Err(PackagePathSegmentError::TooLong(s.len()))
225            );
226        }
227
228        #[test]
229        fn reject_invalid_character(ref s in r"[-_0-9a-z\.]{0, 48}[^-_0-9a-z\.][-_0-9a-z\.]{0, 48}")
230        {
231            let pass = matches!(
232                validate_package_path_segment(s),
233                Err(PackagePathSegmentError::InvalidCharacter{..})
234            );
235            prop_assert!(pass);
236        }
237
238        #[test]
239        fn valid_segment(ref s in random_package_segment())
240        {
241            prop_assert_eq!(
242                validate_package_path_segment(s),
243                Ok(())
244            );
245        }
246    }
247}
248
249#[cfg(test)]
250mod test_package_name {
251    use super::*;
252
253    #[test]
254    fn from_str_rejects_invalid() {
255        assert_eq!(
256            "?".parse::<PackageName>(),
257            Err(PackagePathSegmentError::InvalidCharacter { character: '?'.into() })
258        );
259    }
260
261    #[test]
262    fn from_str_succeeds() {
263        "package-name".parse::<PackageName>().unwrap();
264    }
265
266    #[test]
267    fn try_from_rejects_invalid() {
268        assert_eq!(
269            PackageName::try_from("?".to_string()),
270            Err(PackagePathSegmentError::InvalidCharacter { character: '?'.into() })
271        );
272    }
273
274    #[test]
275    fn try_from_succeeds() {
276        PackageName::try_from("valid-name".to_string()).unwrap();
277    }
278
279    #[test]
280    fn from_succeeds() {
281        assert_eq!(
282            String::from("package-name".parse::<PackageName>().unwrap()),
283            "package-name".to_string()
284        );
285    }
286
287    #[test]
288    fn display() {
289        let path: PackageName = "package-name".parse().unwrap();
290        assert_eq!(format!("{}", path), "package-name");
291    }
292
293    #[test]
294    fn as_ref() {
295        let path: PackageName = "package-name".parse().unwrap();
296        assert_eq!(path.as_ref(), "package-name");
297    }
298
299    #[test]
300    fn deserialize_success() {
301        let actual_value =
302            serde_json::from_str::<PackageName>("\"package-name\"").expect("json to deserialize");
303        assert_eq!(actual_value, "package-name".parse::<PackageName>().unwrap());
304    }
305
306    #[test]
307    fn deserialize_rejects_invalid() {
308        let msg = serde_json::from_str::<PackageName>("\"pack!age-name\"").unwrap_err().to_string();
309        assert!(msg.contains("invalid package name"), r#"Bad error message: "{}""#, msg);
310    }
311}
312
313#[cfg(test)]
314mod test_package_variant {
315    use super::*;
316
317    #[test]
318    fn zero() {
319        assert_eq!(PackageVariant::zero().as_ref(), "0");
320        assert!(PackageVariant::zero().is_zero());
321        assert_eq!("1".parse::<PackageVariant>().unwrap().is_zero(), false);
322    }
323
324    #[test]
325    fn from_str_rejects_invalid() {
326        assert_eq!(
327            "?".parse::<PackageVariant>(),
328            Err(PackagePathSegmentError::InvalidCharacter { character: '?'.into() })
329        );
330    }
331
332    #[test]
333    fn from_str_succeeds() {
334        "package-variant".parse::<PackageVariant>().unwrap();
335    }
336
337    #[test]
338    fn try_from_rejects_invalid() {
339        assert_eq!(
340            PackageVariant::try_from("?".to_string()),
341            Err(PackagePathSegmentError::InvalidCharacter { character: '?'.into() })
342        );
343    }
344
345    #[test]
346    fn try_from_succeeds() {
347        PackageVariant::try_from("valid-variant".to_string()).unwrap();
348    }
349
350    #[test]
351    fn display() {
352        let path: PackageVariant = "package-variant".parse().unwrap();
353        assert_eq!(format!("{}", path), "package-variant");
354    }
355
356    #[test]
357    fn as_ref() {
358        let path: PackageVariant = "package-variant".parse().unwrap();
359        assert_eq!(path.as_ref(), "package-variant");
360    }
361
362    #[test]
363    fn deserialize_success() {
364        let actual_value = serde_json::from_str::<PackageVariant>("\"package-variant\"")
365            .expect("json to deserialize");
366        assert_eq!(actual_value, "package-variant".parse::<PackageVariant>().unwrap());
367    }
368
369    #[test]
370    fn deserialize_rejects_invalid() {
371        let msg =
372            serde_json::from_str::<PackageVariant>("\"pack!age-variant\"").unwrap_err().to_string();
373        assert!(msg.contains("invalid package variant"), r#"Bad error message: "{}""#, msg);
374    }
375}
376
377#[cfg(test)]
378mod test_validate_resource_path {
379    use super::*;
380    use crate::test::*;
381    use proptest::prelude::*;
382
383    // Tests for invalid paths
384    #[test]
385    fn test_empty_string() {
386        assert_eq!(validate_resource_path(""), Err(ResourcePathError::PathIsEmpty));
387    }
388
389    proptest! {
390        #![proptest_config(ProptestConfig{
391            failure_persistence: None,
392            ..Default::default()
393        })]
394
395        #[test]
396        fn test_reject_empty_object_name(
397            ref s in random_resource_path_with_regex_segment_str(5, "")) {
398            prop_assume!(!s.starts_with('/') && !s.ends_with('/'));
399            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::NameEmpty));
400        }
401
402        #[test]
403        fn test_reject_long_object_name(
404            ref s in random_resource_path_with_regex_segment_str(5, r"[[[:ascii:]]--\.--/--\x00]{256}")) {
405            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::NameTooLong));
406        }
407
408        #[test]
409        fn test_reject_contains_null(
410            ref s in random_resource_path_with_regex_segment_string(
411                5, format!(r"{}{{0,3}}\x00{}{{0,3}}",
412                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
413                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE))) {
414            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::NameContainsNull));
415        }
416
417        #[test]
418        fn test_reject_name_is_dot(
419            ref s in random_resource_path_with_regex_segment_str(5, r"\.")) {
420            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::NameIsDot));
421        }
422
423        #[test]
424        fn test_reject_name_is_dot_dot(
425            ref s in random_resource_path_with_regex_segment_str(5, r"\.\.")) {
426            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::NameIsDotDot));
427        }
428
429        #[test]
430        fn test_reject_starts_with_slash(
431            ref s in format!(
432                "/{}{{1,5}}",
433                ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE).as_str()) {
434            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::PathStartsWithSlash));
435        }
436
437        #[test]
438        fn test_reject_ends_with_slash(
439            ref s in format!(
440                "{}{{1,5}}/",
441                ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE).as_str()) {
442            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::PathEndsWithSlash));
443        }
444
445        #[test]
446        fn test_reject_contains_newline(
447            ref s in random_resource_path_with_regex_segment_string(
448                5, format!(r"{}{{0,3}}\x0a{}{{0,3}}",
449                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
450                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE))) {
451            prop_assert_eq!(validate_resource_path(s), Err(ResourcePathError::NameContainsNewline));
452        }
453    }
454
455    // Tests for valid paths
456    proptest! {
457        #![proptest_config(ProptestConfig{
458            failure_persistence: None,
459            ..Default::default()
460        })]
461
462        #[test]
463        fn test_name_contains_dot(
464            ref s in random_resource_path_with_regex_segment_string(
465                5, format!(r"{}{{1,4}}\.{}{{1,4}}",
466                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
467                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE)))
468        {
469            prop_assert_eq!(validate_resource_path(s), Ok(()));
470        }
471
472        #[test]
473        fn test_name_contains_dot_dot(
474            ref s in random_resource_path_with_regex_segment_string(
475                5, format!(r"{}{{1,4}}\.\.{}{{1,4}}",
476                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE,
477                           ANY_UNICODE_EXCEPT_SLASH_NULL_DOT_OR_NEWLINE)))
478        {
479            prop_assert_eq!(validate_resource_path(s), Ok(()));
480        }
481
482        #[test]
483        fn test_single_segment(ref s in always_valid_resource_path_chars(1, 4)) {
484            prop_assert_eq!(validate_resource_path(s), Ok(()));
485        }
486
487        #[test]
488        fn test_multi_segment(
489            ref s in prop::collection::vec(always_valid_resource_path_chars(1, 4), 1..5))
490        {
491            let path = s.join("/");
492            prop_assert_eq!(validate_resource_path(&path), Ok(()));
493        }
494
495        #[test]
496        fn test_long_name(
497            ref s in random_resource_path_with_regex_segment_str(
498                5, "[[[:ascii:]]--\0--/--\n]{255}")) // TODO(https://fxbug.dev/42096516) allow newline once meta/contents supports it in blob paths
499        {
500            prop_assert_eq!(validate_resource_path(s), Ok(()));
501        }
502    }
503}