#![allow(unused_imports)]
mod common;
use bucketwarden_lock::{LockError, ObjectLock, RetentionMode};
use bucketwarden_s3::{
sigv4::{authorization_header, presigned_url_query, sha256_hex, AwsCredentials, SigV4Request},
*,
};
use bucketwarden_server::*;
use common::*;
use std::collections::BTreeMap;
fn percent_encode_key_segment(value: &str) -> String {
value
.bytes()
.map(|byte| {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
(byte as char).to_string()
} else {
format!("%{byte:02X}")
}
})
.collect()
}
const UTF8_IMAGE_KEY: &str = "\u{30C6}\u{30B9}\u{30C8}\u{753B}\u{50CF}.png";
#[test]
fn http_header_contract_handles_request_response_conditionals_checksums_and_sse() {
let mut runtime = runtime();
runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-001"))
.expect("create");
let body = b"header contract body".to_vec();
let content_md5 = base64_encode(&md5_digest(&body));
let checksum_sha256 = base64_encode(&hex_to_bytes(&sha256_hex(&body)).expect("sha256 bytes"));
let put = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-001/headers.txt")
.with_header("content-type", "text/plain")
.with_header("content-md5", content_md5.clone())
.with_header("x-amz-checksum-sha256", checksum_sha256.clone())
.with_header("x-amz-meta-owner", "records-team")
.with_header("x-amz-server-side-encryption", "AES256")
.with_body(body.clone()),
)
.expect("put");
assert_eq!(put.status, 200);
assert_eq!(put.headers.get("content-md5"), Some(&content_md5));
assert_eq!(
put.headers.get("x-amz-checksum-sha256"),
Some(&checksum_sha256)
);
assert_eq!(
put.headers.get("x-amz-server-side-encryption"),
Some(&"AES256".to_string())
);
let etag = put.headers.get("etag").expect("etag").clone();
let get = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/archive-001/headers.txt")
.with_header("range", "bytes=0-5")
.with_query("response-content-type", "application/json")
.with_query("response-cache-control", "no-cache")
.with_query("response-content-disposition", "attachment")
.with_query("response-content-language", "en")
.with_query("response-content-encoding", "identity")
.with_query("response-expires", "Wed, 21 Oct 2026 07:28:00 GMT"),
)
.expect("get");
assert_eq!(get.status, 206);
assert_eq!(get.body, b"header");
assert_eq!(
get.headers.get("content-range"),
Some(&format!("bytes 0-5/{}", body.len()))
);
assert_eq!(
get.headers.get("content-type"),
Some(&"application/json".to_string())
);
assert_eq!(
get.headers.get("cache-control"),
Some(&"no-cache".to_string())
);
assert_eq!(
get.headers.get("content-disposition"),
Some(&"attachment".to_string())
);
assert_eq!(get.headers.get("content-language"), Some(&"en".to_string()));
assert_eq!(
get.headers.get("content-encoding"),
Some(&"identity".to_string())
);
assert_eq!(
get.headers.get("expires"),
Some(&"Wed, 21 Oct 2026 07:28:00 GMT".to_string())
);
assert_eq!(
get.headers.get("x-amz-meta-owner"),
Some(&"records-team".to_string())
);
assert_eq!(
get.headers.get("x-amz-server-side-encryption"),
Some(&"AES256".to_string())
);
let head = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "HEAD", "/archive-001/headers.txt")
.with_query("response-content-type", "text/csv"),
)
.expect("head");
assert_eq!(head.status, 200);
assert!(head.body.is_empty());
assert_eq!(
head.headers.get("content-type"),
Some(&"text/csv".to_string())
);
assert_eq!(
head.headers.get("x-amz-meta-owner"),
Some(&"records-team".to_string())
);
let not_modified = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/archive-001/headers.txt")
.with_header("if-none-match", etag),
)
.expect("not modified");
assert_eq!(not_modified.status, 304);
let bad_digest = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-001/bad-headers.txt")
.with_header("content-md5", content_md5)
.with_body(b"different".to_vec()),
)
.expect("bad digest");
assert_eq!(bad_digest.status, 400);
assert!(String::from_utf8(bad_digest.body)
.expect("xml")
.contains("<Code>BadDigest</Code>"));
}
#[test]
fn http_addressing_resolves_path_style_and_virtual_host_style_targets() {
let mut runtime = runtime();
let path_bucket = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-path")
.with_header("host", "s3.bucketwarden.test"),
)
.expect("path-style bucket");
assert_eq!(path_bucket.status, 200);
let path_put = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-path/path.txt")
.with_header("host", "s3.bucketwarden.test")
.with_body(b"path-style".to_vec()),
)
.expect("path-style put");
assert_eq!(path_put.status, 200);
let path_get = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/archive-path/path.txt")
.with_header("host", "s3.bucketwarden.test"),
)
.expect("path-style get");
assert_eq!(path_get.body, b"path-style");
let virtual_bucket = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/")
.with_header("host", "archive-vhost.s3.bucketwarden.test"),
)
.expect("virtual bucket");
assert_eq!(virtual_bucket.status, 200);
let virtual_put = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/records/vhost.txt")
.with_header("host", "archive-vhost.s3.bucketwarden.test")
.with_body(b"virtual-host".to_vec()),
)
.expect("virtual put");
assert_eq!(virtual_put.status, 200);
let virtual_head = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "HEAD", "/records/vhost.txt")
.with_header("host", "archive-vhost.s3.bucketwarden.test"),
)
.expect("virtual head");
assert_eq!(virtual_head.status, 200);
assert_eq!(
virtual_head.headers.get("content-length"),
Some(&"12".to_string())
);
let virtual_list = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", "/")
.with_header("host", "archive-vhost.s3.bucketwarden.test")
.with_query("list-type", "2"),
)
.expect("virtual list");
let list_xml = String::from_utf8(virtual_list.body).expect("xml");
assert!(list_xml.contains("<Name>archive-vhost</Name>"));
assert!(list_xml.contains("<Key>records/vhost.txt</Key>"));
let invalid_bucket = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/")
.with_header("host", "-bad.s3.bucketwarden.test"),
)
.expect("invalid virtual bucket");
assert_eq!(invalid_bucket.status, 400);
assert!(String::from_utf8(invalid_bucket.body)
.expect("xml")
.contains("<Code>InvalidBucketName</Code>"));
}
#[test]
fn http_sigv4_region_mismatch_redirects_to_bucket_region() {
const HOST: &str = "s3.bucketwarden.test";
const AMZ_DATE: &str = "20130524T000000Z";
const SCOPE_DATE: &str = "20130524";
let credentials =
AwsCredentials::new("AKIDEXAMPLE", "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY");
let mut runtime = runtime();
runtime
.register_sigv4_credentials(
"alice",
credentials.access_key_id.clone(),
credentials.secret_access_key.clone(),
)
.expect("register credentials");
runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-west")
.with_body(b"<CreateBucketConfiguration><LocationConstraint>us-west-2</LocationConstraint></CreateBucketConfiguration>".to_vec()),
)
.expect("west bucket");
runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-east")
.with_body(b"<CreateBucketConfiguration><LocationConstraint>us-east-2</LocationConstraint></CreateBucketConfiguration>".to_vec()),
)
.expect("east bucket");
runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-west/region.txt")
.with_body(b"west".to_vec()),
)
.expect("west put");
runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/archive-east/region.txt")
.with_body(b"east".to_vec()),
)
.expect("east put");
let west = signed_get_request(
"/archive-west/region.txt",
HOST,
"us-west-2",
&credentials,
AMZ_DATE,
SCOPE_DATE,
);
let west = runtime.handle_s3_http(west).expect("west signed");
assert_eq!(west.status, 200);
assert_eq!(west.body, b"west");
let east_wrong_region = signed_get_request(
"/archive-east/region.txt",
HOST,
"us-west-2",
&credentials,
AMZ_DATE,
SCOPE_DATE,
);
let east_wrong_region = runtime
.handle_s3_http(east_wrong_region)
.expect("east wrong region");
assert_eq!(east_wrong_region.status, 301);
assert_eq!(
east_wrong_region.headers.get("x-amz-bucket-region"),
Some(&"us-east-2".to_string())
);
assert_eq!(
east_wrong_region.headers.get("x-bucketwarden-error-family"),
Some(&"general".to_string())
);
assert!(String::from_utf8(east_wrong_region.body)
.expect("xml")
.contains("<Code>PermanentRedirect</Code>"));
let east = signed_get_request(
"/archive-east/region.txt",
HOST,
"us-east-2",
&credentials,
AMZ_DATE,
SCOPE_DATE,
);
let east = runtime.handle_s3_http(east).expect("east signed");
assert_eq!(east.status, 200);
assert_eq!(east.body, b"east");
}
#[test]
fn http_copy_object_supports_non_ascii_keys() {
let mut runtime = runtime();
runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", "/archive-utf8"))
.expect("bucket");
let src_object_key = format!("src/{UTF8_IMAGE_KEY}");
let dst_object_key = format!("dst/{UTF8_IMAGE_KEY}");
let src_key = format!(
"/archive-utf8/{}",
percent_encode_key_segment(&src_object_key)
);
let dst_key = format!(
"/archive-utf8/{}",
percent_encode_key_segment(&dst_object_key)
);
runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", &src_key).with_body(b"png".to_vec()))
.expect("put");
let copied = runtime
.handle_s3_http(S3HttpRequest::new("alice", "PUT", &dst_key).with_header(
"x-amz-copy-source",
&format!(
"/archive-utf8/{}",
percent_encode_key_segment(&src_object_key)
),
))
.expect("copy");
assert_eq!(copied.status, 200);
let fetched = runtime
.handle_s3_http(S3HttpRequest::new("alice", "GET", &dst_key))
.expect("get");
assert_eq!(fetched.body, b"png");
}
#[test]
fn http_virtual_host_style_supports_percent_encoded_utf8_keys() {
let mut runtime = runtime();
runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", "/")
.with_header("host", "archive-vhost-utf8.s3.bucketwarden.test"),
)
.expect("bucket");
let object_key = format!("records/{UTF8_IMAGE_KEY}");
let encoded_key = percent_encode_key_segment(&object_key);
let put = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "PUT", &format!("/{encoded_key}"))
.with_header("host", "archive-vhost-utf8.s3.bucketwarden.test")
.with_body(b"utf8".to_vec()),
)
.expect("put");
assert_eq!(put.status, 200);
let get = runtime
.handle_s3_http(
S3HttpRequest::new("alice", "GET", &format!("/{encoded_key}"))
.with_header("host", "archive-vhost-utf8.s3.bucketwarden.test"),
)
.expect("get");
assert_eq!(get.status, 200);
assert_eq!(get.body, b"utf8");
}