use std::collections::BTreeMap;
pub const BUCKET_OPTIONS_ENV: &str = "EARTHSCI_S3_BUCKET_OPTIONS";
pub const S3_SIGNING_KEYS: [&str; 2] = ["aws_skip_signature", "skip_signature"];
pub const S3_CREDENTIAL_KEYS: [&str; 16] = [
"aws_access_key_id",
"access_key_id",
"aws_secret_access_key",
"secret_access_key",
"aws_session_token",
"session_token",
"aws_token",
"token",
"aws_container_credentials_relative_uri",
"container_credentials_relative_uri",
"aws_container_credentials_full_uri",
"container_credentials_full_uri",
"aws_web_identity_token_file",
"web_identity_token_file",
"aws_role_arn",
"role_arn",
];
pub const S3_STATIC_KEY_KEYS: [&str; 2] = ["aws_access_key_id", "access_key_id"];
pub const S3_ENDPOINT_KEYS: [&str; 5] = [
"endpoint",
"endpoint_url",
"aws_endpoint",
"aws_endpoint_url",
"aws_endpoint_url_s3",
];
pub const S3_REGION_KEYS: [&str; 2] = ["aws_region", "region"];
#[must_use]
pub fn has_any(options: &[(String, String)], keys: &[&str]) -> bool {
options.iter().any(|(key, _)| keys.contains(&key.as_str()))
}
fn value_of<'a>(options: &'a [(String, String)], keys: &[&str]) -> Option<&'a str> {
options
.iter()
.find(|(k, _)| keys.contains(&k.as_str()))
.map(|(_, v)| v.as_str())
}
#[must_use]
pub fn bucket_of_url(url: &str) -> Option<String> {
let rest = url
.strip_prefix("s3://")
.or_else(|| url.strip_prefix("s3a://"))?;
let bucket = rest.split('/').next().unwrap_or("");
(!bucket.is_empty()).then(|| bucket.to_ascii_lowercase())
}
pub fn parse_bucket_options(spec: &str) -> Result<BTreeMap<String, Vec<(String, String)>>, String> {
if spec.trim().is_empty() {
return Ok(BTreeMap::new());
}
let root: serde_json::Value =
serde_json::from_str(spec).map_err(|e| format!("not valid JSON: {e}"))?;
let buckets = root
.as_object()
.ok_or_else(|| "expected a JSON object of {\"bucket\": {options}}".to_string())?;
let mut out = BTreeMap::new();
for (bucket, options) in buckets {
let bucket = bucket.trim().to_ascii_lowercase();
if bucket.is_empty() {
return Err("a bucket name is empty".to_string());
}
let options = options
.as_object()
.ok_or_else(|| format!("'{bucket}' does not map to a JSON object of options"))?;
let mut pairs = Vec::with_capacity(options.len());
for (key, value) in options {
let value = match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Bool(b) => b.to_string(),
serde_json::Value::Number(n) => n.to_string(),
other => {
return Err(format!(
"'{bucket}'.'{key}' is {}, and an option value must be a string, boolean or number",
kind_of(other)
))
}
};
pairs.push((key.trim().to_ascii_lowercase(), value));
}
out.insert(bucket, pairs);
}
Ok(out)
}
fn kind_of(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
}
}
pub fn bucket_options_from_env() -> Result<BTreeMap<String, Vec<(String, String)>>, String> {
match std::env::var(BUCKET_OPTIONS_ENV) {
Ok(spec) => parse_bucket_options(&spec),
Err(_) => Ok(BTreeMap::new()),
}
}
#[must_use]
pub fn options_for_bucket(bucket: &str) -> Vec<(String, String)> {
bucket_options_from_env()
.unwrap_or_default()
.remove(&bucket.to_ascii_lowercase())
.unwrap_or_default()
}
#[must_use]
pub fn options_for_url(url: &str) -> Vec<(String, String)> {
bucket_of_url(url)
.map(|b| options_for_bucket(&b))
.unwrap_or_default()
}
#[must_use]
pub fn stated_signing(options: &[(String, String)]) -> Option<bool> {
if let Some(value) = value_of(options, &S3_SIGNING_KEYS) {
return Some(!matches!(
value.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "on"
));
}
has_any(options, &S3_CREDENTIAL_KEYS).then_some(true)
}
#[must_use]
pub fn stated_region(options: &[(String, String)]) -> Option<&str> {
value_of(options, &S3_REGION_KEYS).filter(|r| !r.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn pairs(map: &BTreeMap<String, Vec<(String, String)>>, bucket: &str) -> Vec<(String, String)> {
map.get(bucket).cloned().unwrap_or_default()
}
#[test]
fn an_unset_or_blank_spec_is_no_buckets() {
assert!(parse_bucket_options("").unwrap().is_empty());
assert!(parse_bucket_options(" ").unwrap().is_empty());
assert!(parse_bucket_options("{}").unwrap().is_empty());
}
#[test]
fn a_bucket_carries_its_own_options() {
let map = parse_bucket_options(
r#"{"inmap-model":{"aws_request_payer":"true","aws_region":"us-east-2"}}"#,
)
.unwrap();
let opts = pairs(&map, "inmap-model");
assert_eq!(value_of(&opts, &["aws_request_payer"]), Some("true"));
assert_eq!(stated_region(&opts), Some("us-east-2"));
}
#[test]
fn a_tool_rendered_boolean_is_a_value_and_not_a_refusal() {
let map = parse_bucket_options(r#"{"b":{"aws_request_payer":true,"n":3}}"#).unwrap();
let opts = pairs(&map, "b");
assert_eq!(value_of(&opts, &["aws_request_payer"]), Some("true"));
assert_eq!(value_of(&opts, &["n"]), Some("3"));
}
#[test]
fn bucket_names_and_option_keys_are_folded_to_lowercase() {
let map = parse_bucket_options(r#"{"InMap-Model":{"AWS_Region":"us-east-2"}}"#).unwrap();
let opts = pairs(&map, "inmap-model");
assert_eq!(stated_region(&opts), Some("us-east-2"));
}
#[test]
fn a_malformed_spec_is_an_error_rather_than_an_empty_map() {
assert!(parse_bucket_options("not json").is_err());
assert!(parse_bucket_options(r#"["inmap-model"]"#).is_err());
assert!(parse_bucket_options(r#"{"inmap-model":"us-east-2"}"#).is_err());
assert!(parse_bucket_options(r#"{"inmap-model":{"k":null}}"#).is_err());
assert!(parse_bucket_options(r#"{"inmap-model":{"k":["a"]}}"#).is_err());
assert!(parse_bucket_options(r#"{"":{"k":"v"}}"#).is_err());
}
#[test]
fn the_bucket_of_a_url_is_its_host_and_nothing_else() {
assert_eq!(
bucket_of_url("s3://inmap-model/a/b"),
Some("inmap-model".into())
);
assert_eq!(
bucket_of_url("s3a://Inmap-Model/a"),
Some("inmap-model".into())
);
assert_eq!(
bucket_of_url("s3://inmap-model"),
Some("inmap-model".into())
);
assert_eq!(bucket_of_url("https://example.com/a"), None);
assert_eq!(bucket_of_url("s3:///a"), None);
}
#[test]
fn signing_is_stated_by_a_polarity_or_by_a_credential() {
let sign = [("aws_skip_signature".to_string(), "false".to_string())];
let anon = [("aws_skip_signature".to_string(), "true".to_string())];
let creds = [("aws_access_key_id".to_string(), "AKIA…".to_string())];
let region_only = [("aws_region".to_string(), "eu-west-1".to_string())];
assert_eq!(stated_signing(&sign), Some(true));
assert_eq!(stated_signing(&anon), Some(false));
assert_eq!(stated_signing(&creds), Some(true));
assert_eq!(stated_signing(®ion_only), None);
assert_eq!(stated_signing(&[]), None);
}
#[test]
fn a_truthy_skip_signature_is_not_read_as_its_opposite() {
for spelling in ["true", "TRUE", " True ", "1", "yes", "on"] {
let opts = [("aws_skip_signature".to_string(), spelling.to_string())];
assert_eq!(stated_signing(&opts), Some(false), "{spelling}");
}
}
}