Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions crates/fakecloud-e2e/tests/s3.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,130 @@ async fn s3_create_list_delete_bucket() {
assert!(resp.buckets().is_empty());
}

#[tokio::test]
async fn s3_list_buckets_returns_region() {
let server = TestServer::start().await;
let client = server.s3_client().await;

client
.create_bucket()
.bucket("region-test")
.send()
.await
.unwrap();

let resp = client.list_buckets().send().await.unwrap();
let entry = resp
.buckets()
.iter()
.find(|b| b.name() == Some("region-test"))
.expect("bucket present");
assert_eq!(entry.bucket_region(), Some("us-east-1"));
}

#[tokio::test]
async fn s3_list_buckets_pagination() {
let server = TestServer::start().await;
let client = server.s3_client().await;

for n in &["page-a", "page-b", "page-c"] {
client.create_bucket().bucket(*n).send().await.unwrap();
}

let page1 = client
.list_buckets()
.max_buckets(2)
.prefix("page-")
.send()
.await
.unwrap();
let names1: Vec<&str> = page1
.buckets()
.iter()
.map(|b| b.name().unwrap_or_default())
.collect();
assert_eq!(names1, vec!["page-a", "page-b"]);
let token = page1
.continuation_token()
.expect("continuation token present");

let page2 = client
.list_buckets()
.max_buckets(2)
.prefix("page-")
.continuation_token(token)
.send()
.await
.unwrap();
let names2: Vec<&str> = page2
.buckets()
.iter()
.map(|b| b.name().unwrap_or_default())
.collect();
assert_eq!(names2, vec!["page-c"]);
assert!(page2.continuation_token().is_none());
}

#[tokio::test]
async fn s3_list_buckets_prefix_filter() {
let server = TestServer::start().await;
let client = server.s3_client().await;

for n in &["alpha-1", "alpha-2", "beta"] {
client.create_bucket().bucket(*n).send().await.unwrap();
}

let resp = client.list_buckets().prefix("alpha-").send().await.unwrap();
let names: Vec<&str> = resp
.buckets()
.iter()
.map(|b| b.name().unwrap_or_default())
.collect();
assert!(names.contains(&"alpha-1"));
assert!(names.contains(&"alpha-2"));
assert!(!names.contains(&"beta"));
assert_eq!(resp.prefix(), Some("alpha-"));
}

#[tokio::test]
async fn s3_list_buckets_region_filter() {
use aws_sdk_s3::types::{BucketLocationConstraint, CreateBucketConfiguration};

let server = TestServer::start().await;
let client = server.s3_client().await;

client
.create_bucket()
.bucket("east-only")
.send()
.await
.unwrap();
client
.create_bucket()
.bucket("west-only")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::UsWest2)
.build(),
)
.send()
.await
.unwrap();

let resp = client
.list_buckets()
.bucket_region("us-west-2")
.send()
.await
.unwrap();
let names: Vec<&str> = resp
.buckets()
.iter()
.map(|b| b.name().unwrap_or_default())
.collect();
assert_eq!(names, vec!["west-only"]);
}

#[tokio::test]
async fn s3_head_bucket() {
let server = TestServer::start().await;
Expand Down
102 changes: 97 additions & 5 deletions crates/fakecloud-s3/src/service/buckets.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
use http::{HeaderMap, StatusCode};

use bytes::Bytes;
Expand All @@ -15,26 +17,116 @@ impl S3Service {
pub(super) fn list_buckets(
&self,
account_id: &str,
_req: &AwsRequest,
req: &AwsRequest,
) -> Result<AwsResponse, AwsServiceError> {
let prefix = req.query_params.get("prefix").cloned();
let bucket_region_filter = req.query_params.get("bucket-region").cloned();

let max_buckets: usize = match req.query_params.get("max-buckets") {
Some(v) => match v.parse::<i64>() {
Ok(n) if (1..=10_000).contains(&n) => n as usize,
_ => {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidArgument",
"max-buckets must be between 1 and 10000",
));
}
},
None => 10_000,
};

let continuation_token = req.query_params.get("continuation-token").cloned();
let token_after: Option<String> = match continuation_token.as_deref() {
None => None,
Some("") => {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidArgument",
"The continuation token provided is incorrect",
));
}
Some(tok) => match BASE64
.decode(tok.as_bytes())
.ok()
.and_then(|d| String::from_utf8(d).ok())
{
Some(s) => Some(s),
None => {
return Err(AwsServiceError::aws_error(
StatusCode::BAD_REQUEST,
"InvalidArgument",
"The continuation token provided is incorrect",
));
}
},
};

let accts = self.state.read();
let __empty = crate::state::S3State::new(account_id, "us-east-1");
let state = accts.get(account_id).unwrap_or(&__empty);

let mut filtered: Vec<&S3Bucket> = state
.buckets
.values()
.filter(|b| {
if let Some(p) = &prefix {
if !b.name.starts_with(p) {
return false;
}
}
if let Some(r) = &bucket_region_filter {
if &b.region != r {
return false;
}
}
true
})
.collect();
filtered.sort_by(|a, b| a.name.cmp(&b.name));

let start_index = match &token_after {
Some(after) => filtered.partition_point(|b| b.name.as_str() <= after.as_str()),
None => 0,
};
let end_index = (start_index + max_buckets).min(filtered.len());
let page = &filtered[start_index..end_index];
let next_continuation = if end_index < filtered.len() {
page.last().map(|b| BASE64.encode(b.name.as_bytes()))
} else {
None
};

let mut buckets_xml = String::new();
let mut sorted: Vec<_> = state.buckets.values().collect();
sorted.sort_by_key(|b| &b.name);
for b in sorted {
for b in page {
buckets_xml.push_str(&format!(
"<Bucket><Name>{}</Name><CreationDate>{}</CreationDate></Bucket>",
"<Bucket><Name>{}</Name><CreationDate>{}</CreationDate><BucketRegion>{}</BucketRegion></Bucket>",
xml_escape(&b.name),
b.creation_date.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
xml_escape(&b.region),
));
}

let mut tail_xml = String::new();
if let Some(p) = &prefix {
tail_xml.push_str(&format!("<Prefix>{}</Prefix>", xml_escape(p)));
}
if let Some(r) = &bucket_region_filter {
tail_xml.push_str(&format!("<BucketRegion>{}</BucketRegion>", xml_escape(r)));
}
if let Some(nct) = &next_continuation {
tail_xml.push_str(&format!(
"<ContinuationToken>{}</ContinuationToken>",
xml_escape(nct),
));
}

let body = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
<ListAllMyBucketsResult xmlns=\"http://s3.amazonaws.com/doc/2006-03-01/\">\
<Owner><ID>{account}</ID><DisplayName>{account}</DisplayName></Owner>\
<Buckets>{buckets_xml}</Buckets>\
{tail_xml}\
</ListAllMyBucketsResult>",
account = account_id,
);
Expand Down
Loading
Loading