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 percent_encoding::{AsciiSet, CONTROLS};
39use std::sync::LazyLock;
40
41const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');
43
44const RELATIVE_SCHEME: &str = "relative";
45
46static 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 path: String,
64 hash: Option<Hash>,
65 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
134fn 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 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
166fn 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
181fn 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 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}