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