use std::env;
use std::fmt;
use std::num::NonZeroU64;
use std::str::FromStr;
use thiserror::Error;
use url::Url;
pub const ENV_ALLOW_HTTP: &str = "GIT_REMOTE_OBJECT_STORE_ALLOW_HTTP";
pub(crate) const MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS: u64 = 7 * 24 * 60 * 60;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteUrl {
S3 {
endpoint: Url,
bucket: String,
prefix: Option<String>,
addressing: S3Addressing,
flags: RemoteFlags,
},
Azure {
endpoint: Url,
account: String,
container: String,
prefix: Option<String>,
addressing: AzureAddressing,
flags: RemoteFlags,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum S3Addressing {
VirtualHosted,
PathStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AzureAddressing {
VirtualHosted,
PathStyle,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageEngine {
Bundle,
Packchain,
}
impl StorageEngine {
pub(crate) const ALL: &'static [Self] = &[Self::Bundle, Self::Packchain];
pub(crate) fn from_name(name: &str) -> Option<Self> {
Self::ALL
.iter()
.copied()
.find(|engine| engine.as_str() == name)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Bundle => "bundle",
Self::Packchain => "packchain",
}
}
#[must_use]
pub(crate) fn supported_list_str() -> String {
Self::ALL
.iter()
.map(|engine| format!("`{}`", engine.as_str()))
.collect::<Vec<_>>()
.join(", ")
}
}
impl fmt::Display for StorageEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum BackendKind {
S3,
Azure,
}
impl BackendKind {
pub(crate) const fn scheme_prefix(self) -> &'static str {
match self {
Self::S3 => "s3+",
Self::Azure => "az+",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RemoteFlags {
pub zip: bool,
pub profile: Option<String>,
pub credential: Option<String>,
pub region: Option<String>,
pub engine: Option<StorageEngine>,
pub bundle_uri: bool,
pub bundle_uri_presign_ttl: Option<NonZeroU64>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParseError {
#[error("empty URL")]
Empty,
#[error("unsupported scheme `{0}`; expected `s3+https`, `s3+http`, `az+https`, or `az+http`")]
UnsupportedScheme(String),
#[error("malformed URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("URL is missing a host")]
MissingHost,
#[error("URL is missing the bucket segment")]
MissingBucket,
#[error("URL is missing the container segment")]
MissingContainer,
#[error("URL is missing the account segment")]
MissingAccount,
#[error("invalid bucket name `{0}`")]
InvalidBucket(String),
#[error("invalid storage-account name `{0}`")]
InvalidAccount(String),
#[error("invalid container name `{0}`")]
InvalidContainer(String),
#[error(
"cleartext http:// is forbidden against non-loopback host `{host}`; \
set {ENV_ALLOW_HTTP}=1 to override"
)]
CleartextHttpForbidden {
host: String,
},
#[error("unknown addressing override `{0}`; expected `path` or `virtual`")]
UnknownAddressing(String),
#[error("invalid value for flag `{name}`: `{value}`")]
InvalidFlagValue {
name: String,
value: String,
},
#[error("unknown query flag `{0}`")]
UnknownFlag(String),
#[error(
"unknown engine `{0}`; expected one of {supported}",
supported = StorageEngine::supported_list_str()
)]
UnknownEngine(String),
#[error(
"hostname `{host}` is not a recognized AWS S3 endpoint; \
for virtual-hosted use `<bucket>.s3[.<region>].amazonaws.com`, \
for path-style use `s3[.<region>|-<region>].amazonaws.com`"
)]
InvalidAwsS3Endpoint {
host: String,
},
#[error(
"bundle_uri_presign_ttl=`{value}` exceeds the 7-day maximum \
({max} seconds); presigned URLs cannot be valid for longer"
)]
BundleUriPresignTtlTooLarge {
value: u64,
max: u64,
},
}
pub fn parse(input: &str) -> Result<RemoteUrl, ParseError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(ParseError::Empty);
}
let (backend, body) = detect_backend(trimmed)?;
let endpoint = Url::parse(body)?;
let host = endpoint
.host_str()
.ok_or(ParseError::MissingHost)?
.to_owned();
if endpoint.scheme() == "http" && !is_loopback(&endpoint) && !http_allowed_by_env() {
return Err(ParseError::CleartextHttpForbidden { host });
}
let (flags, addressing_override) = extract_flags(&endpoint)?;
match backend {
BackendKind::S3 => finish_s3(endpoint, &host, flags, addressing_override),
BackendKind::Azure => finish_azure(endpoint, &host, flags, addressing_override),
}
}
impl FromStr for RemoteUrl {
type Err = ParseError;
fn from_str(s: &str) -> Result<Self, ParseError> {
parse(s)
}
}
impl fmt::Display for RemoteUrl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::S3 { endpoint, .. } => write!(f, "s3+{endpoint}"),
Self::Azure { endpoint, .. } => write!(f, "az+{endpoint}"),
}
}
}
impl RemoteUrl {
#[must_use]
pub const fn endpoint(&self) -> &Url {
match self {
Self::S3 { endpoint, .. } | Self::Azure { endpoint, .. } => endpoint,
}
}
#[must_use]
pub fn prefix(&self) -> Option<&str> {
match self {
Self::S3 { prefix, .. } | Self::Azure { prefix, .. } => prefix.as_deref(),
}
}
#[must_use]
pub const fn flags(&self) -> &RemoteFlags {
match self {
Self::S3 { flags, .. } | Self::Azure { flags, .. } => flags,
}
}
#[must_use]
pub const fn kind(&self) -> BackendKind {
match self {
Self::S3 { .. } => BackendKind::S3,
Self::Azure { .. } => BackendKind::Azure,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AddressingOverride {
Path,
Virtual,
}
fn detect_backend(input: &str) -> Result<(BackendKind, &str), ParseError> {
for kind in [BackendKind::S3, BackendKind::Azure] {
if let Some(body) = input.strip_prefix(kind.scheme_prefix())
&& (body.starts_with("https://") || body.starts_with("http://"))
{
return Ok((kind, body));
}
}
Err(ParseError::UnsupportedScheme(scheme_of(input)))
}
fn scheme_of(input: &str) -> String {
input.split(':').next().unwrap_or(input).to_owned()
}
fn is_loopback(u: &Url) -> bool {
match u.host() {
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
fn http_allowed_by_env() -> bool {
env::var(ENV_ALLOW_HTTP)
.ok()
.as_deref()
.and_then(parse_bool_value)
.unwrap_or(false)
}
fn extract_flags(u: &Url) -> Result<(RemoteFlags, Option<AddressingOverride>), ParseError> {
let mut flags = RemoteFlags::default();
let mut addressing = None;
for (key, value) in u.query_pairs() {
match key.as_ref() {
"zip" => flags.zip = parse_bool_flag("zip", value.as_ref())?,
"profile" => flags.profile = Some(value.into_owned()),
"credential" => flags.credential = Some(value.into_owned()),
"region" => flags.region = Some(value.into_owned()),
"addressing" => {
addressing = Some(match value.as_ref() {
"path" => AddressingOverride::Path,
"virtual" => AddressingOverride::Virtual,
other => return Err(ParseError::UnknownAddressing(other.to_owned())),
});
}
"engine" => {
flags.engine = Some(
StorageEngine::from_name(value.as_ref())
.ok_or_else(|| ParseError::UnknownEngine(value.into_owned()))?,
);
}
"bundle_uri" => flags.bundle_uri = parse_bool_flag("bundle_uri", value.as_ref())?,
"bundle_uri_presign_ttl" => {
flags.bundle_uri_presign_ttl = Some(parse_bundle_uri_presign_ttl(value.as_ref())?);
}
other => return Err(ParseError::UnknownFlag(other.to_owned())),
}
}
Ok((flags, addressing))
}
fn parse_bool_flag(name: &str, value: &str) -> Result<bool, ParseError> {
parse_bool_value(value).ok_or_else(|| ParseError::InvalidFlagValue {
name: name.to_owned(),
value: value.to_owned(),
})
}
fn parse_bool_value(value: &str) -> Option<bool> {
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn parse_nonzero_u64_flag(name: &str, value: &str) -> Result<NonZeroU64, ParseError> {
let n: u64 = value.parse().map_err(|_| ParseError::InvalidFlagValue {
name: name.to_owned(),
value: value.to_owned(),
})?;
NonZeroU64::new(n).ok_or_else(|| ParseError::InvalidFlagValue {
name: name.to_owned(),
value: value.to_owned(),
})
}
fn parse_bundle_uri_presign_ttl(value: &str) -> Result<NonZeroU64, ParseError> {
let ttl = parse_nonzero_u64_flag("bundle_uri_presign_ttl", value)?;
if ttl.get() > MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS {
return Err(ParseError::BundleUriPresignTtlTooLarge {
value: ttl.get(),
max: MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS,
});
}
Ok(ttl)
}
fn path_segments(u: &Url) -> Vec<String> {
u.path_segments()
.map(|iter| iter.filter(|s| !s.is_empty()).map(str::to_owned).collect())
.unwrap_or_default()
}
fn join_prefix(segments: &[String]) -> Option<String> {
if segments.is_empty() {
None
} else {
Some(segments.join("/"))
}
}
fn set_canonical_path(u: &mut Url, segments: &[&str]) {
u.set_path(&format!("/{}", segments.join("/")));
}
pub(crate) const AWS_HOST_SUFFIXES: &[&str] = &[".amazonaws.com.cn", ".amazonaws.com"];
pub(crate) fn strip_aws_host_suffix(host: &str) -> Option<&str> {
AWS_HOST_SUFFIXES
.iter()
.find_map(|suffix| host.strip_suffix(suffix))
}
fn check_aws_s3_host(host: &str) -> Result<(), ParseError> {
let Some(trimmed) = strip_aws_host_suffix(host) else {
return Ok(());
};
let last_label_is_s3 = trimmed.split('.').next_back() == Some("s3");
let valid = trimmed == "s3"
|| trimmed.starts_with("s3.")
|| trimmed.starts_with("s3-")
|| last_label_is_s3
|| trimmed.contains(".s3.")
|| trimmed.contains(".s3-");
if !valid {
return Err(ParseError::InvalidAwsS3Endpoint {
host: host.to_owned(),
});
}
Ok(())
}
fn finish_s3(
mut endpoint: Url,
host: &str,
flags: RemoteFlags,
addressing_override: Option<AddressingOverride>,
) -> Result<RemoteUrl, ParseError> {
let segments = path_segments(&endpoint);
check_aws_s3_host(host)?;
let (addressing, bucket, prefix_segments) =
resolve_s3_components(host, &segments, addressing_override)?;
if !is_valid_bucket(&bucket) {
return Err(ParseError::InvalidBucket(bucket));
}
let prefix = join_prefix(prefix_segments);
let canonical: Vec<&str> = match addressing {
S3Addressing::VirtualHosted => prefix_segments.iter().map(String::as_str).collect(),
S3Addressing::PathStyle => std::iter::once(bucket.as_str())
.chain(prefix_segments.iter().map(String::as_str))
.collect(),
};
set_canonical_path(&mut endpoint, &canonical);
Ok(RemoteUrl::S3 {
endpoint,
bucket,
prefix,
addressing,
flags,
})
}
fn resolve_s3_components<'a>(
host: &str,
segments: &'a [String],
addressing_override: Option<AddressingOverride>,
) -> Result<(S3Addressing, String, &'a [String]), ParseError> {
let (addressing, aws_bucket) = match addressing_override {
Some(AddressingOverride::Path) => (S3Addressing::PathStyle, None),
Some(AddressingOverride::Virtual) => {
(S3Addressing::VirtualHosted, s3_virtual_hosted_bucket(host))
}
None => {
let b = s3_virtual_hosted_bucket(host);
let style = if b.is_some() {
S3Addressing::VirtualHosted
} else {
S3Addressing::PathStyle
};
(style, b)
}
};
let (bucket, prefix_segments) = match addressing {
S3Addressing::VirtualHosted => {
let bucket = aws_bucket
.or_else(|| leftmost_label(host))
.ok_or(ParseError::MissingBucket)?;
(bucket, segments)
}
S3Addressing::PathStyle => {
let (head, tail) = segments.split_first().ok_or(ParseError::MissingBucket)?;
(head.clone(), tail)
}
};
Ok((addressing, bucket, prefix_segments))
}
pub(crate) const AWS_S3_INFIXES: &[&str] = &[".s3.", ".s3-"];
pub(crate) fn s3_virtual_hosted_bucket(host: &str) -> Option<String> {
AWS_S3_INFIXES
.iter()
.filter_map(|infix| host.rfind(infix))
.max()
.map(|idx| host[..idx].to_owned())
.filter(|bucket| !bucket.is_empty())
}
fn leftmost_label(host: &str) -> Option<String> {
host.split('.')
.next()
.filter(|l| !l.is_empty())
.map(str::to_owned)
}
fn finish_azure(
mut endpoint: Url,
host: &str,
flags: RemoteFlags,
addressing_override: Option<AddressingOverride>,
) -> Result<RemoteUrl, ParseError> {
let segments = path_segments(&endpoint);
let addressing = match addressing_override {
Some(AddressingOverride::Path) => AzureAddressing::PathStyle,
Some(AddressingOverride::Virtual) => AzureAddressing::VirtualHosted,
None => detect_azure_addressing(host),
};
let (account, container, prefix_segments) =
resolve_azure_components(addressing, host, &segments)?;
if !is_valid_account(&account) {
return Err(ParseError::InvalidAccount(account));
}
if !is_valid_container(&container) {
return Err(ParseError::InvalidContainer(container));
}
let prefix = join_prefix(prefix_segments);
let canonical: Vec<&str> = match addressing {
AzureAddressing::VirtualHosted => std::iter::once(container.as_str())
.chain(prefix_segments.iter().map(String::as_str))
.collect(),
AzureAddressing::PathStyle => std::iter::once(account.as_str())
.chain(std::iter::once(container.as_str()))
.chain(prefix_segments.iter().map(String::as_str))
.collect(),
};
set_canonical_path(&mut endpoint, &canonical);
Ok(RemoteUrl::Azure {
endpoint,
account,
container,
prefix,
addressing,
flags,
})
}
fn resolve_azure_components<'a>(
addressing: AzureAddressing,
host: &str,
segments: &'a [String],
) -> Result<(String, String, &'a [String]), ParseError> {
match addressing {
AzureAddressing::VirtualHosted => {
let account = leftmost_label(host).ok_or(ParseError::MissingAccount)?;
match segments {
[] => Err(ParseError::MissingContainer),
[container, rest @ ..] => Ok((account, container.clone(), rest)),
}
}
AzureAddressing::PathStyle => match segments {
[] => Err(ParseError::MissingAccount),
[_] => Err(ParseError::MissingContainer),
[account, container, rest @ ..] => Ok((account.clone(), container.clone(), rest)),
},
}
}
fn detect_azure_addressing(host: &str) -> AzureAddressing {
if host.split('.').nth(1) == Some("blob") {
AzureAddressing::VirtualHosted
} else {
AzureAddressing::PathStyle
}
}
const FORBIDDEN_BUCKET_PREFIXES: &[&str] = &["xn--", "sthree-", "amzn-s3-demo-"];
const FORBIDDEN_BUCKET_SUFFIXES: &[&str] =
&["-s3alias", "--ol-s3", ".mrap", "--x-s3", "--table-s3"];
fn is_valid_bucket(s: &str) -> bool {
let bytes = s.as_bytes();
let (Some(&first), Some(&last)) = (bytes.first(), bytes.last()) else {
return false;
};
(3..=63).contains(&bytes.len())
&& is_ascii_alphanum_lower(first)
&& is_ascii_alphanum_lower(last)
&& bytes
.iter()
.all(|b| is_ascii_alphanum_lower(*b) || matches!(*b, b'.' | b'-'))
&& !s.contains("..")
&& !is_ipv4_formatted(s)
&& !FORBIDDEN_BUCKET_PREFIXES.iter().any(|p| s.starts_with(p))
&& !FORBIDDEN_BUCKET_SUFFIXES.iter().any(|p| s.ends_with(p))
}
fn is_valid_account(s: &str) -> bool {
(3..=24).contains(&s.len()) && s.bytes().all(is_ascii_alphanum_lower)
}
fn is_valid_container(s: &str) -> bool {
let bytes = s.as_bytes();
let (Some(&first), Some(&last)) = (bytes.first(), bytes.last()) else {
return false;
};
(3..=63).contains(&bytes.len())
&& is_ascii_alphanum_lower(first)
&& is_ascii_alphanum_lower(last)
&& bytes
.iter()
.all(|b| is_ascii_alphanum_lower(*b) || *b == b'-')
&& !s.contains("--")
}
const fn is_ascii_alphanum_lower(b: u8) -> bool {
b.is_ascii_lowercase() || b.is_ascii_digit()
}
fn is_ipv4_formatted(s: &str) -> bool {
let mut parts = 0usize;
for part in s.split('.') {
parts += 1;
if parts > 4 {
return false;
}
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
}
parts == 4
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty() {
assert_eq!(parse(""), Err(ParseError::Empty));
assert_eq!(parse(" "), Err(ParseError::Empty));
}
#[test]
fn rejects_unknown_scheme() {
let err = parse("https://example.com/bucket").unwrap_err();
assert!(matches!(err, ParseError::UnsupportedScheme(s) if s == "https"));
}
#[test]
fn rejects_backend_tag_with_unsupported_inner_scheme() {
for input in [
"s3+ftp://example.com/b",
"az+ftp://acct.blob.core.windows.net/c",
] {
let err = parse(input).unwrap_err();
assert!(
matches!(&err, ParseError::UnsupportedScheme(_)),
"expected UnsupportedScheme for {input}, got {err:?}",
);
}
}
#[test]
fn validates_bucket_charset() {
assert!(is_valid_bucket("my-bucket"));
assert!(is_valid_bucket("a23"));
assert!(is_valid_bucket("a.b.c"));
assert!(!is_valid_bucket("ab"));
assert!(!is_valid_bucket("-leading-dash"));
assert!(!is_valid_bucket("trailing-dash-"));
assert!(!is_valid_bucket(".leading-dot"));
assert!(!is_valid_bucket("trailing-dot."));
assert!(!is_valid_bucket("UPPER"));
assert!(!is_valid_bucket(&"a".repeat(64)));
}
#[test]
fn rejects_bucket_with_consecutive_dots() {
assert!(!is_valid_bucket("ab..cd"));
assert!(!is_valid_bucket("a..b"));
}
#[test]
fn rejects_bucket_formatted_like_ipv4() {
assert!(!is_valid_bucket("192.168.1.1"));
assert!(!is_valid_bucket("1.2.3.4"));
assert!(!is_valid_bucket("999.999.999.999"));
assert!(is_valid_bucket("1.2.3"));
assert!(is_valid_bucket("1.2.3.4.5"));
}
#[test]
fn rejects_forbidden_bucket_prefixes() {
assert!(!is_valid_bucket("xn--abc"));
assert!(!is_valid_bucket("sthree-foo"));
assert!(!is_valid_bucket("amzn-s3-demo-bucket"));
}
#[test]
fn rejects_forbidden_bucket_suffixes() {
assert!(!is_valid_bucket("my-bucket-s3alias"));
assert!(!is_valid_bucket("my-bucket--ol-s3"));
assert!(!is_valid_bucket("my-bucket--x-s3"));
assert!(!is_valid_bucket("my-bucket--table-s3"));
assert!(!is_valid_bucket("ab.mrap"));
}
#[test]
fn ipv4_formatted_helper() {
assert!(is_ipv4_formatted("0.0.0.0"));
assert!(is_ipv4_formatted("10.20.30.40"));
assert!(!is_ipv4_formatted("a.b.c.d"));
assert!(!is_ipv4_formatted("1.2.3"));
assert!(!is_ipv4_formatted("1.2.3.4.5"));
assert!(!is_ipv4_formatted("1..2.3"));
assert!(!is_ipv4_formatted(".1.2.3.4"));
}
#[test]
fn validates_account_charset() {
assert!(is_valid_account("myacct1"));
assert!(!is_valid_account("ab"));
assert!(!is_valid_account("has-hyphen"));
assert!(!is_valid_account(&"a".repeat(25)));
}
#[test]
fn validates_container_charset() {
assert!(is_valid_container("my-container"));
assert!(is_valid_container("a-b-c"));
assert!(!is_valid_container("ab"));
assert!(!is_valid_container("UPPER"));
assert!(!is_valid_container(&"a".repeat(64)));
}
#[test]
fn rejects_container_with_dash_at_boundary() {
assert!(!is_valid_container("-leading"));
assert!(!is_valid_container("trailing-"));
}
#[test]
fn rejects_container_with_consecutive_dashes() {
assert!(!is_valid_container("a--b"));
assert!(!is_valid_container("foo--bar"));
}
#[test]
fn s3_addressing_heuristic() {
assert!(s3_virtual_hosted_bucket("my-bucket.s3.us-west-2.amazonaws.com").is_some());
assert!(s3_virtual_hosted_bucket("s3.us-west-2.amazonaws.com").is_none());
assert!(s3_virtual_hosted_bucket("acc.r2.cloudflarestorage.com").is_none());
}
#[test]
fn s3_addressing_heuristic_dotted_bucket() {
assert!(s3_virtual_hosted_bucket("bucketname.com.s3.us-west-2.amazonaws.com").is_some());
assert!(s3_virtual_hosted_bucket("my.dotted.s3.us-west-2.amazonaws.com").is_some());
assert!(s3_virtual_hosted_bucket("bucketname.com.s3-us-west-2.amazonaws.com").is_some());
}
#[test]
fn s3_virtual_hosted_bucket_extracts_full_prefix() {
assert_eq!(
s3_virtual_hosted_bucket("my-bucket.s3.us-west-2.amazonaws.com"),
Some("my-bucket".to_owned())
);
assert_eq!(
s3_virtual_hosted_bucket("bucketname.com.s3.us-west-2.amazonaws.com"),
Some("bucketname.com".to_owned())
);
assert_eq!(
s3_virtual_hosted_bucket("my.dotted.s3.us-west-2.amazonaws.com"),
Some("my.dotted".to_owned())
);
assert_eq!(
s3_virtual_hosted_bucket("bucketname.com.s3-us-west-2.amazonaws.com"),
Some("bucketname.com".to_owned())
);
assert_eq!(s3_virtual_hosted_bucket("s3.us-west-2.amazonaws.com"), None);
assert_eq!(
s3_virtual_hosted_bucket("acc.r2.cloudflarestorage.com"),
None
);
assert_eq!(
s3_virtual_hosted_bucket("my.s3.bucket.s3.us-west-2.amazonaws.com"),
Some("my.s3.bucket".to_owned())
);
}
#[test]
fn azure_addressing_heuristic() {
assert_eq!(
detect_azure_addressing("my-account.blob.core.windows.net"),
AzureAddressing::VirtualHosted
);
assert_eq!(
detect_azure_addressing("127.0.0.1"),
AzureAddressing::PathStyle
);
}
#[test]
fn azure_path_style_with_account_only_rejects_missing_container() {
let err = parse("az+https://127.0.0.1/myaccount").unwrap_err();
assert!(
matches!(err, ParseError::MissingContainer),
"expected MissingContainer, got {err:?}",
);
}
#[test]
fn engine_flag_absent_leaves_none() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
assert_eq!(url.flags().engine, None);
}
#[test]
fn engine_flag_bundle_parses() {
let url =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=bundle").unwrap();
assert_eq!(url.flags().engine, Some(StorageEngine::Bundle));
}
#[test]
fn engine_flag_rejects_unknown_value() {
let err =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=pack").unwrap_err();
assert!(
matches!(err, ParseError::UnknownEngine(ref s) if s == "pack"),
"expected UnknownEngine(pack), got {err:?}",
);
}
#[test]
fn engine_flag_rejects_empty_value() {
let err =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=").unwrap_err();
assert!(
matches!(err, ParseError::UnknownEngine(ref s) if s.is_empty()),
"expected UnknownEngine(\"\"), got {err:?}",
);
}
#[test]
fn unknown_engine_error_message_lists_every_supported_engine() {
let err =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=pack").unwrap_err();
let rendered = err.to_string();
assert!(
rendered.contains("unknown engine `pack`"),
"missing rejected-value in `{rendered}`",
);
for engine in StorageEngine::ALL {
assert!(
rendered.contains(&format!("`{}`", engine.as_str())),
"UnknownEngine message must mention engine `{}`, got `{rendered}`",
engine.as_str(),
);
}
}
#[test]
fn engine_as_str_roundtrips() {
assert_eq!(StorageEngine::Bundle.as_str(), "bundle");
assert_eq!(StorageEngine::Bundle.to_string(), "bundle");
assert_eq!(StorageEngine::Packchain.as_str(), "packchain");
assert_eq!(StorageEngine::Packchain.to_string(), "packchain");
}
#[test]
fn engine_from_name_parses_known_and_rejects_unknown() {
assert_eq!(
StorageEngine::from_name("bundle"),
Some(StorageEngine::Bundle)
);
assert_eq!(
StorageEngine::from_name("packchain"),
Some(StorageEngine::Packchain)
);
assert_eq!(StorageEngine::from_name("pack"), None);
assert_eq!(StorageEngine::from_name(""), None);
assert_eq!(StorageEngine::from_name("Bundle"), None); assert_eq!(StorageEngine::from_name("Packchain"), None); }
#[test]
fn engine_flag_packchain_parses() {
let url =
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain").unwrap();
assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
}
#[test]
fn bundle_uri_flag_absent_defaults_to_false() {
let url = parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
assert!(!url.flags().bundle_uri);
}
#[test]
fn bundle_uri_flag_one_sets_true() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
assert!(url.flags().bundle_uri);
}
#[test]
fn bundle_uri_flag_zero_sets_false() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=0",
)
.unwrap();
assert!(!url.flags().bundle_uri);
}
#[test]
fn bundle_uri_presign_ttl_absent_defaults_to_none() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo?engine=packchain&bundle_uri=1",
)
.unwrap();
assert_eq!(url.flags().bundle_uri_presign_ttl, None);
}
#[test]
fn bundle_uri_presign_ttl_positive_int_parses() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=3600",
)
.unwrap();
assert_eq!(
url.flags().bundle_uri_presign_ttl,
Some(NonZeroU64::new(3600).expect("3600 is non-zero")),
);
}
#[test]
fn bundle_uri_presign_ttl_one_second_accepted() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=1",
)
.unwrap();
assert_eq!(
url.flags().bundle_uri_presign_ttl,
Some(NonZeroU64::new(1).expect("1 is non-zero")),
);
}
#[test]
fn bundle_uri_presign_ttl_zero_rejected() {
let err = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=0",
)
.unwrap_err();
assert!(
matches!(
err,
ParseError::InvalidFlagValue { ref name, ref value }
if name == "bundle_uri_presign_ttl" && value == "0"
),
"expected InvalidFlagValue {{ name: bundle_uri_presign_ttl, value: 0 }}, got {err:?}",
);
}
#[test]
fn bundle_uri_presign_ttl_non_numeric_rejected() {
let err = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=abc",
)
.unwrap_err();
assert!(
matches!(
err,
ParseError::InvalidFlagValue { ref name, ref value }
if name == "bundle_uri_presign_ttl" && value == "abc"
),
"expected InvalidFlagValue, got {err:?}",
);
}
#[test]
fn bundle_uri_presign_ttl_negative_rejected() {
let err = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=-1",
)
.unwrap_err();
assert!(
matches!(err, ParseError::InvalidFlagValue { ref name, .. } if name == "bundle_uri_presign_ttl"),
"expected InvalidFlagValue, got {err:?}",
);
}
#[test]
fn bundle_uri_presign_ttl_above_seven_days_rejected() {
let err = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=604801",
)
.unwrap_err();
assert!(
matches!(
err,
ParseError::BundleUriPresignTtlTooLarge { value, max }
if value == 604_801 && max == MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS
),
"expected BundleUriPresignTtlTooLarge {{ value: 604801, max: {MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS} }}, got {err:?}",
);
}
#[test]
fn bundle_uri_presign_ttl_huge_value_rejected_not_panic() {
let err = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=999999999999999999",
)
.unwrap_err();
assert!(
matches!(
err,
ParseError::BundleUriPresignTtlTooLarge { value, .. }
if value == 999_999_999_999_999_999
),
"expected BundleUriPresignTtlTooLarge for huge value, got {err:?}",
);
}
#[test]
fn bundle_uri_presign_ttl_exactly_seven_days_accepted() {
let url = parse(
"s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo\
?engine=packchain&bundle_uri=1&bundle_uri_presign_ttl=604800",
)
.unwrap();
assert_eq!(
url.flags().bundle_uri_presign_ttl,
Some(
NonZeroU64::new(MAX_BUNDLE_URI_PRESIGN_TTL_SECONDS).expect("7-day cap is non-zero")
),
);
}
#[test]
fn engine_flag_packchain_on_azure_url() {
let url =
parse("az+https://myaccount.blob.core.windows.net/my-container/repo?engine=packchain")
.unwrap();
assert_eq!(url.flags().engine, Some(StorageEngine::Packchain));
}
#[test]
fn engine_flag_on_azure_url() {
let url =
parse("az+https://myaccount.blob.core.windows.net/my-container/repo?engine=bundle")
.unwrap();
assert_eq!(url.flags().engine, Some(StorageEngine::Bundle));
}
#[test]
fn rejects_amazonaws_host_missing_s3_service_marker() {
let err = parse("s3+https://git-test-2224.us-west-2.amazonaws.com/git-remote-object-store")
.unwrap_err();
assert!(
matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "git-test-2224.us-west-2.amazonaws.com"),
"expected InvalidAwsS3Endpoint, got {err:?}",
);
}
#[test]
fn accepts_valid_aws_s3_hosts() {
parse("s3+https://my-bucket.s3.us-west-2.amazonaws.com/repo").unwrap();
parse("s3+https://my-bucket.s3.amazonaws.com/repo").unwrap();
parse("s3+https://my-bucket.s3-us-west-2.amazonaws.com/repo").unwrap();
parse("s3+https://s3.us-west-2.amazonaws.com/my-bucket/repo").unwrap();
parse("s3+https://s3.amazonaws.com/my-bucket/repo").unwrap();
parse("s3+https://s3-us-east-1.amazonaws.com/my-bucket/repo").unwrap();
parse("s3+https://my-bucket.s3.cn-north-1.amazonaws.com.cn/repo").unwrap();
parse("s3+https://s3.cn-north-1.amazonaws.com.cn/my-bucket/repo").unwrap();
}
#[test]
fn rejects_china_amazonaws_host_missing_s3_service_marker() {
let err = parse("s3+https://git-test.cn-north-1.amazonaws.com.cn/repo").unwrap_err();
assert!(
matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "git-test.cn-north-1.amazonaws.com.cn"),
"expected InvalidAwsS3Endpoint, got {err:?}",
);
}
#[test]
fn check_aws_s3_host_runs_before_addressing_override() {
let err =
parse("s3+https://corp.amazonaws.com/my-bucket/repo?addressing=path").unwrap_err();
assert!(
matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "corp.amazonaws.com"),
"expected InvalidAwsS3Endpoint, got {err:?}",
);
let err =
parse("s3+https://corp.amazonaws.com/my-bucket/repo?addressing=virtual").unwrap_err();
assert!(
matches!(err, ParseError::InvalidAwsS3Endpoint { ref host } if host == "corp.amazonaws.com"),
"expected InvalidAwsS3Endpoint, got {err:?}",
);
}
#[test]
fn accepts_s3_prefix_known_false_negative() {
parse("s3+https://s3-mybucket.amazonaws.com/my-bucket/repo").unwrap();
}
#[test]
fn accepts_non_aws_s3_compatible_hosts() {
parse("s3+https://play.min.io/my-bucket/repo").unwrap();
parse("s3+https://acc.r2.cloudflarestorage.com/my-bucket/repo").unwrap();
parse("s3+https://localhost/my-bucket/repo?zip=0").unwrap();
}
#[test]
fn parse_bool_value_accepts_truthy_tokens() {
for v in ["1", "true", "yes", "on"] {
assert_eq!(parse_bool_value(v), Some(true), "expected true for `{v}`");
}
}
#[test]
fn parse_bool_value_accepts_falsy_tokens() {
for v in ["0", "false", "no", "off"] {
assert_eq!(parse_bool_value(v), Some(false), "expected false for `{v}`");
}
}
#[test]
fn parse_bool_value_is_case_insensitive() {
for (input, expected) in [
("TRUE", true),
("True", true),
("tRuE", true),
("YES", true),
("Yes", true),
("ON", true),
("On", true),
("FALSE", false),
("False", false),
("NO", false),
("No", false),
("OFF", false),
("Off", false),
] {
assert_eq!(
parse_bool_value(input),
Some(expected),
"expected {expected} for `{input}`",
);
}
}
#[test]
fn parse_bool_value_rejects_unknown_tokens() {
for v in [
"", " ", "yep", "nope", "2", "-1", "truee", "y", "n", "enabled",
] {
assert_eq!(parse_bool_value(v), None, "expected None for `{v}`");
}
}
#[test]
fn parse_bool_flag_propagates_invalid_flag_value_error() {
let err = parse_bool_flag("zip", "maybe").unwrap_err();
assert!(
matches!(&err, ParseError::InvalidFlagValue { name, value }
if name == "zip" && value == "maybe"),
"expected InvalidFlagValue(zip, maybe), got {err:?}",
);
}
#[test]
fn url_bool_flags_accept_mixed_case_and_extended_vocabulary() {
for v in ["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"] {
let url = parse(&format!("s3+https://localhost/my-bucket/repo?zip={v}")).unwrap();
assert!(url.flags().zip, "expected zip=true for `{v}`");
}
for v in ["0", "false", "False", "FALSE", "no", "No", "off", "OFF"] {
let url = parse(&format!("s3+https://localhost/my-bucket/repo?zip={v}")).unwrap();
assert!(!url.flags().zip, "expected zip=false for `{v}`");
}
}
#[test]
fn url_bool_flags_reject_unknown_value_with_flag_name() {
let err = parse("s3+https://localhost/my-bucket/repo?zip=maybe").unwrap_err();
assert!(
matches!(&err, ParseError::InvalidFlagValue { name, value }
if name == "zip" && value == "maybe"),
"expected InvalidFlagValue(zip, maybe), got {err:?}",
);
let err = parse("s3+https://localhost/my-bucket/repo?bundle_uri=2").unwrap_err();
assert!(
matches!(&err, ParseError::InvalidFlagValue { name, value }
if name == "bundle_uri" && value == "2"),
"expected InvalidFlagValue(bundle_uri, 2), got {err:?}",
);
}
}