fuchsia_url/
lib.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
5pub use fuchsia_hash::{Hash, HASH_SIZE};
6
7mod absolute_component_url;
8mod absolute_package_url;
9pub mod boot_url;
10pub mod builtin_url;
11mod component_url;
12pub mod errors;
13mod host;
14mod package_url;
15mod parse;
16mod pinned_absolute_package_url;
17mod relative_component_url;
18mod relative_package_url;
19mod repository_url;
20pub mod test;
21mod unpinned_absolute_package_url;
22
23pub use crate::absolute_component_url::AbsoluteComponentUrl;
24pub use crate::absolute_package_url::AbsolutePackageUrl;
25pub use crate::component_url::ComponentUrl;
26pub use crate::errors::ParseError;
27pub use crate::package_url::PackageUrl;
28pub use crate::parse::{
29    validate_resource_path, PackageName, PackageVariant, MAX_PACKAGE_PATH_SEGMENT_BYTES,
30};
31pub use crate::pinned_absolute_package_url::PinnedAbsolutePackageUrl;
32pub use crate::relative_component_url::RelativeComponentUrl;
33pub use crate::relative_package_url::RelativePackageUrl;
34pub use crate::repository_url::RepositoryUrl;
35pub use crate::unpinned_absolute_package_url::UnpinnedAbsolutePackageUrl;
36
37use crate::host::Host;
38use lazy_static::lazy_static;
39use percent_encoding::{AsciiSet, CONTROLS};
40
41/// https://url.spec.whatwg.org/#fragment-percent-encode-set
42const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
43
44const RELATIVE_SCHEME: &'static str = "relative";
45
46lazy_static! {
47    /// A default base URL from which to parse relative component URL
48    /// components.
49    static ref RELATIVE_BASE: url::Url = url::Url::parse(&format!("{RELATIVE_SCHEME}:///")).unwrap();
50}
51
52#[derive(Clone, Copy, PartialEq, Eq, Debug)]
53enum Scheme {
54    Builtin,
55    FuchsiaPkg,
56    FuchsiaBoot,
57}
58
59#[derive(Debug, PartialEq, Eq)]
60struct UrlParts {
61    scheme: Option<Scheme>,
62    host: Option<Host>,
63    // a forward slash followed by zero or more validated path segments separated by forward slashes
64    path: String,
65    hash: Option<Hash>,
66    // if present, String is a validated resource path
67    resource: Option<String>,
68}
69
70impl UrlParts {
71    fn parse(input: &str) -> Result<Self, ParseError> {
72        match url::Url::parse(input) {
73            Ok(url) => Self::from_url(&url),
74            Err(url::ParseError::RelativeUrlWithoutBase) => {
75                Self::from_scheme_and_url(None, &RELATIVE_BASE.join(input)?)
76            }
77            Err(e) => Err(e)?,
78        }
79    }
80    fn from_url(url: &url::Url) -> Result<Self, ParseError> {
81        Self::from_scheme_and_url(
82            Some(match url.scheme() {
83                builtin_url::SCHEME => Scheme::Builtin,
84                repository_url::SCHEME => Scheme::FuchsiaPkg,
85                boot_url::SCHEME => Scheme::FuchsiaBoot,
86                _ => return Err(ParseError::InvalidScheme),
87            }),
88            url,
89        )
90    }
91    fn from_scheme_and_url(scheme: Option<Scheme>, url: &url::Url) -> Result<Self, ParseError> {
92        if url.port().is_some() {
93            return Err(ParseError::CannotContainPort);
94        }
95
96        if !url.username().is_empty() {
97            return Err(ParseError::CannotContainUsername);
98        }
99
100        if url.password().is_some() {
101            return Err(ParseError::CannotContainPassword);
102        }
103
104        let host = url
105            .host_str()
106            .filter(|s| !s.is_empty())
107            .map(|s| Host::parse(s.to_string()))
108            .transpose()?;
109
110        let path = String::from(if url.path().is_empty() { "/" } else { url.path() });
111        let () = validate_path(&path)?;
112
113        let hash = parse_query_pairs(url.query_pairs())?;
114
115        let resource = if let Some(resource) = url.fragment() {
116            let resource = percent_encoding::percent_decode(resource.as_bytes())
117                .decode_utf8()
118                .map_err(ParseError::ResourcePathPercentDecode)?;
119
120            if resource.is_empty() {
121                None
122            } else {
123                let () =
124                    validate_resource_path(&resource).map_err(ParseError::InvalidResourcePath)?;
125                Some(resource.to_string())
126            }
127        } else {
128            None
129        };
130
131        Ok(Self { scheme, host, path, hash, resource })
132    }
133}
134
135/// After all other checks, ensure the input string does not change when joined
136/// with the `RELATIVE_BASE` URL, and then removing the base (inverse-join()).
137fn validate_inverse_relative_url(input: &str) -> Result<(), ParseError> {
138    let relative_url = RELATIVE_BASE.join(input)?;
139    let unbased = RELATIVE_BASE.make_relative(&relative_url);
140    if Some(input) == unbased.as_deref() {
141        Ok(())
142    } else {
143        Err(ParseError::InvalidRelativePath(input.to_string(), unbased))?
144    }
145}
146
147fn parse_query_pairs(pairs: url::form_urlencoded::Parse<'_>) -> Result<Option<Hash>, ParseError> {
148    let mut query_hash = None;
149    for (key, value) in pairs {
150        if key == "hash" {
151            if query_hash.is_some() {
152                return Err(ParseError::MultipleHashes);
153            }
154            query_hash = Some(value.parse().map_err(ParseError::InvalidHash)?);
155            // fuchsia-pkg URLs require lowercase hex characters, but fuchsia_hash::Hash::parse
156            // accepts uppercase A-F.
157            if !value.bytes().all(|b| (b >= b'0' && b <= b'9') || (b >= b'a' && b <= b'f')) {
158                return Err(ParseError::UpperCaseHash);
159            }
160        } else {
161            return Err(ParseError::ExtraQueryParameters);
162        }
163    }
164    Ok(query_hash)
165}
166
167// Validates path is a forward slash followed by zero or more valid path segments separated by slash
168fn validate_path(path: &str) -> Result<(), ParseError> {
169    if let Some(suffix) = path.strip_prefix('/') {
170        if !suffix.is_empty() {
171            for s in suffix.split('/') {
172                let () = crate::parse::validate_package_path_segment(s)
173                    .map_err(ParseError::InvalidPathSegment)?;
174            }
175        }
176        Ok(())
177    } else {
178        Err(ParseError::PathMustHaveLeadingSlash)
179    }
180}
181
182// Validates that `path` is "/name[/variant]" and returns the name and optional variant if so.
183fn parse_path_to_name_and_variant(
184    path: &str,
185) -> Result<(PackageName, Option<PackageVariant>), ParseError> {
186    let path = path.strip_prefix('/').ok_or(ParseError::PathMustHaveLeadingSlash)?;
187    if path.is_empty() {
188        return Err(ParseError::MissingName);
189    }
190    let mut iter = path.split('/').fuse();
191    let name = if let Some(s) = iter.next() {
192        s.parse().map_err(ParseError::InvalidName)?
193    } else {
194        return Err(ParseError::MissingName);
195    };
196    let variant = if let Some(s) = iter.next() {
197        Some(s.parse().map_err(ParseError::InvalidVariant)?)
198    } else {
199        None
200    };
201    if let Some(_) = iter.next() {
202        return Err(ParseError::ExtraPathSegments);
203    }
204    Ok((name, variant))
205}
206
207#[cfg(test)]
208mod test_validate_path {
209    use super::*;
210    use assert_matches::assert_matches;
211
212    macro_rules! test_err {
213        (
214            $(
215                $test_name:ident => {
216                    path = $path:expr,
217                    err = $err:pat,
218                }
219            )+
220        ) => {
221            $(
222                #[test]
223                fn $test_name() {
224                    assert_matches!(
225                        validate_path($path),
226                        Err($err)
227                    );
228                }
229            )+
230        }
231    }
232
233    test_err! {
234        err_no_leading_slash => {
235            path = "just-name",
236            err = ParseError::PathMustHaveLeadingSlash,
237        }
238        err_trailing_slash => {
239            path = "/name/",
240            err = ParseError::InvalidPathSegment(_),
241        }
242        err_empty_segment => {
243            path = "/name//trailing",
244            err = ParseError::InvalidPathSegment(_),
245        }
246        err_invalid_segment => {
247            path = "/name/#/trailing",
248            err = ParseError::InvalidPathSegment(_),
249        }
250    }
251
252    #[test]
253    fn success() {
254        for path in ["/", "/name", "/name/other", "/name/other/more"] {
255            let () = validate_path(path).unwrap();
256        }
257    }
258}
259
260#[cfg(test)]
261mod test_validate_inverse_relative_url {
262    use super::*;
263    use assert_matches::assert_matches;
264
265    macro_rules! test_err {
266        (
267            $(
268                $test_name:ident => {
269                    path = $path:expr,
270                    some_unbased = $some_unbased:expr,
271                }
272            )+
273        ) => {
274            $(
275                #[test]
276                fn $test_name() {
277                    let err = ParseError::InvalidRelativePath(
278                        $path.to_string(),
279                        $some_unbased.map(|s: &str| s.to_string()),
280                    );
281                    assert_matches!(
282                        validate_inverse_relative_url($path),
283                        Err(e) if e == err,
284                        "the url {:?}; expected = {:?}",
285                        $path, err
286                    );
287                }
288            )+
289        }
290    }
291
292    test_err! {
293        err_slash_prefix => {
294            path = "/name",
295            some_unbased = Some("name"),
296        }
297        err_three_slashes_prefix => {
298            path = "///name",
299            some_unbased = Some("name"),
300        }
301        err_slash_prefix_with_resource => {
302            path = "/name#resource",
303            some_unbased = Some("name#resource"),
304        }
305        err_three_slashes_prefix_and_resource => {
306            path = "///name#resource",
307            some_unbased = Some("name#resource"),
308        }
309        err_masks_host_must_be_empty_err => {
310            path = "//example.org/name",
311            some_unbased = None,
312        }
313        err_dot_masks_missing_name_err => {
314            path = ".",
315            some_unbased = Some(""),
316        }
317        err_dot_dot_masks_missing_name_err => {
318            path = "..",
319            some_unbased = Some(""),
320        }
321    }
322
323    #[test]
324    fn success() {
325        for path in ["name", "other3-name", "name#resource", "name#reso%09urce"] {
326            let () = validate_inverse_relative_url(path).unwrap();
327        }
328    }
329}
330
331#[cfg(test)]
332mod test_parse_path_to_name_and_variant {
333    use super::*;
334    use assert_matches::assert_matches;
335
336    macro_rules! test_err {
337        (
338            $(
339                $test_name:ident => {
340                    path = $path:expr,
341                    err = $err:pat,
342                }
343            )+
344        ) => {
345            $(
346                #[test]
347                fn $test_name() {
348                    assert_matches!(
349                        parse_path_to_name_and_variant($path),
350                        Err($err)
351                    );
352                }
353            )+
354        }
355    }
356
357    test_err! {
358        err_no_leading_slash => {
359            path = "just-name",
360            err = ParseError::PathMustHaveLeadingSlash,
361        }
362        err_no_name => {
363            path = "/",
364            err = ParseError::MissingName,
365        }
366        err_empty_variant => {
367            path = "/name/",
368            err = ParseError::InvalidVariant(_),
369        }
370        err_trailing_slash => {
371            path = "/name/variant/",
372            err = ParseError::ExtraPathSegments,
373        }
374        err_extra_segment => {
375            path = "/name/variant/extra",
376            err = ParseError::ExtraPathSegments,
377        }
378        err_invalid_segment => {
379            path = "/name/#",
380            err = ParseError::InvalidVariant(_),
381        }
382    }
383
384    #[test]
385    fn success() {
386        assert_eq!(
387            ("name".parse().unwrap(), None),
388            parse_path_to_name_and_variant("/name").unwrap()
389        );
390        assert_eq!(
391            ("name".parse().unwrap(), Some("variant".parse().unwrap())),
392            parse_path_to_name_and_variant("/name/variant").unwrap()
393        );
394    }
395}
396
397#[cfg(test)]
398mod test_url_parts {
399    use super::*;
400    use crate::errors::ResourcePathError;
401    use assert_matches::assert_matches;
402
403    macro_rules! test_parse_err {
404        (
405            $(
406                $test_name:ident => {
407                    url = $url:expr,
408                    err = $err:pat,
409                }
410            )+
411        ) => {
412            $(
413                #[test]
414                fn $test_name() {
415                    assert_matches!(
416                        UrlParts::parse($url),
417                        Err($err)
418                    );
419                }
420            )+
421        }
422    }
423
424    test_parse_err! {
425        err_invalid_scheme => {
426            url = "bad-scheme://example.org",
427            err = ParseError::InvalidScheme,
428        }
429        err_port => {
430            url = "fuchsia-pkg://example.org:1",
431            err = ParseError::CannotContainPort,
432        }
433        err_username => {
434            url = "fuchsia-pkg://user@example.org",
435            err = ParseError::CannotContainUsername,
436        }
437        err_password => {
438            url = "fuchsia-pkg://:password@example.org",
439            err = ParseError::CannotContainPassword,
440        }
441        err_invalid_host => {
442            url = "fuchsia-pkg://exa$mple.org",
443            err = ParseError::InvalidHost,
444        }
445        // Path validation covered by test_validate_path, this just checks that the path is
446        // validated at all.
447        err_invalid_path => {
448            url = "fuchsia-pkg://example.org//",
449            err = ParseError::InvalidPathSegment(_),
450        }
451        err_empty_hash => {
452            url = "fuchsia-pkg://example.org/?hash=",
453            err = ParseError::InvalidHash(_),
454        }
455        err_invalid_hash => {
456            url = "fuchsia-pkg://example.org/?hash=INVALID_HASH",
457            err = ParseError::InvalidHash(_),
458        }
459        err_uppercase_hash => {
460            url = "fuchsia-pkg://example.org/?hash=A000000000000000000000000000000000000000000000000000000000000000",
461            err = ParseError::UpperCaseHash,
462        }
463        err_hash_too_long => {
464            url = "fuchsia-pkg://example.org/?hash=00000000000000000000000000000000000000000000000000000000000000001",
465            err = ParseError::InvalidHash(_),
466        }
467        err_hash_too_short => {
468            url = "fuchsia-pkg://example.org/?hash=000000000000000000000000000000000000000000000000000000000000000",
469            err = ParseError::InvalidHash(_),
470        }
471        err_multiple_hashes => {
472            url = "fuchsia-pkg://example.org/?hash=0000000000000000000000000000000000000000000000000000000000000000&\
473            hash=0000000000000000000000000000000000000000000000000000000000000000",
474            err = ParseError::MultipleHashes,
475        }
476        err_non_hash_query_parameter => {
477            url = "fuchsia-pkg://example.org/?invalid-key=invalid-value",
478            err = ParseError::ExtraQueryParameters,
479        }
480        err_resource_slash => {
481            url = "fuchsia-pkg://example.org/name#/",
482            err = ParseError::InvalidResourcePath(ResourcePathError::PathStartsWithSlash),
483        }
484        err_resource_leading_slash => {
485            url = "fuchsia-pkg://example.org/name#/resource",
486            err = ParseError::InvalidResourcePath(ResourcePathError::PathStartsWithSlash),
487        }
488        err_resource_trailing_slash => {
489            url = "fuchsia-pkg://example.org/name#resource/",
490            err = ParseError::InvalidResourcePath(ResourcePathError::PathEndsWithSlash),
491        }
492        err_resource_empty_segment => {
493            url = "fuchsia-pkg://example.org/name#resource//other",
494            err = ParseError::InvalidResourcePath(ResourcePathError::NameEmpty),
495        }
496        err_resource_bad_segment => {
497            url = "fuchsia-pkg://example.org/name#resource/./other",
498            err = ParseError::InvalidResourcePath(ResourcePathError::NameIsDot),
499        }
500        err_resource_percent_encoded_null => {
501            url = "fuchsia-pkg://example.org/name#resource%00",
502            err = ParseError::InvalidResourcePath(ResourcePathError::NameContainsNull),
503        }
504        err_resource_unencoded_null => {
505            url =  "fuchsia-pkg://example.org/name#reso\x00urce",
506            err = ParseError::InvalidResourcePath(ResourcePathError::NameContainsNull),
507        }
508    }
509
510    macro_rules! test_parse_ok {
511        (
512            $(
513                $test_name:ident => {
514                    url = $url:expr,
515                    scheme = $scheme:expr,
516                    host = $host:expr,
517                    path = $path:expr,
518                    hash = $hash:expr,
519                    resource = $resource:expr,
520                }
521            )+
522        ) => {
523            $(
524                #[test]
525                fn $test_name() {
526                    assert_eq!(
527                        UrlParts::parse($url).unwrap(),
528                        UrlParts {
529                            scheme: $scheme,
530                            host: $host,
531                            path: $path.into(),
532                            hash: $hash,
533                            resource: $resource,
534                        }
535                    )
536                }
537            )+
538        }
539    }
540
541    test_parse_ok! {
542        ok_fuchsia_pkg_scheme => {
543            url =  "fuchsia-pkg://",
544            scheme = Some(Scheme::FuchsiaPkg),
545            host = None,
546            path = "/",
547            hash = None,
548            resource = None,
549        }
550        ok_fuchsia_boot_scheme => {
551            url =  "fuchsia-boot://",
552            scheme = Some(Scheme::FuchsiaBoot),
553            host = None,
554            path = "/",
555            hash = None,
556            resource = None,
557        }
558        ok_host => {
559            url =  "fuchsia-pkg://example.org",
560            scheme = Some(Scheme::FuchsiaPkg),
561            host = Some(Host::parse("example.org".into()).unwrap()),
562            path = "/",
563            hash = None,
564            resource = None,
565        }
566        ok_path_single_segment => {
567            url =  "fuchsia-pkg:///name",
568            scheme = Some(Scheme::FuchsiaPkg),
569            host = None,
570            path = "/name",
571            hash = None,
572            resource = None,
573        }
574        ok_path_multiple_segment => {
575            url =  "fuchsia-pkg:///name/variant/other",
576            scheme = Some(Scheme::FuchsiaPkg),
577            host = None,
578            path = "/name/variant/other",
579            hash = None,
580            resource = None,
581        }
582        ok_hash => {
583            url =  "fuchsia-pkg://?hash=0000000000000000000000000000000000000000000000000000000000000000",
584            scheme = Some(Scheme::FuchsiaPkg),
585            host = None,
586            path = "/",
587            hash = Some(
588                "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
589            ),
590            resource = None,
591        }
592        ok_resource_single_segment => {
593            url =  "fuchsia-pkg://#resource",
594            scheme = Some(Scheme::FuchsiaPkg),
595            host = None,
596            path = "/",
597            hash = None,
598            resource = Some("resource".into()),
599        }
600        ok_resource_multiple_segment => {
601            url =  "fuchsia-pkg://#resource/again/third",
602            scheme = Some(Scheme::FuchsiaPkg),
603            host = None,
604            path = "/",
605            hash = None,
606            resource = Some("resource/again/third".into()),
607        }
608        ok_resource_encoded_control_character => {
609            url =  "fuchsia-pkg://#reso%09urce",
610            scheme = Some(Scheme::FuchsiaPkg),
611            host = None,
612            path = "/",
613            hash = None,
614            resource = Some("reso\turce".into()),
615        }
616        ok_all_fields => {
617            url =  "fuchsia-pkg://example.org/name\
618            ?hash=0000000000000000000000000000000000000000000000000000000000000000\
619            #resource",
620            scheme = Some(Scheme::FuchsiaPkg),
621            host = Some(Host::parse("example.org".into()).unwrap()),
622            path = "/name",
623            hash = Some(
624                "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
625            ),
626            resource = Some("resource".into()),
627        }
628        ok_relative_path_single_segment => {
629            url =  "name",
630            scheme = None,
631            host = None,
632            path = "/name",
633            hash = None,
634            resource = None,
635        }
636        ok_relative_path_single_segment_leading_slash => {
637            url =  "/name",
638            scheme = None,
639            host = None,
640            path = "/name",
641            hash = None,
642            resource = None,
643        }
644        ok_relative_path_multiple_segment => {
645            url =  "name/variant/other",
646            scheme = None,
647            host = None,
648            path = "/name/variant/other",
649            hash = None,
650            resource = None,
651        }
652        ok_relative_path_multiple_segment_leading_slash => {
653            url =  "/name/variant/other",
654            scheme = None,
655            host = None,
656            path = "/name/variant/other",
657            hash = None,
658            resource = None,
659        }
660        ok_relative_hash => {
661            url =  "?hash=0000000000000000000000000000000000000000000000000000000000000000",
662            scheme = None,
663            host = None,
664            path = "/",
665            hash = Some(
666                "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
667            ),
668            resource = None,
669        }
670        ok_relative_resource_single_segment => {
671            url =  "#resource",
672            scheme = None,
673            host = None,
674            path = "/",
675            hash = None,
676            resource = Some("resource".into()),
677        }
678        ok_relative_resource_multiple_segment => {
679            url =  "#resource/again/third",
680            scheme = None,
681            host = None,
682            path = "/",
683            hash = None,
684            resource = Some("resource/again/third".into()),
685        }
686        ok_relative_all_fields => {
687            url =  "name\
688            ?hash=0000000000000000000000000000000000000000000000000000000000000000\
689            #resource",
690            scheme = None,
691            host = None,
692            path = "/name",
693            hash = Some(
694                "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
695            ),
696            resource = Some("resource".into()),
697        }
698    }
699}