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