use anyhow::{Context, Result};
use aws_sdk_s3::Client;
use aws_sdk_s3::operation::create_bucket::CreateBucketOutput;
use aws_sdk_s3::operation::delete_bucket::DeleteBucketOutput;
use aws_sdk_s3::operation::delete_bucket_policy::DeleteBucketPolicyOutput;
use aws_sdk_s3::operation::delete_bucket_tagging::DeleteBucketTaggingOutput;
use aws_sdk_s3::operation::delete_object::DeleteObjectOutput;
use aws_sdk_s3::operation::delete_object_tagging::DeleteObjectTaggingOutput;
use aws_sdk_s3::operation::get_bucket_policy::GetBucketPolicyOutput;
use aws_sdk_s3::operation::get_bucket_tagging::GetBucketTaggingOutput;
use aws_sdk_s3::operation::get_bucket_versioning::GetBucketVersioningOutput;
use aws_sdk_s3::operation::get_object_tagging::GetObjectTaggingOutput;
use aws_sdk_s3::operation::head_bucket::HeadBucketOutput;
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
use aws_sdk_s3::operation::put_bucket_policy::PutBucketPolicyOutput;
use aws_sdk_s3::operation::put_bucket_tagging::PutBucketTaggingOutput;
use aws_sdk_s3::operation::put_bucket_versioning::PutBucketVersioningOutput;
use aws_sdk_s3::operation::put_object_tagging::PutObjectTaggingOutput;
use aws_sdk_s3::types::{
BucketInfo, BucketLocationConstraint, BucketType, BucketVersioningStatus, ChecksumMode,
CreateBucketConfiguration, DataRedundancy, LocationInfo, LocationType, Tagging,
VersioningConfiguration,
};
#[derive(Debug, thiserror::Error)]
pub enum HeadError {
#[error("bucket does not exist")]
BucketNotFound,
#[error("target does not exist")]
NotFound,
#[error(transparent)]
Other(#[from] anyhow::Error),
}
const GET_OBJECT_TAGGING_NOT_FOUND_CODES: &[&str] = &["NoSuchKey", "NoSuchVersion"];
const GET_BUCKET_POLICY_NOT_FOUND_CODES: &[&str] = &["NoSuchBucketPolicy"];
const GET_BUCKET_TAGGING_NOT_FOUND_CODES: &[&str] = &["NoSuchTagSet"];
const GET_BUCKET_VERSIONING_NOT_FOUND_CODES: &[&str] = &[];
pub struct HeadObjectOpts {
pub version_id: Option<String>,
pub sse_c: Option<String>,
pub sse_c_key: Option<String>,
pub sse_c_key_md5: Option<String>,
pub enable_additional_checksum: bool,
}
pub async fn head_object(
client: &Client,
bucket: &str,
key: &str,
opts: HeadObjectOpts,
) -> Result<HeadObjectOutput, HeadError> {
let mut req = client.head_object().bucket(bucket).key(key);
if let Some(vid) = opts.version_id {
req = req.version_id(vid);
}
if let Some(alg) = opts.sse_c {
req = req.sse_customer_algorithm(alg);
}
if let Some(k) = opts.sse_c_key {
req = req.sse_customer_key(k);
}
if let Some(md5) = opts.sse_c_key_md5 {
req = req.sse_customer_key_md5(md5);
}
if opts.enable_additional_checksum {
req = req.checksum_mode(ChecksumMode::Enabled);
}
req.send().await.map_err(|e| {
if e.as_service_error()
.map(|s| s.is_not_found())
.unwrap_or(false)
{
HeadError::NotFound
} else {
HeadError::Other(
anyhow::Error::new(e).context(format!("head-object on s3://{bucket}/{key}")),
)
}
})
}
pub async fn delete_object(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
) -> Result<DeleteObjectOutput> {
let mut req = client.delete_object().bucket(bucket).key(key);
if let Some(v) = version_id {
req = req.version_id(v);
}
req.send()
.await
.with_context(|| format!("rm s3://{bucket}/{key}"))
}
pub async fn get_object_tagging(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
) -> Result<GetObjectTaggingOutput, HeadError> {
let mut req = client.get_object_tagging().bucket(bucket).key(key);
if let Some(v) = version_id {
req = req.version_id(v);
}
req.send().await.map_err(|e| {
let code = e
.as_service_error()
.and_then(aws_smithy_types::error::metadata::ProvideErrorMetadata::code);
match classify_not_found(code, GET_OBJECT_TAGGING_NOT_FOUND_CODES) {
Some(he) => he,
None => HeadError::Other(
anyhow::Error::new(e).context(format!("get-object-tagging on s3://{bucket}/{key}")),
),
}
})
}
fn matches_not_found_code(code: Option<&str>, candidates: &[&str]) -> bool {
code.is_some_and(|c| candidates.contains(&c))
}
fn classify_not_found(code: Option<&str>, subresource_codes: &[&str]) -> Option<HeadError> {
if code == Some("NoSuchBucket") {
return Some(HeadError::BucketNotFound);
}
if matches_not_found_code(code, subresource_codes) {
return Some(HeadError::NotFound);
}
None
}
pub async fn put_object_tagging(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
tagging: Tagging,
) -> Result<PutObjectTaggingOutput> {
let mut req = client
.put_object_tagging()
.bucket(bucket)
.key(key)
.tagging(tagging);
if let Some(v) = version_id {
req = req.version_id(v);
}
req.send()
.await
.with_context(|| format!("put-object-tagging on s3://{bucket}/{key}"))
}
pub async fn delete_object_tagging(
client: &Client,
bucket: &str,
key: &str,
version_id: Option<&str>,
) -> Result<DeleteObjectTaggingOutput> {
let mut req = client.delete_object_tagging().bucket(bucket).key(key);
if let Some(v) = version_id {
req = req.version_id(v);
}
req.send()
.await
.with_context(|| format!("delete-object-tagging on s3://{bucket}/{key}"))
}
pub async fn head_bucket(client: &Client, bucket: &str) -> Result<HeadBucketOutput, HeadError> {
client
.head_bucket()
.bucket(bucket)
.send()
.await
.map_err(|e| {
if e.as_service_error()
.map(|s| s.is_not_found())
.unwrap_or(false)
{
HeadError::BucketNotFound
} else {
HeadError::Other(
anyhow::Error::new(e).context(format!("head-bucket on s3://{bucket}")),
)
}
})
}
pub async fn create_bucket(client: &Client, bucket: &str) -> Result<CreateBucketOutput> {
let mut req = client.create_bucket().bucket(bucket);
if let Some(loc) = parse_directory_bucket_zone(bucket) {
let location = LocationInfo::builder()
.r#type(loc.location_type)
.name(loc.zone_id)
.build();
let bucket_info = BucketInfo::builder()
.r#type(BucketType::Directory)
.data_redundancy(loc.data_redundancy)
.build();
let cfg = CreateBucketConfiguration::builder()
.location(location)
.bucket(bucket_info)
.build();
req = req.create_bucket_configuration(cfg);
} else if let Some(region) = client.config().region().map(|r| r.as_ref())
&& !region.is_empty()
&& region != "us-east-1"
{
let constraint = BucketLocationConstraint::from(region);
let cfg = CreateBucketConfiguration::builder()
.location_constraint(constraint)
.build();
req = req.create_bucket_configuration(cfg);
}
req.send()
.await
.with_context(|| format!("create-bucket on s3://{bucket}"))
}
struct DirectoryBucketZone {
zone_id: String,
location_type: LocationType,
data_redundancy: DataRedundancy,
}
fn parse_directory_bucket_zone(bucket: &str) -> Option<DirectoryBucketZone> {
let stripped = bucket.strip_suffix(super::EXPRESS_ONEZONE_STORAGE_SUFFIX)?;
let (_, zone_id) = stripped.rsplit_once("--")?;
if zone_id.is_empty() {
return None;
}
let (location_type, data_redundancy) = if zone_id.matches('-').count() <= 1 {
(
LocationType::AvailabilityZone,
DataRedundancy::SingleAvailabilityZone,
)
} else {
(LocationType::LocalZone, DataRedundancy::SingleLocalZone)
};
Some(DirectoryBucketZone {
zone_id: zone_id.to_string(),
location_type,
data_redundancy,
})
}
pub async fn delete_bucket(client: &Client, bucket: &str) -> Result<DeleteBucketOutput> {
client
.delete_bucket()
.bucket(bucket)
.send()
.await
.with_context(|| format!("delete-bucket on s3://{bucket}"))
}
pub async fn put_bucket_tagging(
client: &Client,
bucket: &str,
tagging: Tagging,
) -> Result<PutBucketTaggingOutput> {
client
.put_bucket_tagging()
.bucket(bucket)
.tagging(tagging)
.send()
.await
.with_context(|| format!("put-bucket-tagging on s3://{bucket}"))
}
pub async fn get_bucket_tagging(
client: &Client,
bucket: &str,
) -> Result<GetBucketTaggingOutput, HeadError> {
client
.get_bucket_tagging()
.bucket(bucket)
.send()
.await
.map_err(|e| {
let code = e
.as_service_error()
.and_then(aws_smithy_types::error::metadata::ProvideErrorMetadata::code);
match classify_not_found(code, GET_BUCKET_TAGGING_NOT_FOUND_CODES) {
Some(he) => he,
None => HeadError::Other(
anyhow::Error::new(e).context(format!("get-bucket-tagging on s3://{bucket}")),
),
}
})
}
pub async fn delete_bucket_tagging(
client: &Client,
bucket: &str,
) -> Result<DeleteBucketTaggingOutput> {
client
.delete_bucket_tagging()
.bucket(bucket)
.send()
.await
.with_context(|| format!("delete-bucket-tagging on s3://{bucket}"))
}
pub async fn put_bucket_versioning(
client: &Client,
bucket: &str,
status: BucketVersioningStatus,
) -> Result<PutBucketVersioningOutput> {
let versioning_config = VersioningConfiguration::builder().status(status).build();
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(versioning_config)
.send()
.await
.with_context(|| format!("put-bucket-versioning on s3://{bucket}"))
}
pub async fn get_bucket_versioning(
client: &Client,
bucket: &str,
) -> Result<GetBucketVersioningOutput, HeadError> {
client
.get_bucket_versioning()
.bucket(bucket)
.send()
.await
.map_err(|e| {
let code = e
.as_service_error()
.and_then(aws_smithy_types::error::metadata::ProvideErrorMetadata::code);
match classify_not_found(code, GET_BUCKET_VERSIONING_NOT_FOUND_CODES) {
Some(he) => he,
None => HeadError::Other(
anyhow::Error::new(e)
.context(format!("get-bucket-versioning on s3://{bucket}")),
),
}
})
}
pub async fn put_bucket_policy(
client: &Client,
bucket: &str,
policy: &str,
) -> Result<PutBucketPolicyOutput> {
client
.put_bucket_policy()
.bucket(bucket)
.policy(policy)
.send()
.await
.with_context(|| format!("put-bucket-policy on s3://{bucket}"))
}
pub async fn get_bucket_policy(
client: &Client,
bucket: &str,
) -> Result<GetBucketPolicyOutput, HeadError> {
client
.get_bucket_policy()
.bucket(bucket)
.send()
.await
.map_err(|e| {
let code = e
.as_service_error()
.and_then(aws_smithy_types::error::metadata::ProvideErrorMetadata::code);
match classify_not_found(code, GET_BUCKET_POLICY_NOT_FOUND_CODES) {
Some(he) => he,
None => HeadError::Other(
anyhow::Error::new(e).context(format!("get-bucket-policy on s3://{bucket}")),
),
}
})
}
pub async fn delete_bucket_policy(
client: &Client,
bucket: &str,
) -> Result<DeleteBucketPolicyOutput> {
client
.delete_bucket_policy()
.bucket(bucket)
.send()
.await
.with_context(|| format!("delete-bucket-policy on s3://{bucket}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_not_found_code_returns_false_for_none() {
assert!(!matches_not_found_code(None, &["NoSuchKey"]));
}
#[test]
fn matches_not_found_code_returns_false_for_empty_candidates() {
assert!(!matches_not_found_code(Some("NoSuchKey"), &[]));
}
#[test]
fn matches_not_found_code_returns_false_for_unrelated_code() {
assert!(!matches_not_found_code(
Some("AccessDenied"),
&["NoSuchKey", "NoSuchBucket"]
));
}
#[test]
fn matches_not_found_code_returns_true_for_matching_code() {
assert!(matches_not_found_code(
Some("NoSuchKey"),
&["NoSuchKey", "NoSuchBucket"]
));
assert!(matches_not_found_code(
Some("NoSuchBucket"),
&["NoSuchKey", "NoSuchBucket"]
));
}
#[test]
fn matches_not_found_code_is_case_sensitive() {
assert!(!matches_not_found_code(
Some("nosuchkey"),
&["NoSuchKey", "NoSuchBucket"]
));
}
#[test]
fn get_object_tagging_not_found_codes_pinned() {
assert_eq!(
GET_OBJECT_TAGGING_NOT_FOUND_CODES,
&["NoSuchKey", "NoSuchVersion"]
);
}
#[test]
fn get_bucket_policy_not_found_codes_pinned() {
assert_eq!(GET_BUCKET_POLICY_NOT_FOUND_CODES, &["NoSuchBucketPolicy"]);
}
#[test]
fn get_bucket_tagging_not_found_codes_pinned() {
assert_eq!(GET_BUCKET_TAGGING_NOT_FOUND_CODES, &["NoSuchTagSet"]);
}
#[test]
fn get_bucket_versioning_not_found_codes_pinned() {
let empty: &[&str] = &[];
assert_eq!(GET_BUCKET_VERSIONING_NOT_FOUND_CODES, empty);
}
#[test]
fn classify_not_found_routes_no_such_bucket_to_bucket_not_found() {
let got = classify_not_found(Some("NoSuchBucket"), &["NoSuchTagSet"]);
assert!(matches!(got, Some(HeadError::BucketNotFound)));
}
#[test]
fn classify_not_found_routes_subresource_code_to_not_found() {
let got = classify_not_found(Some("NoSuchTagSet"), &["NoSuchTagSet"]);
assert!(matches!(got, Some(HeadError::NotFound)));
}
#[test]
fn classify_not_found_returns_none_for_unrelated_code() {
assert!(classify_not_found(Some("AccessDenied"), &["NoSuchTagSet"]).is_none());
assert!(classify_not_found(None, &["NoSuchTagSet"]).is_none());
}
#[test]
fn classify_not_found_no_such_bucket_takes_priority_over_subresource_list() {
let got = classify_not_found(Some("NoSuchBucket"), &["NoSuchBucket", "NoSuchTagSet"]);
assert!(matches!(got, Some(HeadError::BucketNotFound)));
}
#[test]
fn parse_directory_bucket_zone_returns_none_for_general_purpose_name() {
assert!(parse_directory_bucket_zone("my-bucket").is_none());
assert!(parse_directory_bucket_zone("my-bucket--with-dashes").is_none());
}
#[test]
fn parse_directory_bucket_zone_parses_availability_zone_id() {
let z = parse_directory_bucket_zone("test-s3rm-e2e-0e1932b0b372--apne1-az4--x-s3")
.expect("expected directory-bucket parse");
assert_eq!(z.zone_id, "apne1-az4");
assert_eq!(z.location_type, LocationType::AvailabilityZone);
assert_eq!(z.data_redundancy, DataRedundancy::SingleAvailabilityZone);
}
#[test]
fn parse_directory_bucket_zone_parses_local_zone_id() {
let z = parse_directory_bucket_zone("mybucket--usw2-lax1-az1--x-s3")
.expect("expected directory-bucket parse");
assert_eq!(z.zone_id, "usw2-lax1-az1");
assert_eq!(z.location_type, LocationType::LocalZone);
assert_eq!(z.data_redundancy, DataRedundancy::SingleLocalZone);
}
#[test]
fn parse_directory_bucket_zone_handles_base_with_embedded_double_dash() {
let z = parse_directory_bucket_zone("my--weird--base--apne1-az4--x-s3")
.expect("expected directory-bucket parse");
assert_eq!(z.zone_id, "apne1-az4");
}
#[test]
fn parse_directory_bucket_zone_rejects_missing_zone_segment() {
assert!(parse_directory_bucket_zone("bucket--x-s3").is_none());
assert!(parse_directory_bucket_zone("--x-s3").is_none());
}
#[test]
fn parse_directory_bucket_zone_rejects_empty_zone_id() {
assert!(parse_directory_bucket_zone("base----x-s3").is_none());
}
}