1pub use fuchsia_hash::{HASH_SIZE, Hash};
6
7pub mod boot;
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;
16pub(crate) mod generic;
17mod host;
18mod parse;
19mod path;
20mod relative_component_url;
21mod relative_package_url;
22mod repository_url;
23mod resource;
24pub mod test;
25
26pub use crate::errors::ParseError;
27pub use crate::fuchsia_pkg_absolute_component_url::FuchsiaPkgAbsoluteComponentUrl;
28pub use crate::fuchsia_pkg_absolute_package_url::FuchsiaPkgAbsolutePackageUrl;
29pub use crate::fuchsia_pkg_component_url::FuchsiaPkgComponentUrl;
30pub use crate::fuchsia_pkg_package_url::FuchsiaPkgPackageUrl;
31pub use crate::fuchsia_pkg_pinned_absolute_package_url::FuchsiaPkgPinnedAbsolutePackageUrl;
32pub use crate::fuchsia_pkg_unpinned_absolute_package_url::FuchsiaPkgUnpinnedAbsolutePackageUrl;
33pub mod fuchsia_pkg {
34 pub use crate::{
35 FuchsiaPkgAbsoluteComponentUrl as AbsoluteComponentUrl,
36 FuchsiaPkgAbsolutePackageUrl as AbsolutePackageUrl, FuchsiaPkgComponentUrl as ComponentUrl,
37 FuchsiaPkgPackageUrl as PackageUrl,
38 FuchsiaPkgPinnedAbsolutePackageUrl as PinnedAbsolutePackageUrl,
39 FuchsiaPkgUnpinnedAbsolutePackageUrl as UnpinnedAbsolutePackageUrl,
40 };
41}
42pub use crate::generic::{NoneHash, NoneHost};
43pub use crate::parse::{MAX_PACKAGE_PATH_SEGMENT_BYTES, PackageName, PackageVariant};
44pub use crate::path::Path;
45pub(crate) use crate::path::parse_path_to_name_and_variant;
46pub use crate::relative_component_url::RelativeComponentUrl;
47pub use crate::relative_package_url::RelativePackageUrl;
48pub use crate::repository_url::RepositoryUrl;
49pub use crate::resource::{Resource, ResourcePathError};
50
51use crate::host::Host;
52use percent_encoding::{AsciiSet, CONTROLS};
53use std::sync::LazyLock;
54
55const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
57
58const RELATIVE_SCHEME: &str = "relative";
59
60static RELATIVE_BASE: LazyLock<url::Url> =
63 LazyLock::new(|| url::Url::parse(&format!("{RELATIVE_SCHEME}:///")).unwrap());
64
65#[derive(Clone, Copy, PartialEq, Eq, Debug)]
66pub enum Scheme {
67 Builtin,
68 FuchsiaPkg,
69 FuchsiaBoot,
70}
71
72impl std::fmt::Display for Scheme {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 Self::Builtin => builtin_url::SCHEME,
76 Self::FuchsiaPkg => repository_url::SCHEME,
77 Self::FuchsiaBoot => boot::SCHEME_STR,
78 }
79 .fmt(f)
80 }
81}
82
83#[derive(Debug, PartialEq, Eq)]
84struct UrlParts {
85 scheme: Option<Scheme>,
86 host: Option<Host>,
87 path: Option<Path>,
88 hash: Option<Hash>,
89 resource: Option<Resource>,
90}
91
92impl UrlParts {
93 fn parse(input: &str) -> Result<Self, ParseError> {
94 match url::Url::parse(input) {
95 Ok(url) => Self::from_url(&url),
96 Err(url::ParseError::RelativeUrlWithoutBase) => {
97 Self::from_scheme_and_url(None, &RELATIVE_BASE.join(input)?)
98 }
99 Err(e) => Err(e)?,
100 }
101 }
102 fn from_url(url: &url::Url) -> Result<Self, ParseError> {
103 Self::from_scheme_and_url(
104 Some(match url.scheme() {
105 builtin_url::SCHEME => Scheme::Builtin,
106 repository_url::SCHEME => Scheme::FuchsiaPkg,
107 boot::SCHEME_STR => Scheme::FuchsiaBoot,
108 _ => return Err(ParseError::InvalidScheme),
109 }),
110 url,
111 )
112 }
113 fn from_scheme_and_url(scheme: Option<Scheme>, url: &url::Url) -> Result<Self, ParseError> {
114 if url.port().is_some() {
115 return Err(ParseError::CannotContainPort);
116 }
117
118 if !url.username().is_empty() {
119 return Err(ParseError::CannotContainUsername);
120 }
121
122 if url.password().is_some() {
123 return Err(ParseError::CannotContainPassword);
124 }
125
126 let host = url
127 .host_str()
128 .filter(|s| !s.is_empty())
129 .map(|s| Host::parse(s.to_string()))
130 .transpose()?;
131
132 let path = if url.path().is_empty() || url.path() == "/" {
177 None
178 } else {
179 Some(url.path().strip_prefix("/").unwrap_or_else(|| url.path()).parse()?)
180 };
181
182 let hash = parse_query_pairs(url.query_pairs())?;
183
184 let resource = if let Some(resource) = url.fragment() {
185 let resource = percent_encoding::percent_decode(resource.as_bytes())
186 .decode_utf8()
187 .map_err(ParseError::ResourcePathPercentDecode)?;
188 if resource.is_empty() {
189 None
190 } else {
191 Some(
192 Resource::try_from(resource.into_owned())
193 .map_err(ParseError::InvalidResourcePath)?,
194 )
195 }
196 } else {
197 None
198 };
199
200 Ok(Self { scheme, host, path, hash, resource })
201 }
202}
203
204fn validate_inverse_relative_url(input: &str) -> Result<(), ParseError> {
207 let relative_url = RELATIVE_BASE.join(input)?;
208 let unbased = RELATIVE_BASE.make_relative(&relative_url);
209 if Some(input) == unbased.as_deref() {
210 Ok(())
211 } else {
212 Err(ParseError::InvalidRelativePath(input.to_string(), unbased))?
213 }
214}
215
216fn parse_query_pairs(pairs: url::form_urlencoded::Parse<'_>) -> Result<Option<Hash>, ParseError> {
217 let mut query_hash = None;
218 for (key, value) in pairs {
219 if key == "hash" {
220 if query_hash.is_some() {
221 return Err(ParseError::MultipleHashes);
222 }
223 query_hash = Some(value.parse().map_err(ParseError::InvalidHash)?);
224 if !value.bytes().all(|b| (b >= b'0' && b <= b'9') || (b >= b'a' && b <= b'f')) {
227 return Err(ParseError::UpperCaseHash);
228 }
229 } else {
230 return Err(ParseError::ExtraQueryParameters);
231 }
232 }
233 Ok(query_hash)
234}
235
236pub type ComponentUrl =
239 generic::ComponentUrl<Scheme, generic::OptionHost, Option<Path>, Option<Hash>>;
240
241pub type AbsoluteComponentUrl =
249 generic::AbsoluteComponentUrl<Scheme, generic::OptionHost, Option<Path>, Option<Hash>>;
250
251pub type PackageUrl = generic::PackageUrl<Scheme, generic::OptionHost, Option<Path>, Option<Hash>>;
254
255pub type AbsolutePackageUrl =
262 generic::AbsolutePackageUrl<Scheme, generic::OptionHost, Option<Path>, Option<Hash>>;
263
264trait Sealer {}
266
267#[cfg(test)]
268mod test_validate_inverse_relative_url {
269 use super::*;
270 use assert_matches::assert_matches;
271
272 macro_rules! test_err {
273 (
274 $(
275 $test_name:ident => {
276 path = $path:expr,
277 some_unbased = $some_unbased:expr,
278 }
279 )+
280 ) => {
281 $(
282 #[test]
283 fn $test_name() {
284 let err = ParseError::InvalidRelativePath(
285 $path.to_string(),
286 $some_unbased.map(|s: &str| s.to_string()),
287 );
288 assert_matches!(
289 validate_inverse_relative_url($path),
290 Err(e) if e == err,
291 "the url {:?}; expected = {:?}",
292 $path, err
293 );
294 }
295 )+
296 }
297 }
298
299 test_err! {
300 err_slash_prefix => {
301 path = "/name",
302 some_unbased = Some("name"),
303 }
304 err_three_slashes_prefix => {
305 path = "///name",
306 some_unbased = Some("name"),
307 }
308 err_slash_prefix_with_resource => {
309 path = "/name#resource",
310 some_unbased = Some("name#resource"),
311 }
312 err_three_slashes_prefix_and_resource => {
313 path = "///name#resource",
314 some_unbased = Some("name#resource"),
315 }
316 err_masks_host_must_be_empty_err => {
317 path = "//example.org/name",
318 some_unbased = None,
319 }
320 err_dot_masks_missing_name_err => {
321 path = ".",
322 some_unbased = Some(""),
323 }
324 err_dot_dot_masks_missing_name_err => {
325 path = "..",
326 some_unbased = Some(""),
327 }
328 }
329
330 #[test]
331 fn success() {
332 for path in ["name", "other3-name", "name#resource", "name#reso%09urce"] {
333 let () = validate_inverse_relative_url(path).unwrap();
334 }
335 }
336}
337
338#[cfg(test)]
339mod test_url_parts {
340 use super::*;
341 use assert_matches::assert_matches;
342
343 macro_rules! test_parse_err {
344 (
345 $(
346 $test_name:ident => {
347 url = $url:expr,
348 err = $err:pat,
349 }
350 )+
351 ) => {
352 $(
353 #[test]
354 fn $test_name() {
355 assert_matches!(
356 UrlParts::parse($url),
357 Err($err)
358 );
359 }
360 )+
361 }
362 }
363
364 test_parse_err! {
365 err_invalid_scheme => {
366 url = "bad-scheme://example.org",
367 err = ParseError::InvalidScheme,
368 }
369 err_port => {
370 url = "fuchsia-pkg://example.org:1",
371 err = ParseError::CannotContainPort,
372 }
373 err_username => {
374 url = "fuchsia-pkg://user@example.org",
375 err = ParseError::CannotContainUsername,
376 }
377 err_password => {
378 url = "fuchsia-pkg://:password@example.org",
379 err = ParseError::CannotContainPassword,
380 }
381 err_invalid_host => {
382 url = "fuchsia-pkg://exa$mple.org",
383 err = ParseError::InvalidHost,
384 }
385 err_invalid_path => {
388 url = "fuchsia-pkg://example.org//",
389 err = ParseError::InvalidPathSegment(_),
390 }
391 err_empty_hash => {
392 url = "fuchsia-pkg://example.org/?hash=",
393 err = ParseError::InvalidHash(_),
394 }
395 err_invalid_hash => {
396 url = "fuchsia-pkg://example.org/?hash=INVALID_HASH",
397 err = ParseError::InvalidHash(_),
398 }
399 err_uppercase_hash => {
400 url = "fuchsia-pkg://example.org/?hash=A000000000000000000000000000000000000000000000000000000000000000",
401 err = ParseError::UpperCaseHash,
402 }
403 err_hash_too_long => {
404 url = "fuchsia-pkg://example.org/?hash=00000000000000000000000000000000000000000000000000000000000000001",
405 err = ParseError::InvalidHash(_),
406 }
407 err_hash_too_short => {
408 url = "fuchsia-pkg://example.org/?hash=000000000000000000000000000000000000000000000000000000000000000",
409 err = ParseError::InvalidHash(_),
410 }
411 err_multiple_hashes => {
412 url = "fuchsia-pkg://example.org/?hash=0000000000000000000000000000000000000000000000000000000000000000&\
413 hash=0000000000000000000000000000000000000000000000000000000000000000",
414 err = ParseError::MultipleHashes,
415 }
416 err_non_hash_query_parameter => {
417 url = "fuchsia-pkg://example.org/?invalid-key=invalid-value",
418 err = ParseError::ExtraQueryParameters,
419 }
420 err_resource_slash => {
421 url = "fuchsia-pkg://example.org/name#/",
422 err = ParseError::InvalidResourcePath(ResourcePathError::PathStartsWithSlash),
423 }
424 err_resource_leading_slash => {
425 url = "fuchsia-pkg://example.org/name#/resource",
426 err = ParseError::InvalidResourcePath(ResourcePathError::PathStartsWithSlash),
427 }
428 err_resource_trailing_slash => {
429 url = "fuchsia-pkg://example.org/name#resource/",
430 err = ParseError::InvalidResourcePath(ResourcePathError::PathEndsWithSlash),
431 }
432 err_resource_empty_segment => {
433 url = "fuchsia-pkg://example.org/name#resource//other",
434 err = ParseError::InvalidResourcePath(ResourcePathError::NameEmpty),
435 }
436 err_resource_bad_segment => {
437 url = "fuchsia-pkg://example.org/name#resource/./other",
438 err = ParseError::InvalidResourcePath(ResourcePathError::NameIsDot),
439 }
440 err_resource_percent_encoded_null => {
441 url = "fuchsia-pkg://example.org/name#resource%00",
442 err = ParseError::InvalidResourcePath(ResourcePathError::NameContainsNull),
443 }
444 err_resource_unencoded_null => {
445 url = "fuchsia-pkg://example.org/name#reso\x00urce",
446 err = ParseError::InvalidResourcePath(ResourcePathError::NameContainsNull),
447 }
448 }
449
450 macro_rules! test_parse_ok {
451 (
452 $(
453 $test_name:ident => {
454 url = $url:expr,
455 scheme = $scheme:expr,
456 host = $host:expr,
457 path = $path:expr,
458 hash = $hash:expr,
459 resource = $resource:expr,
460 }
461 )+
462 ) => {
463 $(
464 #[test]
465 fn $test_name() {
466 assert_eq!(
467 UrlParts::parse($url).unwrap(),
468 UrlParts {
469 scheme: $scheme,
470 host: $host,
471 path: $path.map(|s| s.parse::<Path>().unwrap()),
472 hash: $hash,
473 resource: $resource,
474 }
475 )
476 }
477 )+
478 }
479 }
480
481 test_parse_ok! {
482 ok_fuchsia_pkg_scheme => {
483 url = "fuchsia-pkg://",
484 scheme = Some(Scheme::FuchsiaPkg),
485 host = None,
486 path = Option::<&str>::None,
487 hash = None,
488 resource = None,
489 }
490 ok_fuchsia_boot_scheme => {
491 url = "fuchsia-boot://",
492 scheme = Some(Scheme::FuchsiaBoot),
493 host = None,
494 path = Option::<&str>::None,
495 hash = None,
496 resource = None,
497 }
498 ok_host => {
499 url = "fuchsia-pkg://example.org",
500 scheme = Some(Scheme::FuchsiaPkg),
501 host = Some(Host::parse("example.org".into()).unwrap()),
502 path = Option::<&str>::None,
503 hash = None,
504 resource = None,
505 }
506 ok_path_single_segment => {
507 url = "fuchsia-pkg:///name",
508 scheme = Some(Scheme::FuchsiaPkg),
509 host = None,
510 path = Some("name"),
511 hash = None,
512 resource = None,
513 }
514 ok_path_multiple_segment => {
515 url = "fuchsia-pkg:///name/variant/other",
516 scheme = Some(Scheme::FuchsiaPkg),
517 host = None,
518 path = Some("name/variant/other"),
519 hash = None,
520 resource = None,
521 }
522 ok_hash => {
523 url = "fuchsia-pkg://?hash=0000000000000000000000000000000000000000000000000000000000000000",
524 scheme = Some(Scheme::FuchsiaPkg),
525 host = None,
526 path = Option::<&str>::None,
527 hash = Some(
528 "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
529 ),
530 resource = None,
531 }
532 ok_resource_single_segment => {
533 url = "fuchsia-pkg://#resource",
534 scheme = Some(Scheme::FuchsiaPkg),
535 host = None,
536 path = Option::<&str>::None,
537 hash = None,
538 resource = Some("resource".parse().unwrap()),
539 }
540 ok_resource_multiple_segment => {
541 url = "fuchsia-pkg://#resource/again/third",
542 scheme = Some(Scheme::FuchsiaPkg),
543 host = None,
544 path = Option::<&str>::None,
545 hash = None,
546 resource = Some("resource/again/third".parse().unwrap()),
547 }
548 ok_resource_encoded_control_character => {
549 url = "fuchsia-pkg://#reso%09urce",
550 scheme = Some(Scheme::FuchsiaPkg),
551 host = None,
552 path = Option::<&str>::None,
553 hash = None,
554 resource = Some("reso\turce".parse().unwrap()),
555 }
556 ok_all_fields => {
557 url = "fuchsia-pkg://example.org/name\
558 ?hash=0000000000000000000000000000000000000000000000000000000000000000\
559 #resource",
560 scheme = Some(Scheme::FuchsiaPkg),
561 host = Some(Host::parse("example.org".into()).unwrap()),
562 path = Some("name"),
563 hash = Some(
564 "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
565 ),
566 resource = Some("resource".parse().unwrap()),
567 }
568 ok_relative_path_single_segment => {
569 url = "name",
570 scheme = None,
571 host = None,
572 path = Some("name"),
573 hash = None,
574 resource = None,
575 }
576 ok_relative_path_single_segment_leading_slash => {
577 url = "/name",
578 scheme = None,
579 host = None,
580 path = Some("name"),
581 hash = None,
582 resource = None,
583 }
584 ok_relative_path_multiple_segment => {
585 url = "name/variant/other",
586 scheme = None,
587 host = None,
588 path = Some("name/variant/other"),
589 hash = None,
590 resource = None,
591 }
592 ok_relative_path_multiple_segment_leading_slash => {
593 url = "/name/variant/other",
594 scheme = None,
595 host = None,
596 path = Some("name/variant/other"),
597 hash = None,
598 resource = None,
599 }
600 ok_relative_hash => {
601 url = "?hash=0000000000000000000000000000000000000000000000000000000000000000",
602 scheme = None,
603 host = None,
604 path = Option::<&str>::None,
605 hash = Some(
606 "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
607 ),
608 resource = None,
609 }
610 ok_relative_resource_single_segment => {
611 url = "#resource",
612 scheme = None,
613 host = None,
614 path = Option::<&str>::None,
615 hash = None,
616 resource = Some("resource".parse().unwrap()),
617 }
618 ok_relative_resource_multiple_segment => {
619 url = "#resource/again/third",
620 scheme = None,
621 host = None,
622 path = Option::<&str>::None,
623 hash = None,
624 resource = Some("resource/again/third".parse().unwrap()),
625 }
626 ok_relative_all_fields => {
627 url = "name\
628 ?hash=0000000000000000000000000000000000000000000000000000000000000000\
629 #resource",
630 scheme = None,
631 host = None,
632 path = Some("name"),
633 hash = Some(
634 "0000000000000000000000000000000000000000000000000000000000000000".parse().unwrap()
635 ),
636 resource = Some("resource".parse().unwrap()),
637 }
638 }
639}
640
641#[cfg(test)]
642mod test_url_type_aliases {
643 use super::*;
644
645 #[test]
646 fn component_url_round_trip() {
647 let abs_empty = "fuchsia-boot://#my-resource";
648 assert_eq!(ComponentUrl::parse(abs_empty).unwrap().to_string(), abs_empty);
649
650 let abs_present = "fuchsia-boot://my-host/my/path\
651 ?hash=0000000000000000000000000000000000000000000000000000000000000000#my-resource";
652 assert_eq!(ComponentUrl::parse(abs_present).unwrap().to_string(), abs_present);
653
654 let rel = "my-path#my-resource";
655 assert_eq!(ComponentUrl::parse(rel).unwrap().to_string(), rel);
656 }
657
658 #[test]
659 fn package_url_round_trip() {
660 let abs_empty = "fuchsia-boot://";
661 assert_eq!(PackageUrl::parse(abs_empty).unwrap().to_string(), abs_empty);
662
663 let abs_present = "fuchsia-boot://my-host/my/path\
664 ?hash=0000000000000000000000000000000000000000000000000000000000000000";
665 assert_eq!(PackageUrl::parse(abs_present).unwrap().to_string(), abs_present);
666
667 let rel = "my-path";
668 assert_eq!(PackageUrl::parse(rel).unwrap().to_string(), rel);
669 }
670}