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