Skip to main content

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