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