use git_remote_object_store::test_util::EnvGuard;
use git_remote_object_store::url::{
AzureAddressing, ENV_ALLOW_HTTP, ParseError, RemoteFlags, RemoteUrl, S3Addressing, parse,
};
use proptest::prelude::*;
fn with_allow_http_env<R>(value: Option<&str>, f: impl FnOnce() -> R) -> R {
let _env = match value {
Some(v) => EnvGuard::set(ENV_ALLOW_HTTP, v),
None => EnvGuard::unset(ENV_ALLOW_HTTP),
};
f()
}
#[test]
fn s3_virtual_hosted_aws() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/my-repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
flags,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, S3Addressing::VirtualHosted);
assert_eq!(flags, RemoteFlags::default());
}
#[test]
fn s3_path_style_aws() {
let url = parse("s3+https://s3.us-west-2.amazonaws.com/my-bucket/my-repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, S3Addressing::PathStyle);
}
#[test]
fn s3_local_minio() {
with_allow_http_env(None, || {
let url = parse("s3+http://localhost:9000/my-bucket/my-repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, S3Addressing::PathStyle);
});
}
#[test]
fn s3_cloudflare_r2() {
let url = parse("s3+https://acc-id1234.r2.cloudflarestorage.com/my-bucket/my-repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, S3Addressing::PathStyle);
}
#[test]
fn s3_backblaze_b2() {
let url = parse("s3+https://s3.us-west-002.backblazeb2.com/my-bucket/my-repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, S3Addressing::PathStyle);
}
#[test]
fn azure_public_cloud() {
let url = parse("az+https://myaccount.blob.core.windows.net/my-container/my-repo").unwrap();
let RemoteUrl::Azure {
account,
container,
prefix,
addressing,
..
} = url
else {
panic!("expected Azure");
};
assert_eq!(account, "myaccount");
assert_eq!(container, "my-container");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, AzureAddressing::VirtualHosted);
}
#[test]
fn azure_us_gov_cloud() {
let url =
parse("az+https://myaccount.blob.core.usgovcloudapi.net/my-container/my-repo").unwrap();
let RemoteUrl::Azure {
account,
container,
prefix,
addressing,
..
} = url
else {
panic!("expected Azure");
};
assert_eq!(account, "myaccount");
assert_eq!(container, "my-container");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, AzureAddressing::VirtualHosted);
}
#[test]
fn azure_azurite_path_style() {
with_allow_http_env(None, || {
let url = parse("az+http://127.0.0.1:10000/devstoreaccount1/my-container/my-repo").unwrap();
let RemoteUrl::Azure {
account,
container,
prefix,
addressing,
..
} = url
else {
panic!("expected Azure");
};
assert_eq!(account, "devstoreaccount1");
assert_eq!(container, "my-container");
assert_eq!(prefix.as_deref(), Some("my-repo"));
assert_eq!(addressing, AzureAddressing::PathStyle);
});
}
#[test]
fn s3_virtual_hosted_dotted_bucket_auto_detect() {
let url = parse("s3+https://bucketname.com.s3.us-west-2.amazonaws.com/repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "bucketname.com");
assert_eq!(prefix.as_deref(), Some("repo"));
assert_eq!(addressing, S3Addressing::VirtualHosted);
}
#[test]
fn s3_virtual_hosted_dotted_bucket_explicit_flag() {
let url = parse("s3+https://bucketname.com.s3.us-west-2.amazonaws.com/repo?addressing=virtual")
.unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "bucketname.com");
assert_eq!(prefix.as_deref(), Some("repo"));
assert_eq!(addressing, S3Addressing::VirtualHosted);
}
#[test]
fn s3_legacy_hyphenated_with_dotted_bucket() {
let url = parse("s3+https://bucketname.com.s3-us-west-2.amazonaws.com/repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "bucketname.com");
assert_eq!(prefix.as_deref(), Some("repo"));
assert_eq!(addressing, S3Addressing::VirtualHosted);
}
#[test]
fn s3_virtual_hosted_short_dotted_bucket_no_longer_invalid() {
let url = parse("s3+https://my.dotted.s3.us-west-2.amazonaws.com/repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my.dotted");
assert_eq!(prefix.as_deref(), Some("repo"));
assert_eq!(addressing, S3Addressing::VirtualHosted);
}
#[test]
fn s3_path_style_dotted_bucket_still_works() {
let url = parse("s3+https://s3.us-west-2.amazonaws.com/my.dotted.bucket/repo").unwrap();
let RemoteUrl::S3 {
bucket,
prefix,
addressing,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(bucket, "my.dotted.bucket");
assert_eq!(prefix.as_deref(), Some("repo"));
assert_eq!(addressing, S3Addressing::PathStyle);
}
#[test]
fn s3_zip_flag() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/my-repo?zip=1").unwrap();
assert!(url.flags().zip);
assert_eq!(url.flags().profile, None);
}
#[test]
fn s3_all_flags() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/my-repo\
?zip=true&profile=prod®ion=us-east-1",
)
.unwrap();
assert!(url.flags().zip);
assert_eq!(url.flags().profile.as_deref(), Some("prod"));
assert_eq!(url.flags().region.as_deref(), Some("us-east-1"));
}
#[test]
fn azure_credential_flag() {
let url = parse(
"az+https://myaccount.blob.core.windows.net/my-container/repo\
?credential=ci-cd",
)
.unwrap();
assert_eq!(url.flags().credential.as_deref(), Some("ci-cd"));
}
#[test]
fn missing_prefix_is_allowed_virtual() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com").unwrap();
let RemoteUrl::S3 { bucket, prefix, .. } = url else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix, None);
}
#[test]
fn missing_prefix_is_allowed_path_style() {
let url = parse("s3+https://s3.us-west-2.amazonaws.com/my-bucket").unwrap();
let RemoteUrl::S3 { bucket, prefix, .. } = url else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix, None);
}
#[test]
fn trailing_slash_on_prefix_is_stripped() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/my-repo/").unwrap();
assert_eq!(url.prefix(), Some("my-repo"));
}
#[test]
fn nested_prefix_is_joined() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/team/repo").unwrap();
assert_eq!(url.prefix(), Some("team/repo"));
}
#[test]
fn addressing_override_forces_path_on_virtual_host() {
let url =
parse("s3+https://example.s3.us-west-2.amazonaws.com/my-bucket/my-repo?addressing=path")
.unwrap();
let RemoteUrl::S3 {
bucket,
addressing,
prefix,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(addressing, S3Addressing::PathStyle);
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
}
#[test]
fn addressing_override_forces_virtual_on_path_host() {
let url = parse("s3+https://my-bucket.minio.example.com/my-repo?addressing=virtual").unwrap();
let RemoteUrl::S3 {
bucket,
addressing,
prefix,
..
} = url
else {
panic!("expected S3");
};
assert_eq!(addressing, S3Addressing::VirtualHosted);
assert_eq!(bucket, "my-bucket");
assert_eq!(prefix.as_deref(), Some("my-repo"));
}
#[test]
fn azure_addressing_override_path() {
let url = parse(
"az+https://myaccount.blob.core.windows.net/myacct1/my-container/my-repo\
?addressing=path",
)
.unwrap();
let RemoteUrl::Azure {
account,
container,
prefix,
addressing,
..
} = url
else {
panic!("expected Azure");
};
assert_eq!(addressing, AzureAddressing::PathStyle);
assert_eq!(account, "myacct1");
assert_eq!(container, "my-container");
assert_eq!(prefix.as_deref(), Some("my-repo"));
}
#[test]
fn rejects_https_without_backend_prefix() {
let err = parse("https://my-bucket.s3.us-west-2.amazonaws.com/my-repo").unwrap_err();
assert!(matches!(err, ParseError::UnsupportedScheme(s) if s == "https"));
}
#[test]
fn rejects_ftp() {
let err = parse("ftp://example.com/").unwrap_err();
assert!(matches!(err, ParseError::UnsupportedScheme(s) if s == "ftp"));
}
#[test]
fn rejects_cleartext_http_to_non_loopback_without_env() {
with_allow_http_env(None, || {
let err = parse("s3+http://example.com/my-bucket/my-repo").unwrap_err();
assert!(matches!(err, ParseError::CleartextHttpForbidden { .. }));
});
}
#[test]
fn allows_cleartext_http_to_non_loopback_with_env() {
with_allow_http_env(Some("1"), || {
let url = parse("s3+http://example.com/my-bucket/my-repo").unwrap();
let RemoteUrl::S3 { bucket, .. } = url else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
});
}
#[test]
fn env_allow_http_accepts_every_truthy_token() {
for value in [
"1", "true", "TRUE", "True", "yes", "Yes", "YES", "on", "On", "ON",
] {
with_allow_http_env(Some(value), || {
let url = parse("s3+http://example.com/my-bucket/my-repo")
.unwrap_or_else(|err| panic!("expected `{value}` to open the gate, got {err:?}"));
let RemoteUrl::S3 { bucket, .. } = url else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
});
}
}
#[test]
fn env_allow_http_rejects_falsy_and_unknown_tokens() {
for value in [
"0", "false", "FALSE", "False", "no", "No", "NO", "off", "Off", "OFF", "", " ", "maybe",
"2", "-1",
] {
with_allow_http_env(Some(value), || {
let err = parse("s3+http://example.com/my-bucket/my-repo")
.err()
.unwrap_or_else(|| panic!("expected `{value}` to keep the gate closed"));
assert!(
matches!(err, ParseError::CleartextHttpForbidden { .. }),
"expected CleartextHttpForbidden for `{value}`, got {err:?}",
);
});
}
}
#[test]
fn ipv6_loopback_allows_cleartext() {
with_allow_http_env(None, || {
let url = parse("s3+http://[::1]:9000/my-bucket/my-repo").unwrap();
let RemoteUrl::S3 { bucket, .. } = url else {
panic!("expected S3");
};
assert_eq!(bucket, "my-bucket");
});
}
#[test]
fn rejects_uppercase_bucket() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/MyBucket/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidBucket(s) if s == "MyBucket"));
}
#[test]
fn rejects_too_short_bucket() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/ab/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidBucket(s) if s == "ab"));
}
#[test]
fn rejects_bucket_starting_with_dash() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/-bucket/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidBucket(s) if s == "-bucket"));
}
#[test]
fn rejects_missing_bucket() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/").unwrap_err();
assert!(matches!(err, ParseError::MissingBucket));
}
#[test]
fn rejects_missing_container() {
let url = "az+https://myaccount.blob.core.windows.net/";
let err = parse(url).unwrap_err();
assert!(matches!(err, ParseError::MissingContainer));
}
#[test]
fn rejects_missing_account_path_style() {
with_allow_http_env(None, || {
let err = parse("az+http://127.0.0.1:10000/").unwrap_err();
assert!(matches!(err, ParseError::MissingAccount));
});
}
#[test]
fn rejects_invalid_account_charset() {
let err = parse("az+https://has-hyphen.blob.core.windows.net/my-container/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidAccount(s) if s == "has-hyphen"));
}
#[test]
fn rejects_invalid_container_charset() {
let err = parse("az+https://myaccount.blob.core.windows.net/UPPER/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidContainer(s) if s == "UPPER"));
}
#[test]
fn rejects_forbidden_bucket_prefixes_path_style() {
for bad in ["xn--abcdef", "sthree-foo", "amzn-s3-demo-bucket"] {
let url = format!("s3+https://s3.us-west-2.amazonaws.com/{bad}/repo");
let err = parse(&url).unwrap_err();
assert!(
matches!(&err, ParseError::InvalidBucket(s) if s == bad),
"expected InvalidBucket({bad}), got {err:?}"
);
}
}
#[test]
fn rejects_forbidden_bucket_suffixes_path_style() {
for bad in [
"my-bucket-s3alias",
"my-bucket--ol-s3",
"my-bucket--x-s3",
"my-bucket--table-s3",
"ab.mrap",
] {
let url = format!("s3+https://s3.us-west-2.amazonaws.com/{bad}/repo");
let err = parse(&url).unwrap_err();
assert!(
matches!(&err, ParseError::InvalidBucket(s) if s == bad),
"expected InvalidBucket({bad}), got {err:?}"
);
}
}
#[test]
fn rejects_bucket_formatted_as_ipv4() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/192.168.1.1/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidBucket(s) if s == "192.168.1.1"));
}
#[test]
fn rejects_bucket_with_consecutive_periods() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/ab..cd/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidBucket(s) if s == "ab..cd"));
}
#[test]
fn rejects_bucket_ending_with_dash() {
let err = parse("s3+https://s3.us-west-2.amazonaws.com/bucket-/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidBucket(s) if s == "bucket-"));
}
#[test]
fn rejects_container_with_leading_dash() {
let err = parse("az+https://myaccount.blob.core.windows.net/-leading/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidContainer(s) if s == "-leading"));
}
#[test]
fn rejects_container_with_trailing_dash() {
let err = parse("az+https://myaccount.blob.core.windows.net/trailing-/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidContainer(s) if s == "trailing-"));
}
#[test]
fn rejects_container_with_consecutive_dashes() {
let err = parse("az+https://myaccount.blob.core.windows.net/foo--bar/repo").unwrap_err();
assert!(matches!(err, ParseError::InvalidContainer(s) if s == "foo--bar"));
}
#[test]
fn rejects_unknown_flag() {
let err = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?bogus=1").unwrap_err();
assert!(matches!(err, ParseError::UnknownFlag(s) if s == "bogus"));
}
#[test]
fn rejects_invalid_zip_value() {
let err = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?zip=maybe").unwrap_err();
assert!(matches!(
err,
ParseError::InvalidFlagValue { name, value } if name == "zip" && value == "maybe"
));
}
#[test]
fn rejects_unknown_addressing() {
let err =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?addressing=weird").unwrap_err();
assert!(matches!(err, ParseError::UnknownAddressing(s) if s == "weird"));
}
#[test]
fn rejects_empty_input() {
assert_eq!(parse(""), Err(ParseError::Empty));
assert_eq!(parse(" "), Err(ParseError::Empty));
}
#[test]
fn display_round_trip_concrete() {
let inputs = [
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/my-repo",
"s3+https://s3.us-west-2.amazonaws.com/my-bucket/my-repo",
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/my-repo?zip=1",
"az+https://myaccount.blob.core.windows.net/my-container/my-repo",
];
for input in inputs {
let parsed = parse(input).expect(input);
let displayed = parsed.to_string();
let reparsed = parse(&displayed).expect(&displayed);
assert_eq!(parsed, reparsed, "round-trip mismatch for `{input}`");
}
}
const FORBIDDEN_BUCKET_PREFIXES: &[&str] = &["xn--", "sthree-", "amzn-s3-demo-"];
const FORBIDDEN_BUCKET_SUFFIXES: &[&str] = &["-s3alias", "--ol-s3", "--x-s3", "--table-s3"];
fn arb_bucket() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-z0-9][a-z0-9-]{1,28}[a-z0-9]")
.expect("valid bucket regex")
.prop_filter("excludes AWS reserved prefix/suffix", |s| {
!FORBIDDEN_BUCKET_PREFIXES.iter().any(|p| s.starts_with(p))
&& !FORBIDDEN_BUCKET_SUFFIXES.iter().any(|p| s.ends_with(p))
})
}
fn looks_like_ipv4(s: &str) -> bool {
let mut parts = s.split('.');
let segs = [parts.next(), parts.next(), parts.next(), parts.next()];
parts.next().is_none()
&& segs
.iter()
.all(|p| matches!(p, Some(seg) if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit())))
}
fn arb_dotted_bucket() -> impl Strategy<Value = String> {
proptest::collection::vec(
proptest::string::string_regex("[a-z0-9]{1,8}").expect("dotted-bucket segment regex"),
2..=4,
)
.prop_map(|parts| parts.join("."))
.prop_filter("valid AWS bucket name", |s| {
(3..=63).contains(&s.len())
&& !FORBIDDEN_BUCKET_PREFIXES.iter().any(|p| s.starts_with(p))
&& !FORBIDDEN_BUCKET_SUFFIXES.iter().any(|p| s.ends_with(p))
&& !looks_like_ipv4(s)
})
}
fn arb_account() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-z0-9]{3,24}").expect("valid account regex")
}
fn arb_container() -> impl Strategy<Value = String> {
proptest::string::string_regex("[a-z0-9][a-z0-9-]{1,28}[a-z0-9]")
.expect("valid container regex")
.prop_filter("no consecutive hyphens", |s| !s.contains("--"))
}
fn arb_prefix() -> impl Strategy<Value = Option<String>> {
prop_oneof![
Just(None),
proptest::string::string_regex("[a-z0-9][a-z0-9_-]{0,16}")
.expect("prefix regex")
.prop_map(Some),
(
proptest::string::string_regex("[a-z0-9]{1,8}").expect("seg regex"),
proptest::string::string_regex("[a-z0-9]{1,8}").expect("seg regex"),
)
.prop_map(|(a, b)| Some(format!("{a}/{b}"))),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn s3_virtual_hosted_round_trip(
bucket in arb_bucket(),
prefix in arb_prefix(),
zip in any::<bool>(),
) {
let prefix_part = prefix.as_deref().map_or(String::new(), |p| format!("/{p}"));
let zip_part = if zip { "?zip=1" } else { "" };
let input = format!(
"s3+https://{bucket}.s3.us-west-2.amazonaws.com{prefix_part}{zip_part}"
);
let parsed = parse(&input).expect("valid input");
let displayed = parsed.to_string();
let reparsed = parse(&displayed).expect("display output should re-parse");
prop_assert_eq!(parsed, reparsed);
}
#[test]
fn s3_path_style_round_trip(
bucket in arb_bucket(),
prefix in arb_prefix(),
) {
let prefix_part = prefix.as_deref().map_or(String::new(), |p| format!("/{p}"));
let input = format!("s3+https://s3.us-west-2.amazonaws.com/{bucket}{prefix_part}");
let parsed = parse(&input).expect("valid input");
let reparsed = parse(&parsed.to_string()).expect("display output should re-parse");
prop_assert_eq!(parsed, reparsed);
}
#[test]
fn s3_virtual_hosted_dotted_bucket_round_trip(
bucket in arb_dotted_bucket(),
prefix in arb_prefix(),
) {
let prefix_part = prefix.as_deref().map_or(String::new(), |p| format!("/{p}"));
let input = format!(
"s3+https://{bucket}.s3.us-west-2.amazonaws.com{prefix_part}"
);
let parsed = parse(&input).expect("valid input");
let RemoteUrl::S3 { bucket: parsed_bucket, addressing, .. } = &parsed else {
panic!("expected S3");
};
prop_assert_eq!(parsed_bucket, &bucket);
prop_assert_eq!(*addressing, S3Addressing::VirtualHosted);
let reparsed = parse(&parsed.to_string()).expect("display output should re-parse");
prop_assert_eq!(parsed, reparsed);
}
#[test]
fn azure_virtual_hosted_round_trip(
account in arb_account(),
container in arb_container(),
prefix in arb_prefix(),
) {
let prefix_part = prefix.as_deref().map_or(String::new(), |p| format!("/{p}"));
let input = format!(
"az+https://{account}.blob.core.windows.net/{container}{prefix_part}"
);
let parsed = parse(&input).expect("valid input");
let reparsed = parse(&parsed.to_string()).expect("display output should re-parse");
prop_assert_eq!(parsed, reparsed);
}
#[test]
fn azure_path_style_round_trip(
account in arb_account(),
container in arb_container(),
prefix in arb_prefix(),
) {
let prefix_part = prefix.as_deref().map_or(String::new(), |p| format!("/{p}"));
let input = format!(
"az+http://127.0.0.1:10000/{account}/{container}{prefix_part}"
);
let parsed = parse(&input).expect("valid input");
let reparsed = parse(&parsed.to_string()).expect("display output should re-parse");
prop_assert_eq!(parsed, reparsed);
}
}