bucketwarden-server 0.1.0

BucketWarden storage server runtime.
Documentation
#![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");
}