use std::collections::BTreeSet;
use std::path::Path;
use super::{Conditional, FetchResult, HttpTransport, Transport};
use crate::auth::AuthResolver;
use crate::error::{Error, Result};
pub const DEFAULT_S3_REGION: &str = "us-east-2";
pub const SIGNED_BUCKETS_ENV: &str = "EARTHSCI_S3_SIGNED_BUCKETS";
fn signed_buckets_from_env() -> BTreeSet<String> {
std::env::var(SIGNED_BUCKETS_ENV)
.ok()
.map(|spec| parse_signed_buckets(&spec))
.unwrap_or_default()
}
fn parse_signed_buckets(spec: &str) -> BTreeSet<String> {
spec.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_ascii_lowercase)
.collect()
}
fn bucket_of(s3_url: &str) -> Result<&str> {
let rest = s3_url.strip_prefix("s3://").ok_or_else(|| Error::BadUrl {
url: s3_url.to_string(),
detail: "not an s3:// URL".to_string(),
})?;
let bucket = rest.split('/').next().unwrap_or("");
if bucket.is_empty() {
return Err(Error::BadUrl {
url: s3_url.to_string(),
detail: "s3:// URL has an empty bucket".to_string(),
});
}
Ok(bucket)
}
fn check_bucket_options() -> Result<()> {
crate::s3_config::bucket_options_from_env()
.map(|_| ())
.map_err(|detail| Error::BadConfig {
var: crate::s3_config::BUCKET_OPTIONS_ENV.to_string(),
detail,
})
}
pub fn resolve_region_for_bucket(bucket: &str, explicit: Option<&str>) -> String {
if let Some(r) = explicit {
return r.to_string();
}
let per_bucket = crate::s3_config::options_for_bucket(bucket);
if let Some(region) = crate::s3_config::stated_region(&per_bucket) {
return region.to_string();
}
resolve_region(None)
}
pub fn resolve_region(explicit: Option<&str>) -> String {
if let Some(r) = explicit {
return r.to_string();
}
for var in ["EARTHSCI_S3_REGION", "AWS_REGION"] {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
return v;
}
}
}
DEFAULT_S3_REGION.to_string()
}
pub fn s3_https_url(s3_url: &str, region: &str) -> Result<String> {
let bad = |detail: String| Error::BadUrl {
url: s3_url.to_string(),
detail,
};
let rest = s3_url
.strip_prefix("s3://")
.ok_or_else(|| bad("not an s3:// URL".to_string()))?;
let (bucket, key) = rest
.split_once('/')
.ok_or_else(|| bad("s3:// URL has no object key".to_string()))?;
if bucket.is_empty() {
return Err(bad("s3:// URL has an empty bucket".to_string()));
}
Ok(format!("https://{bucket}.s3.{region}.amazonaws.com/{key}"))
}
pub struct S3Transport {
http: HttpTransport,
region: Option<String>,
signed: BTreeSet<String>,
env_signing: bool,
}
impl S3Transport {
pub fn new() -> Self {
Self {
http: HttpTransport::new(),
region: None,
signed: signed_buckets_from_env(),
env_signing: true,
}
}
pub fn with_region(region: impl Into<String>) -> Self {
Self {
http: HttpTransport::new(),
region: Some(region.into()),
signed: signed_buckets_from_env(),
env_signing: true,
}
}
#[must_use]
pub fn signing_buckets<I, S>(mut self, buckets: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.signed = buckets
.into_iter()
.map(|b| b.as_ref().trim().to_ascii_lowercase())
.filter(|b| !b.is_empty())
.collect();
self.env_signing = false;
self
}
#[must_use]
pub fn signs(&self, bucket: &str) -> bool {
let bucket = bucket.to_ascii_lowercase();
if self.env_signing {
if let Some(stated) =
crate::s3_config::stated_signing(&crate::s3_config::options_for_bucket(&bucket))
{
return stated;
}
}
self.signed.contains(&bucket)
}
#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
fn fetch_signed(&self, url: &str, dest: &Path, conditional: &Conditional) -> Result<FetchResult> {
use std::io::Write;
use object_store::ObjectStoreExt;
use crate::format::{resolve_backend, runtime};
const CHUNK: u64 = 8 * 1024 * 1024;
let region = resolve_region_for_bucket(bucket_of(url)?, self.region.as_deref());
let options = crate::read_store_options(
url,
&[
("aws_skip_signature".to_string(), "false".to_string()),
("aws_region".to_string(), region),
],
);
let (store, path) = resolve_backend(url, &options)?;
let rt = runtime()?;
let transport_err = |e: object_store::Error| match e {
object_store::Error::NotFound { .. } => Error::NotFound {
url: url.to_string(),
detail: "signed s3 read: no such key".to_string(),
},
other => Error::Transport {
url: url.to_string(),
detail: format!("signed s3 read: {other}"),
},
};
let meta = rt.block_on(store.head(&path)).map_err(transport_err)?;
let unchanged = match (conditional.etag.as_deref(), meta.e_tag.as_deref()) {
(Some(have), Some(now)) => etag_eq(have, now),
_ => false,
};
if unchanged {
return Ok(FetchResult {
status: super::FetchStatus::NotModified,
etag: meta.e_tag,
last_modified: None,
bytes_written: 0,
});
}
let mut file = std::fs::File::create(dest)?;
let mut offset = 0u64;
while offset < meta.size {
let end = (offset + CHUNK).min(meta.size);
let bytes = rt
.block_on(store.get_range(&path, offset..end))
.map_err(transport_err)?;
file.write_all(&bytes)?;
offset = end;
}
file.flush()?;
Ok(FetchResult {
status: super::FetchStatus::Downloaded,
etag: meta.e_tag,
last_modified: None,
bytes_written: meta.size,
})
}
#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
fn fetch_signed(&self, url: &str, _d: &Path, _c: &Conditional) -> Result<FetchResult> {
Err(Error::Transport {
url: url.to_string(),
detail: format!(
"this bucket is named in {SIGNED_BUCKETS_ENV}, but signing needs the \
`object-store` feature, which this build does not have"
),
})
}
}
fn etag_eq(a: &str, b: &str) -> bool {
fn norm(s: &str) -> &str {
s.trim().trim_start_matches("W/").trim_matches('"')
}
norm(a) == norm(b)
}
impl Default for S3Transport {
fn default() -> Self {
Self::new()
}
}
impl Transport for S3Transport {
fn schemes(&self) -> &'static [&'static str] {
&["s3"]
}
fn fetch(
&self,
url: &str,
dest: &Path,
conditional: &Conditional,
auth: Option<&dyn AuthResolver>,
) -> Result<FetchResult> {
let bucket = bucket_of(url)?;
check_bucket_options()?;
if self.signs(bucket) {
return self.fetch_signed(url, dest, conditional);
}
let region = resolve_region_for_bucket(bucket, self.region.as_deref());
let https = s3_https_url(url, ®ion)?;
self.http.fetch(&https, dest, conditional, auth)
}
fn fetch_hashed(
&self,
url: &str,
dest: &Path,
conditional: &Conditional,
auth: Option<&dyn AuthResolver>,
) -> Result<(FetchResult, Option<String>)> {
let bucket = bucket_of(url)?;
check_bucket_options()?;
if self.signs(bucket) {
return Ok((self.fetch_signed(url, dest, conditional)?, None));
}
let region = resolve_region_for_bucket(bucket, self.region.as_deref());
let https = s3_https_url(url, ®ion)?;
self.http.fetch_hashed(&https, dest, conditional, auth)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_env::EnvScope;
#[test]
fn scheme_is_s3() {
assert_eq!(S3Transport::new().schemes(), &["s3"]);
}
#[test]
fn rewrite_default_and_explicit_region() {
assert_eq!(
s3_https_url(
"s3://inmap-model/isrm_v1.2.1.zarr/PrimaryPM25/0.5.0",
"us-east-2"
)
.unwrap(),
"https://inmap-model.s3.us-east-2.amazonaws.com/isrm_v1.2.1.zarr/PrimaryPM25/0.5.0"
);
assert_eq!(
s3_https_url("s3://b/k/o", "eu-west-1").unwrap(),
"https://b.s3.eu-west-1.amazonaws.com/k/o"
);
}
#[test]
fn rewrite_rejects_bad_urls() {
assert!(s3_https_url("https://x/y", "us-east-2").is_err());
assert!(s3_https_url("s3://bucket-only", "us-east-2").is_err());
assert!(s3_https_url("s3:///key", "us-east-2").is_err());
}
#[test]
fn nothing_is_signed_by_default() {
let t = S3Transport::new().signing_buckets(Vec::<String>::new());
assert!(!t.signs("inmap-model"));
assert!(!t.signs("anything-at-all"));
}
#[test]
fn only_the_named_buckets_are_signed() {
let t = S3Transport::new().signing_buckets(["my-private-bucket"]);
assert!(t.signs("my-private-bucket"));
assert!(!t.signs("inmap-model"), "a public bucket must not start signing");
assert!(!t.signs("my-private-bucket-2"), "prefixes are not matches");
}
#[test]
fn bucket_matching_folds_case() {
let t = S3Transport::new().signing_buckets(["My-Bucket"]);
assert!(t.signs("my-bucket"));
assert!(t.signs("MY-BUCKET"));
}
#[test]
fn an_explicit_list_replaces_the_environment() {
let t = S3Transport::new()
.signing_buckets(["from-env-maybe"])
.signing_buckets(["only-this-one"]);
assert!(t.signs("only-this-one"));
assert!(!t.signs("from-env-maybe"));
}
#[test]
fn a_named_bucket_states_its_own_signing() {
let _env = EnvScope::new(&[(
crate::BUCKET_OPTIONS_ENV,
r#"{"inmap-model":{"aws_skip_signature":"false"}}"#,
)]);
assert!(S3Transport::new().signs("inmap-model"));
assert!(!S3Transport::new().signs("some-other-bucket"));
}
#[test]
fn naming_a_bucket_for_its_region_does_not_start_signing_it() {
let _env = EnvScope::new(&[(
crate::BUCKET_OPTIONS_ENV,
r#"{"inmap-model":{"aws_region":"us-east-2"}}"#,
)]);
assert!(!S3Transport::new().signs("inmap-model"));
}
#[test]
fn a_bucket_can_state_anonymous_against_the_signed_list() {
let _env = EnvScope::new(&[
(SIGNED_BUCKETS_ENV, "inmap-model"),
(
crate::BUCKET_OPTIONS_ENV,
r#"{"inmap-model":{"aws_skip_signature":"true"}}"#,
),
]);
assert!(!S3Transport::new().signs("inmap-model"));
}
#[test]
fn an_explicit_list_also_replaces_the_options_maps_say() {
let _env = EnvScope::new(&[(
crate::BUCKET_OPTIONS_ENV,
r#"{"inmap-model":{"aws_skip_signature":"false"}}"#,
)]);
let t = S3Transport::new().signing_buckets(["only-this-one"]);
assert!(t.signs("only-this-one"));
assert!(!t.signs("inmap-model"));
}
#[test]
fn a_buckets_own_region_beats_the_process_wide_pin() {
let _env = EnvScope::new(&[
("EARTHSCI_S3_REGION", "eu-west-1"),
(
crate::BUCKET_OPTIONS_ENV,
r#"{"inmap-model":{"aws_region":"us-east-2"}}"#,
),
]);
assert_eq!(resolve_region_for_bucket("inmap-model", None), "us-east-2");
assert_eq!(resolve_region_for_bucket("other", None), "eu-west-1");
assert_eq!(
resolve_region_for_bucket("inmap-model", Some("ap-south-1")),
"ap-south-1",
"an argument passed in code is the most local statement of all"
);
}
#[test]
fn a_malformed_bucket_options_spec_refuses_the_fetch() {
let _env = EnvScope::new(&[(crate::BUCKET_OPTIONS_ENV, "{not json")]);
let dest = std::env::temp_dir().join("earthsciio-bucket-options-test");
let err = S3Transport::new()
.fetch("s3://inmap-model/k", &dest, &Conditional::default(), None)
.unwrap_err();
match err {
Error::BadConfig { var, .. } => assert_eq!(var, crate::BUCKET_OPTIONS_ENV),
other => panic!("expected BadConfig, got {other}"),
}
assert!(!dest.exists(), "a refused fetch writes nothing");
}
#[test]
fn the_env_list_drops_blanks_and_is_never_wild() {
let parsed = parse_signed_buckets(" a , ,b,, ");
assert_eq!(parsed.len(), 2);
assert!(parsed.contains("a") && parsed.contains("b"));
let star = parse_signed_buckets("*");
assert!(star.contains("*"));
assert!(!S3Transport::new().signing_buckets(["*"]).signs("inmap-model"));
}
#[test]
fn bucket_of_reads_the_host() {
assert_eq!(bucket_of("s3://b/k/o").unwrap(), "b");
assert_eq!(bucket_of("s3://b/").unwrap(), "b");
assert!(bucket_of("s3://").is_err());
assert!(bucket_of("https://b/k").is_err());
}
#[test]
fn a_keyless_url_fails_the_same_way_signed_or_not() {
assert!(s3_https_url("s3://bucket-only", "us-east-2").is_err());
assert_eq!(bucket_of("s3://bucket-only").unwrap(), "bucket-only");
}
#[test]
fn etags_compare_through_quotes_and_weak_markers() {
assert!(etag_eq("\"abc\"", "abc"));
assert!(etag_eq("W/\"abc\"", "\"abc\""));
assert!(!etag_eq("\"abc\"", "\"abd\""));
}
}