1use std::env;
11use std::fmt;
12use std::num::NonZeroU64;
13use std::str::FromStr;
14
15use thiserror::Error;
16use url::Url;
17
18pub const ENV_ALLOW_HTTP: &str = "GIT_REMOTE_OBJECT_STORE_ALLOW_HTTP";
25
26pub(crate) const MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS: u64 = 7 * 24 * 60 * 60;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RemoteUrl {
49 S3 {
51 endpoint: Url,
53 bucket: String,
55 prefix: Option<String>,
57 addressing: S3Addressing,
59 flags: RemoteFlags,
61 },
62 Azure {
64 endpoint: Url,
66 account: String,
68 container: String,
70 prefix: Option<String>,
72 addressing: AzureAddressing,
74 flags: RemoteFlags,
76 },
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum S3Addressing {
82 VirtualHosted,
85 PathStyle,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum AzureAddressing {
93 VirtualHosted,
98 PathStyle,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum StorageEngine {
112 Bundle,
116 Packchain,
125}
126
127impl StorageEngine {
128 pub(crate) const ALL: &'static [Self] = &[Self::Bundle, Self::Packchain];
135
136 pub(crate) fn from_name(name: &str) -> Option<Self> {
139 Self::ALL
140 .iter()
141 .copied()
142 .find(|engine| engine.as_str() == name)
143 }
144
145 #[must_use]
148 pub const fn as_str(self) -> &'static str {
149 match self {
150 Self::Bundle => "bundle",
151 Self::Packchain => "packchain",
152 }
153 }
154
155 #[must_use]
162 pub(crate) fn supported_list_str() -> String {
163 Self::ALL
164 .iter()
165 .map(|engine| format!("`{}`", engine.as_str()))
166 .collect::<Vec<_>>()
167 .join(", ")
168 }
169}
170
171impl fmt::Display for StorageEngine {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 f.write_str(self.as_str())
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189#[non_exhaustive]
190pub enum BackendKind {
191 S3,
193 Azure,
195}
196
197impl BackendKind {
198 pub(crate) const fn scheme_prefix(self) -> &'static str {
200 match self {
201 Self::S3 => "s3+",
202 Self::Azure => "az+",
203 }
204 }
205
206 pub(crate) const fn name(self) -> &'static str {
208 match self {
209 Self::S3 => "S3",
210 Self::Azure => "Azure",
211 }
212 }
213}
214
215impl fmt::Display for BackendKind {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 f.write_str(self.name())
218 }
219}
220
221#[derive(Debug, Clone, Default, PartialEq, Eq)]
223pub struct RemoteFlags {
224 pub zip: bool,
226 pub profile: Option<String>,
230 pub credential: Option<String>,
234 pub region: Option<String>,
238 pub engine: Option<StorageEngine>,
244 pub bundle_uri: bool,
252 pub bundle_uri_presign_ttl: Option<NonZeroU64>,
276}
277
278#[derive(Debug, Error, PartialEq, Eq)]
280pub enum ParseError {
281 #[error("empty URL")]
283 Empty,
284 #[error("unsupported scheme `{0}`; expected `s3+https`, `s3+http`, `az+https`, or `az+http`")]
286 UnsupportedScheme(String),
287 #[error("malformed URL: {0}")]
289 InvalidUrl(#[from] url::ParseError),
290 #[error("URL is missing a host")]
292 MissingHost,
293 #[error("URL is missing the bucket segment")]
295 MissingBucket,
296 #[error("URL is missing the container segment")]
299 MissingContainer,
300 #[error("URL is missing the account segment")]
303 MissingAccount,
304 #[error("invalid bucket name `{0}`")]
306 InvalidBucket(String),
307 #[error("invalid storage-account name `{0}`")]
309 InvalidAccount(String),
310 #[error("invalid container name `{0}`")]
312 InvalidContainer(String),
313 #[error(
316 "cleartext http:// is forbidden against non-loopback host `{host}`; \
317 set {ENV_ALLOW_HTTP}=1 to override"
318 )]
319 CleartextHttpForbidden {
320 host: String,
322 },
323 #[error("unknown addressing override `{0}`; expected `path` or `virtual`")]
325 UnknownAddressing(String),
326 #[error("invalid value for flag `{name}`: `{value}`")]
328 InvalidFlagValue {
329 name: String,
331 value: String,
333 },
334 #[error("unknown query flag `{0}`")]
336 UnknownFlag(String),
337 #[error("query flag `{flag}` does not apply to the {backend} backend")]
343 FlagNotApplicable {
344 flag: String,
346 backend: BackendKind,
348 },
349 #[error(
351 "unknown engine `{0}`; expected one of {supported}",
352 supported = StorageEngine::supported_list_str()
353 )]
354 UnknownEngine(String),
355 #[error(
361 "hostname `{host}` is not a recognized AWS S3 endpoint; \
362 for virtual-hosted use `<bucket>.s3[.<region>].amazonaws.com`, \
363 for path-style use `s3[.<region>|-<region>].amazonaws.com`"
364 )]
365 InvalidAwsS3Endpoint {
366 host: String,
368 },
369 #[error(
375 "bundle_uri_presign_ttl=`{value}` exceeds the 7-day maximum \
376 ({max} seconds); presigned URLs cannot be valid for longer"
377 )]
378 BundleUriPresignTtlTooLarge {
379 value: u64,
381 max: u64,
384 },
385 #[error("bundle_uri_presign_ttl requires `?bundle_uri=1`; it has no effect otherwise")]
394 BundleUriPresignTtlWithoutBundleUri,
395}
396
397pub fn parse(input: &str) -> Result<RemoteUrl, ParseError> {
408 let trimmed = input.trim();
409 if trimmed.is_empty() {
410 return Err(ParseError::Empty);
411 }
412
413 let (backend, body) = detect_backend(trimmed)?;
414 let endpoint = Url::parse(body)?;
415
416 let host = endpoint
417 .host_str()
418 .ok_or(ParseError::MissingHost)?
419 .to_owned();
420 if endpoint.scheme() == "http" && !is_loopback(&endpoint) && !http_allowed_by_env() {
421 return Err(ParseError::CleartextHttpForbidden { host });
422 }
423
424 let (flags, addressing_override) = extract_flags(&endpoint)?;
425 reject_inapplicable_presign_ttl(&flags)?;
426
427 match backend {
428 BackendKind::S3 => finish_s3(endpoint, &host, flags, addressing_override),
429 BackendKind::Azure => finish_azure(endpoint, &host, flags, addressing_override),
430 }
431}
432
433impl FromStr for RemoteUrl {
434 type Err = ParseError;
435
436 fn from_str(s: &str) -> Result<Self, ParseError> {
437 parse(s)
438 }
439}
440
441impl fmt::Display for RemoteUrl {
442 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443 match self {
444 Self::S3 { endpoint, .. } => write!(f, "s3+{endpoint}"),
445 Self::Azure { endpoint, .. } => write!(f, "az+{endpoint}"),
446 }
447 }
448}
449
450impl RemoteUrl {
451 #[must_use]
453 pub const fn endpoint(&self) -> &Url {
454 match self {
455 Self::S3 { endpoint, .. } | Self::Azure { endpoint, .. } => endpoint,
456 }
457 }
458
459 #[must_use]
461 pub fn prefix(&self) -> Option<&str> {
462 match self {
463 Self::S3 { prefix, .. } | Self::Azure { prefix, .. } => prefix.as_deref(),
464 }
465 }
466
467 #[must_use]
469 pub const fn flags(&self) -> &RemoteFlags {
470 match self {
471 Self::S3 { flags, .. } | Self::Azure { flags, .. } => flags,
472 }
473 }
474
475 #[must_use]
477 pub const fn kind(&self) -> BackendKind {
478 match self {
479 Self::S3 { .. } => BackendKind::S3,
480 Self::Azure { .. } => BackendKind::Azure,
481 }
482 }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
490enum AddressingOverride {
491 Path,
492 Virtual,
493}
494
495fn detect_backend(input: &str) -> Result<(BackendKind, &str), ParseError> {
504 for kind in [BackendKind::S3, BackendKind::Azure] {
505 if let Some(body) = input.strip_prefix(kind.scheme_prefix())
506 && (body.starts_with("https://") || body.starts_with("http://"))
507 {
508 return Ok((kind, body));
509 }
510 }
511 Err(ParseError::UnsupportedScheme(scheme_of(input)))
512}
513
514fn scheme_of(input: &str) -> String {
517 input.split(':').next().unwrap_or(input).to_owned()
518}
519
520fn is_loopback(u: &Url) -> bool {
521 match u.host() {
522 Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
523 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
524 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
525 None => false,
526 }
527}
528
529fn http_allowed_by_env() -> bool {
530 env::var(ENV_ALLOW_HTTP)
535 .ok()
536 .as_deref()
537 .and_then(parse_bool_value)
538 .unwrap_or(false)
539}
540
541fn extract_flags(u: &Url) -> Result<(RemoteFlags, Option<AddressingOverride>), ParseError> {
544 let mut flags = RemoteFlags::default();
545 let mut addressing = None;
546 for (key, value) in u.query_pairs() {
547 match key.as_ref() {
548 "zip" => flags.zip = parse_bool_flag("zip", value.as_ref())?,
549 "profile" => flags.profile = Some(value.into_owned()),
550 "credential" => flags.credential = Some(value.into_owned()),
551 "region" => flags.region = Some(value.into_owned()),
552 "addressing" => {
553 addressing = Some(match value.as_ref() {
554 "path" => AddressingOverride::Path,
555 "virtual" => AddressingOverride::Virtual,
556 other => return Err(ParseError::UnknownAddressing(other.to_owned())),
557 });
558 }
559 "engine" => {
560 flags.engine = Some(
561 StorageEngine::from_name(value.as_ref())
562 .ok_or_else(|| ParseError::UnknownEngine(value.into_owned()))?,
563 );
564 }
565 "bundle_uri" => flags.bundle_uri = parse_bool_flag("bundle_uri", value.as_ref())?,
566 "bundle_uri_presign_ttl" => {
567 flags.bundle_uri_presign_ttl = Some(parse_bundle_uri_presign_ttl(value.as_ref())?);
568 }
569 other => return Err(ParseError::UnknownFlag(other.to_owned())),
570 }
571 }
572 Ok((flags, addressing))
573}
574
575fn reject_inapplicable_flag(
586 present: bool,
587 flag: &str,
588 backend: BackendKind,
589) -> Result<(), ParseError> {
590 if present {
591 return Err(ParseError::FlagNotApplicable {
592 flag: flag.to_owned(),
593 backend,
594 });
595 }
596 Ok(())
597}
598
599fn reject_inapplicable_presign_ttl(flags: &RemoteFlags) -> Result<(), ParseError> {
615 let has_ttl = flags.bundle_uri_presign_ttl.is_some();
616 if has_ttl && !flags.bundle_uri {
617 return Err(ParseError::BundleUriPresignTtlWithoutBundleUri);
618 }
619 Ok(())
620}
621
622fn parse_bool_flag(name: &str, value: &str) -> Result<bool, ParseError> {
623 parse_bool_value(value).ok_or_else(|| ParseError::InvalidFlagValue {
624 name: name.to_owned(),
625 value: value.to_owned(),
626 })
627}
628
629fn parse_bool_value(value: &str) -> Option<bool> {
643 match value.to_ascii_lowercase().as_str() {
647 "1" | "true" | "yes" | "on" => Some(true),
648 "0" | "false" | "no" | "off" => Some(false),
649 _ => None,
650 }
651}
652
653fn parse_nonzero_u64_flag(name: &str, value: &str) -> Result<NonZeroU64, ParseError> {
657 let n: u64 = value.parse().map_err(|_| ParseError::InvalidFlagValue {
658 name: name.to_owned(),
659 value: value.to_owned(),
660 })?;
661 NonZeroU64::new(n).ok_or_else(|| ParseError::InvalidFlagValue {
662 name: name.to_owned(),
663 value: value.to_owned(),
664 })
665}
666
667fn parse_bundle_uri_presign_ttl(value: &str) -> Result<NonZeroU64, ParseError> {
672 let ttl = parse_nonzero_u64_flag("bundle_uri_presign_ttl", value)?;
673 if ttl.get() > MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS {
674 return Err(ParseError::BundleUriPresignTtlTooLarge {
675 value: ttl.get(),
676 max: MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS,
677 });
678 }
679 Ok(ttl)
680}
681
682fn path_segments(u: &Url) -> Vec<String> {
686 u.path_segments()
687 .map(|iter| iter.filter(|s| !s.is_empty()).map(str::to_owned).collect())
688 .unwrap_or_default()
689}
690
691fn join_prefix(segments: &[String]) -> Option<String> {
692 if segments.is_empty() {
693 None
694 } else {
695 Some(segments.join("/"))
696 }
697}
698
699fn set_canonical_path(u: &mut Url, segments: &[&str]) {
702 u.set_path(&format!("/{}", segments.join("/")));
703}
704
705pub(crate) const AWS_HOST_SUFFIXES: &[&str] = &[".amazonaws.com.cn", ".amazonaws.com"];
720
721pub(crate) fn strip_aws_host_suffix(host: &str) -> Option<&str> {
725 AWS_HOST_SUFFIXES
726 .iter()
727 .find_map(|suffix| host.strip_suffix(suffix))
728}
729
730fn check_aws_s3_host(host: &str) -> Result<(), ParseError> {
762 let Some(trimmed) = strip_aws_host_suffix(host) else {
763 return Ok(());
765 };
766
767 let last_label_is_s3 = trimmed.split('.').next_back() == Some("s3");
773
774 let valid = trimmed == "s3"
775 || trimmed.starts_with("s3.")
776 || trimmed.starts_with("s3-")
783 || last_label_is_s3
784 || trimmed.contains(".s3.")
785 || trimmed.contains(".s3-");
786
787 if !valid {
788 return Err(ParseError::InvalidAwsS3Endpoint {
789 host: host.to_owned(),
790 });
791 }
792 Ok(())
793}
794
795fn finish_s3(
796 mut endpoint: Url,
797 host: &str,
798 flags: RemoteFlags,
799 addressing_override: Option<AddressingOverride>,
800) -> Result<RemoteUrl, ParseError> {
801 reject_inapplicable_flag(flags.credential.is_some(), "credential", BackendKind::S3)?;
804
805 let segments = path_segments(&endpoint);
806
807 check_aws_s3_host(host)?;
808
809 let (addressing, bucket, prefix_segments) =
810 resolve_s3_components(host, &segments, addressing_override)?;
811
812 if !is_valid_bucket(&bucket) {
813 return Err(ParseError::InvalidBucket(bucket));
814 }
815 let prefix = join_prefix(prefix_segments);
816
817 let canonical: Vec<&str> = match addressing {
819 S3Addressing::VirtualHosted => prefix_segments.iter().map(String::as_str).collect(),
820 S3Addressing::PathStyle => std::iter::once(bucket.as_str())
821 .chain(prefix_segments.iter().map(String::as_str))
822 .collect(),
823 };
824 set_canonical_path(&mut endpoint, &canonical);
825
826 Ok(RemoteUrl::S3 {
827 endpoint,
828 bucket,
829 prefix,
830 addressing,
831 flags,
832 })
833}
834
835fn resolve_s3_components<'a>(
842 host: &str,
843 segments: &'a [String],
844 addressing_override: Option<AddressingOverride>,
845) -> Result<(S3Addressing, String, &'a [String]), ParseError> {
846 let (addressing, aws_bucket) = match addressing_override {
848 Some(AddressingOverride::Path) => (S3Addressing::PathStyle, None),
849 Some(AddressingOverride::Virtual) => {
850 (S3Addressing::VirtualHosted, s3_virtual_hosted_bucket(host))
851 }
852 None => {
853 let b = s3_virtual_hosted_bucket(host);
854 let style = if b.is_some() {
855 S3Addressing::VirtualHosted
856 } else {
857 S3Addressing::PathStyle
858 };
859 (style, b)
860 }
861 };
862
863 let (bucket, prefix_segments) = match addressing {
864 S3Addressing::VirtualHosted => {
865 let bucket = aws_bucket
870 .or_else(|| leftmost_label(host))
871 .ok_or(ParseError::MissingBucket)?;
872 (bucket, segments)
873 }
874 S3Addressing::PathStyle => {
875 let (head, tail) = segments.split_first().ok_or(ParseError::MissingBucket)?;
876 (head.clone(), tail)
877 }
878 };
879
880 Ok((addressing, bucket, prefix_segments))
881}
882
883pub(crate) const AWS_S3_INFIXES: &[&str] = &[".s3.", ".s3-"];
890
891pub(crate) fn s3_virtual_hosted_bucket(host: &str) -> Option<String> {
902 AWS_S3_INFIXES
906 .iter()
907 .filter_map(|infix| host.rfind(infix))
908 .max()
909 .map(|idx| host[..idx].to_owned())
910 .filter(|bucket| !bucket.is_empty())
911}
912
913fn leftmost_label(host: &str) -> Option<String> {
914 host.split('.')
915 .next()
916 .filter(|l| !l.is_empty())
917 .map(str::to_owned)
918}
919
920fn finish_azure(
925 mut endpoint: Url,
926 host: &str,
927 flags: RemoteFlags,
928 addressing_override: Option<AddressingOverride>,
929) -> Result<RemoteUrl, ParseError> {
930 reject_inapplicable_flag(flags.profile.is_some(), "profile", BackendKind::Azure)?;
934 reject_inapplicable_flag(flags.region.is_some(), "region", BackendKind::Azure)?;
935
936 let segments = path_segments(&endpoint);
937
938 let addressing = match addressing_override {
939 Some(AddressingOverride::Path) => AzureAddressing::PathStyle,
940 Some(AddressingOverride::Virtual) => AzureAddressing::VirtualHosted,
941 None => detect_azure_addressing(host),
942 };
943
944 let (account, container, prefix_segments) =
945 resolve_azure_components(addressing, host, &segments)?;
946
947 if !is_valid_account(&account) {
948 return Err(ParseError::InvalidAccount(account));
949 }
950 if !is_valid_container(&container) {
951 return Err(ParseError::InvalidContainer(container));
952 }
953 let prefix = join_prefix(prefix_segments);
954
955 let canonical: Vec<&str> = match addressing {
956 AzureAddressing::VirtualHosted => std::iter::once(container.as_str())
957 .chain(prefix_segments.iter().map(String::as_str))
958 .collect(),
959 AzureAddressing::PathStyle => std::iter::once(account.as_str())
960 .chain(std::iter::once(container.as_str()))
961 .chain(prefix_segments.iter().map(String::as_str))
962 .collect(),
963 };
964 set_canonical_path(&mut endpoint, &canonical);
965
966 Ok(RemoteUrl::Azure {
967 endpoint,
968 account,
969 container,
970 prefix,
971 addressing,
972 flags,
973 })
974}
975
976fn resolve_azure_components<'a>(
979 addressing: AzureAddressing,
980 host: &str,
981 segments: &'a [String],
982) -> Result<(String, String, &'a [String]), ParseError> {
983 match addressing {
984 AzureAddressing::VirtualHosted => {
985 let account = leftmost_label(host).ok_or(ParseError::MissingAccount)?;
986 match segments {
987 [] => Err(ParseError::MissingContainer),
988 [container, rest @ ..] => Ok((account, container.clone(), rest)),
989 }
990 }
991 AzureAddressing::PathStyle => match segments {
992 [] => Err(ParseError::MissingAccount),
993 [_] => Err(ParseError::MissingContainer),
994 [account, container, rest @ ..] => Ok((account.clone(), container.clone(), rest)),
995 },
996 }
997}
998
999fn detect_azure_addressing(host: &str) -> AzureAddressing {
1000 if host.split('.').nth(1) == Some("blob") {
1003 AzureAddressing::VirtualHosted
1004 } else {
1005 AzureAddressing::PathStyle
1006 }
1007}
1008
1009const FORBIDDEN_BUCKET_PREFIXES: &[&str] = &["xn--", "sthree-", "amzn-s3-demo-"];
1016
1017const FORBIDDEN_BUCKET_SUFFIXES: &[&str] =
1019 &["-s3alias", "--ol-s3", ".mrap", "--x-s3", "--table-s3"];
1020
1021fn is_valid_bucket(s: &str) -> bool {
1026 let bytes = s.as_bytes();
1027 let (Some(&first), Some(&last)) = (bytes.first(), bytes.last()) else {
1028 return false;
1029 };
1030 (3..=63).contains(&bytes.len())
1031 && is_ascii_alphanum_lower(first)
1032 && is_ascii_alphanum_lower(last)
1033 && bytes
1034 .iter()
1035 .all(|b| is_ascii_alphanum_lower(*b) || matches!(*b, b'.' | b'-'))
1036 && !s.contains("..")
1037 && !is_ipv4_formatted(s)
1038 && !FORBIDDEN_BUCKET_PREFIXES.iter().any(|p| s.starts_with(p))
1039 && !FORBIDDEN_BUCKET_SUFFIXES.iter().any(|p| s.ends_with(p))
1040}
1041
1042fn is_valid_account(s: &str) -> bool {
1044 (3..=24).contains(&s.len()) && s.bytes().all(is_ascii_alphanum_lower)
1045}
1046
1047fn is_valid_container(s: &str) -> bool {
1052 let bytes = s.as_bytes();
1053 let (Some(&first), Some(&last)) = (bytes.first(), bytes.last()) else {
1054 return false;
1055 };
1056 (3..=63).contains(&bytes.len())
1057 && is_ascii_alphanum_lower(first)
1058 && is_ascii_alphanum_lower(last)
1059 && bytes
1060 .iter()
1061 .all(|b| is_ascii_alphanum_lower(*b) || *b == b'-')
1062 && !s.contains("--")
1063}
1064
1065const fn is_ascii_alphanum_lower(b: u8) -> bool {
1066 b.is_ascii_lowercase() || b.is_ascii_digit()
1067}
1068
1069fn is_ipv4_formatted(s: &str) -> bool {
1073 let mut parts = 0usize;
1074 for part in s.split('.') {
1075 parts += 1;
1076 if parts > 4 {
1077 return false;
1078 }
1079 if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
1080 return false;
1081 }
1082 }
1083 parts == 4
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 #[test]
1091 fn rejects_empty() {
1092 assert_eq!(parse(""), Err(ParseError::Empty));
1093 assert_eq!(parse(" "), Err(ParseError::Empty));
1094 }
1095
1096 #[test]
1097 fn rejects_unknown_scheme() {
1098 let err = parse("https://example.com/bucket").unwrap_err();
1099 assert!(matches!(err, ParseError::UnsupportedScheme(s) if s == "https"));
1100 }
1101
1102 #[test]
1103 fn rejects_backend_tag_with_unsupported_inner_scheme() {
1104 for input in [
1109 "s3+ftp://example.com/b",
1110 "az+ftp://acct.blob.core.windows.net/c",
1111 ] {
1112 let err = parse(input).unwrap_err();
1113 assert!(
1114 matches!(&err, ParseError::UnsupportedScheme(_)),
1115 "expected UnsupportedScheme for {input}, got {err:?}",
1116 );
1117 }
1118 }
1119
1120 #[test]
1121 fn validates_bucket_charset() {
1122 assert!(is_valid_bucket("my-bucket"));
1123 assert!(is_valid_bucket("a23"));
1124 assert!(is_valid_bucket("a.b.c"));
1125 assert!(!is_valid_bucket("ab"));
1126 assert!(!is_valid_bucket("-leading-dash"));
1127 assert!(!is_valid_bucket("trailing-dash-"));
1128 assert!(!is_valid_bucket(".leading-dot"));
1129 assert!(!is_valid_bucket("trailing-dot."));
1130 assert!(!is_valid_bucket("UPPER"));
1131 assert!(!is_valid_bucket(&"a".repeat(64)));
1132 }
1133
1134 #[test]
1135 fn rejects_bucket_with_consecutive_dots() {
1136 assert!(!is_valid_bucket("ab..cd"));
1137 assert!(!is_valid_bucket("a..b"));
1138 }
1139
1140 #[test]
1141 fn rejects_bucket_formatted_like_ipv4() {
1142 assert!(!is_valid_bucket("192.168.1.1"));
1143 assert!(!is_valid_bucket("1.2.3.4"));
1144 assert!(!is_valid_bucket("999.999.999.999"));
1145 assert!(is_valid_bucket("1.2.3"));
1147 assert!(is_valid_bucket("1.2.3.4.5"));
1148 }
1149
1150 #[test]
1151 fn rejects_forbidden_bucket_prefixes() {
1152 assert!(!is_valid_bucket("xn--abc"));
1153 assert!(!is_valid_bucket("sthree-foo"));
1154 assert!(!is_valid_bucket("amzn-s3-demo-bucket"));
1155 }
1156
1157 #[test]
1158 fn rejects_forbidden_bucket_suffixes() {
1159 assert!(!is_valid_bucket("my-bucket-s3alias"));
1160 assert!(!is_valid_bucket("my-bucket--ol-s3"));
1161 assert!(!is_valid_bucket("my-bucket--x-s3"));
1162 assert!(!is_valid_bucket("my-bucket--table-s3"));
1163 assert!(!is_valid_bucket("ab.mrap"));
1164 }
1165
1166 #[test]
1167 fn ipv4_formatted_helper() {
1168 assert!(is_ipv4_formatted("0.0.0.0"));
1169 assert!(is_ipv4_formatted("10.20.30.40"));
1170 assert!(!is_ipv4_formatted("a.b.c.d"));
1171 assert!(!is_ipv4_formatted("1.2.3"));
1172 assert!(!is_ipv4_formatted("1.2.3.4.5"));
1173 assert!(!is_ipv4_formatted("1..2.3"));
1174 assert!(!is_ipv4_formatted(".1.2.3.4"));
1175 }
1176
1177 #[test]
1178 fn validates_account_charset() {
1179 assert!(is_valid_account("myacct1"));
1180 assert!(!is_valid_account("ab"));
1181 assert!(!is_valid_account("has-hyphen"));
1182 assert!(!is_valid_account(&"a".repeat(25)));
1183 }
1184
1185 #[test]
1186 fn validates_container_charset() {
1187 assert!(is_valid_container("my-container"));
1188 assert!(is_valid_container("a-b-c"));
1189 assert!(!is_valid_container("ab"));
1190 assert!(!is_valid_container("UPPER"));
1191 assert!(!is_valid_container(&"a".repeat(64)));
1192 }
1193
1194 #[test]
1195 fn rejects_container_with_dash_at_boundary() {
1196 assert!(!is_valid_container("-leading"));
1197 assert!(!is_valid_container("trailing-"));
1198 }
1199
1200 #[test]
1201 fn rejects_container_with_consecutive_dashes() {
1202 assert!(!is_valid_container("a--b"));
1203 assert!(!is_valid_container("foo--bar"));
1204 }
1205
1206 #[test]
1207 fn s3_addressing_heuristic() {
1208 assert!(s3_virtual_hosted_bucket("my-bucket.s3.us-west-2.amazonaws.com").is_some());
1210 assert!(s3_virtual_hosted_bucket("s3.us-west-2.amazonaws.com").is_none());
1211 assert!(s3_virtual_hosted_bucket("acc.r2.cloudflarestorage.com").is_none());
1212 }
1213
1214 #[test]
1215 fn s3_addressing_heuristic_dotted_bucket() {
1216 assert!(s3_virtual_hosted_bucket("bucketname.com.s3.us-west-2.amazonaws.com").is_some());
1220 assert!(s3_virtual_hosted_bucket("my.dotted.s3.us-west-2.amazonaws.com").is_some());
1221 assert!(s3_virtual_hosted_bucket("bucketname.com.s3-us-west-2.amazonaws.com").is_some());
1223 }
1224
1225 #[test]
1226 fn s3_virtual_hosted_bucket_extracts_full_prefix() {
1227 assert_eq!(
1228 s3_virtual_hosted_bucket("my-bucket.s3.us-west-2.amazonaws.com"),
1229 Some("my-bucket".to_owned())
1230 );
1231 assert_eq!(
1232 s3_virtual_hosted_bucket("bucketname.com.s3.us-west-2.amazonaws.com"),
1233 Some("bucketname.com".to_owned())
1234 );
1235 assert_eq!(
1236 s3_virtual_hosted_bucket("my.dotted.s3.us-west-2.amazonaws.com"),
1237 Some("my.dotted".to_owned())
1238 );
1239 assert_eq!(
1240 s3_virtual_hosted_bucket("bucketname.com.s3-us-west-2.amazonaws.com"),
1241 Some("bucketname.com".to_owned())
1242 );
1243 assert_eq!(s3_virtual_hosted_bucket("s3.us-west-2.amazonaws.com"), None);
1246 assert_eq!(
1248 s3_virtual_hosted_bucket("acc.r2.cloudflarestorage.com"),
1249 None
1250 );
1251 assert_eq!(
1255 s3_virtual_hosted_bucket("my.s3.bucket.s3.us-west-2.amazonaws.com"),
1256 Some("my.s3.bucket".to_owned())
1257 );
1258 }
1259
1260 #[test]
1261 fn azure_addressing_heuristic() {
1262 assert_eq!(
1263 detect_azure_addressing("my-account.blob.core.windows.net"),
1264 AzureAddressing::VirtualHosted
1265 );
1266 assert_eq!(
1267 detect_azure_addressing("127.0.0.1"),
1268 AzureAddressing::PathStyle
1269 );
1270 }
1271
1272 #[test]
1273 fn azure_path_style_with_account_only_rejects_missing_container() {
1274 let err = parse("az+https://127.0.0.1/myaccount").unwrap_err();
1277 assert!(
1278 matches!(err, ParseError::MissingContainer),
1279 "expected MissingContainer, got {err:?}",
1280 );
1281 }
1282
1283 #[test]
1286 fn engine_flag_absent_leaves_none() {
1287 let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
1288 assert_eq!(url.flags().engine, None);
1289 }
1290
1291 #[test]
1292 fn engine_flag_bundle_parses() {
1293 let url =
1294 parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=bundle").unwrap();
1295 assert_eq!(url.flags().engine, Some(StorageEngine::Bundle));
1296 }
1297
1298 #[test]
1299 fn engine_flag_rejects_unknown_value() {
1300 let err =
1301 parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=pack").unwrap_err();
1302 assert!(
1303 matches!(err, ParseError::UnknownEngine(ref s) if s == "pack"),
1304 "expected UnknownEngine(pack), got {err:?}",
1305 );
1306 }
1307
1308 #[test]
1309 fn engine_flag_rejects_empty_value() {
1310 let err =
1311 parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=").unwrap_err();
1312 assert!(
1313 matches!(err, ParseError::UnknownEngine(ref s) if s.is_empty()),
1314 "expected UnknownEngine(\"\"), got {err:?}",
1315 );
1316 }
1317
1318 #[test]
1319 fn unknown_engine_error_message_lists_every_supported_engine() {
1320 let err =
1324 parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=pack").unwrap_err();
1325 let rendered = err.to_string();
1326 assert!(
1327 rendered.contains("unknown engine `pack`"),
1328 "missing rejected-value in `{rendered}`",
1329 );
1330 for engine in StorageEngine::ALL {
1331 assert!(
1332 rendered.contains(&format!("`{}`", engine.as_str())),
1333 "UnknownEngine message must mention engine `{}`, got `{rendered}`",
1334 engine.as_str(),
1335 );
1336 }
1337 }
1338
1339 #[test]
1340 fn engine_as_str_roundtrips() {
1341 assert_eq!(StorageEngine::Bundle.as_str(), "bundle");
1342 assert_eq!(StorageEngine::Bundle.to_string(), "bundle");
1343 assert_eq!(StorageEngine::Packchain.as_str(), "packchain");
1344 assert_eq!(StorageEngine::Packchain.to_string(), "packchain");
1345 }
1346
1347 #[test]
1348 fn engine_from_name_parses_known_and_rejects_unknown() {
1349 assert_eq!(
1350 StorageEngine::from_name("bundle"),
1351 Some(StorageEngine::Bundle)
1352 );
1353 assert_eq!(
1354 StorageEngine::from_name("packchain"),
1355 Some(StorageEngine::Packchain)
1356 );
1357 assert_eq!(StorageEngine::from_name("pack"), None);
1358 assert_eq!(StorageEngine::from_name(""), None);
1359 assert_eq!(StorageEngine::from_name("Bundle"), None); assert_eq!(StorageEngine::from_name("Packchain"), None); }
1362
1363 #[test]
1364 fn engine_flag_packchain_parses() {
1365 let url =
1366 parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain").unwrap();
1367 assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
1368 }
1369
1370 #[test]
1373 fn bundle_uri_flag_absent_defaults_to_false() {
1374 let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
1375 assert!(!url.flags().bundle_uri);
1376 }
1377
1378 #[test]
1379 fn bundle_uri_flag_one_sets_true() {
1380 let url = parse(
1381 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
1382 )
1383 .unwrap();
1384 assert!(url.flags().bundle_uri);
1385 }
1386
1387 #[test]
1388 fn bundle_uri_flag_zero_sets_false() {
1389 let url = parse(
1390 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=0",
1391 )
1392 .unwrap();
1393 assert!(!url.flags().bundle_uri);
1394 }
1395
1396 #[test]
1399 fn bundle_uri_presign_ttl_absent_defaults_to_none() {
1400 let url = parse(
1401 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
1402 )
1403 .unwrap();
1404 assert_eq!(url.flags().bundle_uri_presign_ttl, None);
1405 }
1406
1407 #[test]
1408 fn bundle_uri_presign_ttl_positive_int_parses() {
1409 let url = parse(
1410 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1411 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
1412 )
1413 .unwrap();
1414 assert_eq!(
1415 url.flags().bundle_uri_presign_ttl,
1416 Some(NonZeroU64::new(3600).expect("3600 is non-zero")),
1417 );
1418 }
1419
1420 #[test]
1421 fn bundle_uri_presign_ttl_one_second_accepted() {
1422 let url = parse(
1425 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1426 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=1",
1427 )
1428 .unwrap();
1429 assert_eq!(
1430 url.flags().bundle_uri_presign_ttl,
1431 Some(NonZeroU64::new(1).expect("1 is non-zero")),
1432 );
1433 }
1434
1435 #[test]
1436 fn bundle_uri_presign_ttl_zero_rejected() {
1437 let err = parse(
1441 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1442 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=0",
1443 )
1444 .unwrap_err();
1445 assert!(
1446 matches!(
1447 err,
1448 ParseError::InvalidFlagValue { ref name, ref value }
1449 if name == "bundle_uri_presign_ttl" && value == "0"
1450 ),
1451 "expected InvalidFlagValue {{ name: bundle_uri_presign_ttl, value: 0 }}, got {err:?}",
1452 );
1453 }
1454
1455 #[test]
1456 fn bundle_uri_presign_ttl_non_numeric_rejected() {
1457 let err = parse(
1458 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1459 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=abc",
1460 )
1461 .unwrap_err();
1462 assert!(
1463 matches!(
1464 err,
1465 ParseError::InvalidFlagValue { ref name, ref value }
1466 if name == "bundle_uri_presign_ttl" && value == "abc"
1467 ),
1468 "expected InvalidFlagValue, got {err:?}",
1469 );
1470 }
1471
1472 #[test]
1473 fn bundle_uri_presign_ttl_negative_rejected() {
1474 let err = parse(
1476 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1477 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=-1",
1478 )
1479 .unwrap_err();
1480 assert!(
1481 matches!(err, ParseError::InvalidFlagValue { ref name, .. } if name == "bundle_uri_presign_ttl"),
1482 "expected InvalidFlagValue, got {err:?}",
1483 );
1484 }
1485
1486 #[test]
1492 fn bundle_uri_presign_ttl_above_seven_days_rejected() {
1493 let err = parse(
1494 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1495 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=604801",
1496 )
1497 .unwrap_err();
1498 assert!(
1499 matches!(
1500 err,
1501 ParseError::BundleUriPresignTtlTooLarge { value, max }
1502 if value == 604_801 && max == MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS
1503 ),
1504 "expected BundleUriPresignTtlTooLarge {{ value: 604801, max: {MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS} }}, got {err:?}",
1505 );
1506 }
1507
1508 #[test]
1512 fn bundle_uri_presign_ttl_huge_value_rejected_not_panic() {
1513 let err = parse(
1514 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1515 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=999999999999999999",
1516 )
1517 .unwrap_err();
1518 assert!(
1519 matches!(
1520 err,
1521 ParseError::BundleUriPresignTtlTooLarge { value, .. }
1522 if value == 999_999_999_999_999_999
1523 ),
1524 "expected BundleUriPresignTtlTooLarge for huge value, got {err:?}",
1525 );
1526 }
1527
1528 #[test]
1531 fn bundle_uri_presign_ttl_exactly_seven_days_accepted() {
1532 let url = parse(
1533 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1534 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=604800",
1535 )
1536 .unwrap();
1537 assert_eq!(
1538 url.flags().bundle_uri_presign_ttl,
1539 Some(
1540 NonZeroU64::new(MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS).expect("7-day cap is non-zero")
1541 ),
1542 );
1543 }
1544
1545 #[test]
1546 fn engine_flag_packchain_on_azure_url() {
1547 let url =
1548 parse("az+https://myaccount.blob.core.windows.net/my-container/repo?engine=packchain")
1549 .unwrap();
1550 assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
1551 }
1552
1553 #[test]
1554 fn engine_flag_on_azure_url() {
1555 let url =
1556 parse("az+https://myaccount.blob.core.windows.net/my-container/repo?engine=bundle")
1557 .unwrap();
1558 assert_eq!(url.flags().engine, Some(StorageEngine::Bundle));
1559 }
1560
1561 #[test]
1564 fn rejects_amazonaws_host_missing_s3_service_marker() {
1565 let err = parse("s3+https://git-test-2224.us-west-2.amazonaws.com/git-remote-object-store")
1567 .unwrap_err();
1568 assert!(
1569 matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "git-test-2224.us-west-2.amazonaws.com"),
1570 "expected InvalidAwsS3Endpoint, got {err:?}",
1571 );
1572 }
1573
1574 #[test]
1575 fn accepts_valid_aws_s3_hosts() {
1576 parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
1578 parse("s3+https://my-bucket.s3.amazonaws.com/repo").unwrap();
1580 parse("s3+https://my-bucket.s3-us-west-2.amazonaws.com/repo").unwrap();
1582 parse("s3+https://s3.us-west-2.amazonaws.com/my-bucket/repo").unwrap();
1584 parse("s3+https://s3.amazonaws.com/my-bucket/repo").unwrap();
1586 parse("s3+https://s3-us-east-1.amazonaws.com/my-bucket/repo").unwrap();
1588 parse("s3+https://my-bucket.s3.cn-north-1.amazonaws.com.cn/repo").unwrap();
1590 parse("s3+https://s3.cn-north-1.amazonaws.com.cn/my-bucket/repo").unwrap();
1591 }
1592
1593 #[test]
1594 fn rejects_china_amazonaws_host_missing_s3_service_marker() {
1595 let err = parse("s3+https://git-test.cn-north-1.amazonaws.com.cn/repo").unwrap_err();
1601 assert!(
1602 matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "git-test.cn-north-1.amazonaws.com.cn"),
1603 "expected InvalidAwsS3Endpoint, got {err:?}",
1604 );
1605 }
1606
1607 #[test]
1608 fn check_aws_s3_host_runs_before_addressing_override() {
1609 let err =
1615 parse("s3+https://corp.amazonaws.com/my-bucket/repo?addressing=path").unwrap_err();
1616 assert!(
1617 matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "corp.amazonaws.com"),
1618 "expected InvalidAwsS3Endpoint, got {err:?}",
1619 );
1620 let err =
1621 parse("s3+https://corp.amazonaws.com/my-bucket/repo?addressing=virtual").unwrap_err();
1622 assert!(
1623 matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "corp.amazonaws.com"),
1624 "expected InvalidAwsS3Endpoint, got {err:?}",
1625 );
1626 }
1627
1628 #[test]
1629 fn accepts_s3_prefix_known_false_negative() {
1630 parse("s3+https://s3-mybucket.amazonaws.com/my-bucket/repo").unwrap();
1639 }
1640
1641 #[test]
1642 fn accepts_non_aws_s3_compatible_hosts() {
1643 parse("s3+https://play.min.io/my-bucket/repo").unwrap();
1646 parse("s3+https://acc.r2.cloudflarestorage.com/my-bucket/repo").unwrap();
1647 parse("s3+https://localhost/my-bucket/repo?zip=0").unwrap();
1648 }
1649
1650 #[test]
1658 fn parse_bool_value_accepts_truthy_tokens() {
1659 for v in ["1", "true", "yes", "on"] {
1660 assert_eq!(parse_bool_value(v), Some(true), "expected true for `{v}`");
1661 }
1662 }
1663
1664 #[test]
1665 fn parse_bool_value_accepts_falsy_tokens() {
1666 for v in ["0", "false", "no", "off"] {
1667 assert_eq!(parse_bool_value(v), Some(false), "expected false for `{v}`");
1668 }
1669 }
1670
1671 #[test]
1672 fn parse_bool_value_is_case_insensitive() {
1673 for (input, expected) in [
1677 ("TRUE", true),
1678 ("True", true),
1679 ("tRuE", true),
1680 ("YES", true),
1681 ("Yes", true),
1682 ("ON", true),
1683 ("On", true),
1684 ("FALSE", false),
1685 ("False", false),
1686 ("NO", false),
1687 ("No", false),
1688 ("OFF", false),
1689 ("Off", false),
1690 ] {
1691 assert_eq!(
1692 parse_bool_value(input),
1693 Some(expected),
1694 "expected {expected} for `{input}`",
1695 );
1696 }
1697 }
1698
1699 #[test]
1700 fn parse_bool_value_rejects_unknown_tokens() {
1701 for v in [
1708 "", " ", "yep", "nope", "2", "-1", "truee", "y", "n", "enabled",
1709 ] {
1710 assert_eq!(parse_bool_value(v), None, "expected None for `{v}`");
1711 }
1712 }
1713
1714 #[test]
1715 fn parse_bool_flag_propagates_invalid_flag_value_error() {
1716 let err = parse_bool_flag("zip", "maybe").unwrap_err();
1719 assert!(
1720 matches!(&err, ParseError::InvalidFlagValue { name, value }
1721 if name == "zip" && value == "maybe"),
1722 "expected InvalidFlagValue(zip, maybe), got {err:?}",
1723 );
1724 }
1725
1726 #[test]
1727 fn url_bool_flags_accept_mixed_case_and_extended_vocabulary() {
1728 for v in ["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"] {
1733 let url = parse(&format!("s3+https://localhost/my-bucket/repo?zip={v}")).unwrap();
1734 assert!(url.flags().zip, "expected zip=true for `{v}`");
1735 }
1736 for v in ["0", "false", "False", "FALSE", "no", "No", "off", "OFF"] {
1737 let url = parse(&format!("s3+https://localhost/my-bucket/repo?zip={v}")).unwrap();
1738 assert!(!url.flags().zip, "expected zip=false for `{v}`");
1739 }
1740 }
1741
1742 #[test]
1743 fn url_bool_flags_reject_unknown_value_with_flag_name() {
1744 let err = parse("s3+https://localhost/my-bucket/repo?zip=maybe").unwrap_err();
1745 assert!(
1746 matches!(&err, ParseError::InvalidFlagValue { name, value }
1747 if name == "zip" && value == "maybe"),
1748 "expected InvalidFlagValue(zip, maybe), got {err:?}",
1749 );
1750
1751 let err = parse("s3+https://localhost/my-bucket/repo?bundle_uri=2").unwrap_err();
1752 assert!(
1753 matches!(&err, ParseError::InvalidFlagValue { name, value }
1754 if name == "bundle_uri" && value == "2"),
1755 "expected InvalidFlagValue(bundle_uri, 2), got {err:?}",
1756 );
1757 }
1758
1759 #[test]
1762 fn azure_url_rejects_s3_only_profile_flag() {
1763 let err =
1764 parse("az+https://myaccount.blob.core.windows.net/my-container/repo?profile=prod")
1765 .unwrap_err();
1766 assert!(
1767 matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1768 if flag == "profile" && *backend == BackendKind::Azure),
1769 "expected FlagNotApplicable(profile, Azure), got {err:?}",
1770 );
1771 }
1772
1773 #[test]
1774 fn azure_url_rejects_s3_only_region_flag() {
1775 let err =
1776 parse("az+https://myaccount.blob.core.windows.net/my-container/repo?region=us-east-1")
1777 .unwrap_err();
1778 assert!(
1779 matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1780 if flag == "region" && *backend == BackendKind::Azure),
1781 "expected FlagNotApplicable(region, Azure), got {err:?}",
1782 );
1783 }
1784
1785 #[test]
1786 fn s3_url_rejects_azure_only_credential_flag() {
1787 let err = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?credential=ci-cd")
1788 .unwrap_err();
1789 assert!(
1790 matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1791 if flag == "credential" && *backend == BackendKind::S3),
1792 "expected FlagNotApplicable(credential, S3), got {err:?}",
1793 );
1794 }
1795
1796 #[test]
1797 fn inapplicable_flag_rejected_even_with_empty_value() {
1798 let err = parse("az+https://myaccount.blob.core.windows.net/my-container/repo?profile=")
1802 .unwrap_err();
1803 assert!(
1804 matches!(&err, ParseError::FlagNotApplicable { flag, backend }
1805 if flag == "profile" && *backend == BackendKind::Azure),
1806 "expected FlagNotApplicable(profile, Azure), got {err:?}",
1807 );
1808 }
1809
1810 #[test]
1811 fn flag_not_applicable_message_names_flag_and_backend() {
1812 let err =
1813 parse("az+https://myaccount.blob.core.windows.net/my-container/repo?region=us-east-1")
1814 .unwrap_err();
1815 let rendered = err.to_string();
1816 assert!(
1817 rendered.contains("`region`") && rendered.contains("Azure"),
1818 "message must name the flag and backend, got `{rendered}`",
1819 );
1820 }
1821
1822 #[test]
1823 fn valid_backend_flag_pairings_still_parse() {
1824 let s3 = parse(
1827 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1828 ?profile=prod®ion=us-east-1",
1829 )
1830 .unwrap();
1831 assert_eq!(s3.flags().profile.as_deref(), Some("prod"));
1832 assert_eq!(s3.flags().region.as_deref(), Some("us-east-1"));
1833 assert_eq!(s3.flags().credential, None);
1834
1835 let azure =
1836 parse("az+https://myaccount.blob.core.windows.net/my-container/repo?credential=ci-cd")
1837 .unwrap();
1838 assert_eq!(azure.flags().credential.as_deref(), Some("ci-cd"));
1839 assert_eq!(azure.flags().profile, None);
1840 assert_eq!(azure.flags().region, None);
1841 }
1842
1843 #[test]
1854 fn presign_ttl_without_bundle_uri_rejected() {
1855 let err = parse(
1857 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1858 ?engine=packchain&bundle_uri_presign_ttl=3600",
1859 )
1860 .unwrap_err();
1861 assert_eq!(err, ParseError::BundleUriPresignTtlWithoutBundleUri);
1862 }
1863
1864 #[test]
1865 fn presign_ttl_with_bundle_uri_disabled_rejected() {
1866 let err = parse(
1868 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1869 ?engine=packchain&bundle_uri=0&bundle_uri_presign_ttl=3600",
1870 )
1871 .unwrap_err();
1872 assert_eq!(err, ParseError::BundleUriPresignTtlWithoutBundleUri);
1873 }
1874
1875 #[test]
1876 fn presign_ttl_with_explicit_bundle_engine_still_parses() {
1877 let url = parse(
1885 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1886 ?engine=bundle&bundle_uri=1&bundle_uri_presign_ttl=3600",
1887 )
1888 .unwrap();
1889 assert_eq!(
1890 url.flags().bundle_uri_presign_ttl,
1891 Some(NonZeroU64::new(3600).unwrap())
1892 );
1893 }
1894
1895 #[test]
1896 fn presign_ttl_without_engine_flag_still_parses() {
1897 let url = parse(
1903 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1904 ?bundle_uri=1&bundle_uri_presign_ttl=3600",
1905 )
1906 .unwrap();
1907 assert_eq!(url.flags().engine, None);
1908 assert!(url.flags().bundle_uri);
1909 assert_eq!(
1910 url.flags().bundle_uri_presign_ttl,
1911 Some(NonZeroU64::new(3600).unwrap())
1912 );
1913 }
1914
1915 #[test]
1916 fn presign_ttl_with_packchain_and_bundle_uri_parses() {
1917 let url = parse(
1919 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1920 ?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
1921 )
1922 .unwrap();
1923 assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
1924 assert!(url.flags().bundle_uri);
1925 assert_eq!(
1926 url.flags().bundle_uri_presign_ttl,
1927 Some(NonZeroU64::new(3600).unwrap())
1928 );
1929 }
1930
1931 #[test]
1932 fn packchain_and_bundle_uri_without_ttl_parses() {
1933 let url = parse(
1936 "s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
1937 ?engine=packchain&bundle_uri=1",
1938 )
1939 .unwrap();
1940 assert_eq!(url.flags().bundle_uri_presign_ttl, None);
1941 }
1942}