1pub 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
50const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
52
53const RELATIVE_SCHEME: &str = "relative";
54
55static 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 path: String,
73 hash: Option<Hash>,
74 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
143fn 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 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
175fn 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
190fn 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 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}