Skip to content

Commit 5d3234d

Browse files
committed
Preserve headers in multipart stream uploads
1 parent b584ce7 commit 5d3234d

1 file changed

Lines changed: 220 additions & 3 deletions

File tree

s3/src/bucket.rs

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,7 +1670,7 @@ impl Bucket {
16701670
}
16711671

16721672
let msg = self
1673-
.initiate_multipart_upload(s3_path, content_type)
1673+
.initiate_multipart_upload_with_headers(s3_path, content_type, custom_headers)
16741674
.await?;
16751675
let path = msg.key;
16761676
let upload_id = &msg.upload_id;
@@ -1866,8 +1866,55 @@ impl Bucket {
18661866
s3_path: &str,
18671867
content_type: &str,
18681868
) -> Result<InitiateMultipartUploadResponse, S3Error> {
1869+
self.initiate_multipart_upload_with_headers(s3_path, content_type, None)
1870+
.await
1871+
}
1872+
1873+
/// Initiate a multipart upload with headers that configure the completed object.
1874+
///
1875+
/// Headers such as object metadata, cache control, storage class, and server-side
1876+
/// encryption using S3-managed or KMS keys must be supplied when the multipart upload is
1877+
/// initiated.
1878+
///
1879+
/// # Example
1880+
///
1881+
/// ```no_run
1882+
/// use anyhow::Result;
1883+
/// use http::{HeaderMap, HeaderValue};
1884+
/// use s3::{Bucket, creds::Credentials};
1885+
///
1886+
/// # #[tokio::main]
1887+
/// # async fn main() -> Result<()> {
1888+
/// let bucket = Bucket::new("my-bucket", "us-east-1".parse()?, Credentials::default()?)?;
1889+
/// let mut headers = HeaderMap::new();
1890+
/// headers.insert(
1891+
/// "x-amz-meta-uploaded-by",
1892+
/// HeaderValue::from_static("multipart-client"),
1893+
/// );
1894+
///
1895+
/// bucket
1896+
/// .initiate_multipart_upload_with_headers(
1897+
/// "/large-file.zip",
1898+
/// "application/zip",
1899+
/// Some(headers),
1900+
/// )
1901+
/// .await?;
1902+
/// # Ok(())
1903+
/// # }
1904+
/// ```
1905+
#[maybe_async::async_impl]
1906+
pub async fn initiate_multipart_upload_with_headers(
1907+
&self,
1908+
s3_path: &str,
1909+
content_type: &str,
1910+
custom_headers: Option<HeaderMap>,
1911+
) -> Result<InitiateMultipartUploadResponse, S3Error> {
1912+
let mut request_bucket = self.clone();
1913+
if let Some(custom_headers) = custom_headers {
1914+
request_bucket.extra_headers.extend(custom_headers);
1915+
}
18691916
let command = Command::InitiateMultipartUpload { content_type };
1870-
let request = RequestImpl::new(self, s3_path, command).await?;
1917+
let request = RequestImpl::new(&request_bucket, s3_path, command).await?;
18711918
let response_data = request.response_data(false).await?;
18721919
if response_data.status_code() >= 300 {
18731920
return Err(error_from_response_data(response_data)?);
@@ -1884,8 +1931,27 @@ impl Bucket {
18841931
s3_path: &str,
18851932
content_type: &str,
18861933
) -> Result<InitiateMultipartUploadResponse, S3Error> {
1934+
self.initiate_multipart_upload_with_headers(s3_path, content_type, None)
1935+
}
1936+
1937+
/// Initiate a multipart upload with headers that configure the completed object.
1938+
///
1939+
/// Headers such as object metadata, cache control, storage class, and server-side
1940+
/// encryption using S3-managed or KMS keys must be supplied when the multipart upload is
1941+
/// initiated.
1942+
#[maybe_async::sync_impl]
1943+
pub fn initiate_multipart_upload_with_headers(
1944+
&self,
1945+
s3_path: &str,
1946+
content_type: &str,
1947+
custom_headers: Option<HeaderMap>,
1948+
) -> Result<InitiateMultipartUploadResponse, S3Error> {
1949+
let mut request_bucket = self.clone();
1950+
if let Some(custom_headers) = custom_headers {
1951+
request_bucket.extra_headers.extend(custom_headers);
1952+
}
18871953
let command = Command::InitiateMultipartUpload { content_type };
1888-
let request = RequestImpl::new(self, s3_path, command)?;
1954+
let request = RequestImpl::new(&request_bucket, s3_path, command)?;
18891955
let response_data = request.response_data(false)?;
18901956
if response_data.status_code() >= 300 {
18911957
return Err(error_from_response_data(response_data)?);
@@ -3187,6 +3253,92 @@ mod test {
31873253
assert_eq!(requests.load(Ordering::SeqCst), 1);
31883254
}
31893255

3256+
#[cfg(all(not(feature = "sync"), feature = "with-tokio"))]
3257+
#[tokio::test]
3258+
async fn multipart_initiation_with_headers_sends_and_signs_object_configuration() {
3259+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
3260+
let endpoint = format!("http://{}", listener.local_addr().unwrap());
3261+
3262+
let server = thread::spawn(move || {
3263+
let (mut stream, _) = listener.accept().unwrap();
3264+
let mut request_bytes = Vec::new();
3265+
let mut buffer = [0; 4096];
3266+
3267+
loop {
3268+
let bytes_read = stream.read(&mut buffer).unwrap();
3269+
assert!(bytes_read > 0, "connection closed before headers arrived");
3270+
request_bytes.extend_from_slice(&buffer[..bytes_read]);
3271+
if request_bytes.windows(4).any(|window| window == b"\r\n\r\n") {
3272+
break;
3273+
}
3274+
}
3275+
3276+
let request = String::from_utf8(request_bytes)
3277+
.unwrap()
3278+
.to_ascii_lowercase();
3279+
assert!(request.starts_with("post /test-bucket/artifact?uploads http/1.1\r\n"));
3280+
assert!(request.contains("\r\nx-amz-meta-artifact-tag: synthetic-signature\r\n"));
3281+
assert!(request.contains("\r\ncache-control: max-age=60\r\n"));
3282+
assert!(request.contains("\r\nx-amz-server-side-encryption: aes256\r\n"));
3283+
3284+
let authorization = request
3285+
.lines()
3286+
.find(|line| line.starts_with("authorization:"))
3287+
.unwrap();
3288+
assert!(authorization.contains("cache-control"));
3289+
assert!(authorization.contains("x-amz-meta-artifact-tag"));
3290+
assert!(authorization.contains("x-amz-server-side-encryption"));
3291+
3292+
let body = "<InitiateMultipartUploadResult><Bucket>test-bucket</Bucket><Key>artifact</Key><UploadId>synthetic-upload-id</UploadId></InitiateMultipartUploadResult>";
3293+
let response = format!(
3294+
"HTTP/1.1 200 OK\r\nContent-Type: application/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3295+
body.len()
3296+
);
3297+
stream.write_all(response.as_bytes()).unwrap();
3298+
});
3299+
3300+
let credentials = Credentials::new(
3301+
Some("test_access_key"),
3302+
Some("test_secret_key"),
3303+
None,
3304+
None,
3305+
None,
3306+
)
3307+
.unwrap();
3308+
let bucket = Bucket::new(
3309+
"test-bucket",
3310+
Region::Custom {
3311+
region: "us-east-1".to_owned(),
3312+
endpoint,
3313+
},
3314+
credentials,
3315+
)
3316+
.unwrap()
3317+
.with_path_style();
3318+
let mut custom_headers = HeaderMap::new();
3319+
custom_headers.insert(
3320+
HeaderName::from_static("x-amz-meta-artifact-tag"),
3321+
HeaderValue::from_static("synthetic-signature"),
3322+
);
3323+
custom_headers.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=60"));
3324+
custom_headers.insert(
3325+
HeaderName::from_static("x-amz-server-side-encryption"),
3326+
HeaderValue::from_static("AES256"),
3327+
);
3328+
3329+
let initiated = bucket
3330+
.initiate_multipart_upload_with_headers(
3331+
"/artifact",
3332+
"application/octet-stream",
3333+
Some(custom_headers),
3334+
)
3335+
.await
3336+
.unwrap();
3337+
3338+
server.join().unwrap();
3339+
assert_eq!(initiated.upload_id, "synthetic-upload-id");
3340+
}
3341+
31903342
#[test]
31913343
#[cfg(any(feature = "tokio-native-tls", feature = "tokio-rustls-tls"))]
31923344
#[allow(deprecated)]
@@ -3595,6 +3747,71 @@ mod test {
35953747
streaming_test_put_get_delete_big_object(*test_minio_bucket()).await;
35963748
}
35973749

3750+
#[ignore]
3751+
#[cfg(all(not(feature = "sync"), feature = "with-tokio"))]
3752+
#[tokio::test]
3753+
async fn streaming_minio_preserves_metadata_at_multipart_boundary_tokio() {
3754+
streaming_minio_preserves_metadata_at_multipart_boundary().await;
3755+
}
3756+
3757+
#[ignore]
3758+
#[cfg(all(not(feature = "sync"), feature = "with-async-std"))]
3759+
#[async_std::test]
3760+
async fn streaming_minio_preserves_metadata_at_multipart_boundary_async_std() {
3761+
streaming_minio_preserves_metadata_at_multipart_boundary().await;
3762+
}
3763+
3764+
#[cfg(all(
3765+
not(feature = "sync"),
3766+
any(feature = "with-tokio", feature = "with-async-std")
3767+
))]
3768+
async fn streaming_minio_preserves_metadata_at_multipart_boundary() {
3769+
let bucket = test_minio_bucket();
3770+
let artifact_tag = "synthetic-signature";
3771+
let sizes = [
3772+
crate::bucket::CHUNK_SIZE - 1,
3773+
crate::bucket::CHUNK_SIZE,
3774+
crate::bucket::CHUNK_SIZE + 326_431,
3775+
];
3776+
3777+
for size in sizes {
3778+
let remote_path = format!("+stream_metadata_{}_{}", size, uuid::Uuid::new_v4());
3779+
let content = vec![33; size];
3780+
#[cfg(feature = "with-tokio")]
3781+
let mut reader = std::io::Cursor::new(&content);
3782+
#[cfg(feature = "with-async-std")]
3783+
let mut reader = async_std::io::Cursor::new(&content);
3784+
3785+
let response = bucket
3786+
.put_object_stream_builder(&remote_path)
3787+
.with_metadata("artifact-tag", artifact_tag)
3788+
.unwrap()
3789+
.execute_stream(&mut reader)
3790+
.await
3791+
.unwrap();
3792+
3793+
assert_eq!(response.status_code(), 200);
3794+
assert_eq!(response.uploaded_bytes(), size);
3795+
3796+
let (head, status) = bucket.head_object(&remote_path).await.unwrap();
3797+
assert_eq!(status, 200);
3798+
assert_eq!(
3799+
head.metadata
3800+
.as_ref()
3801+
.and_then(|metadata| metadata.get("artifact-tag"))
3802+
.map(String::as_str),
3803+
Some(artifact_tag)
3804+
);
3805+
3806+
let downloaded = bucket.get_object(&remote_path).await.unwrap();
3807+
assert_eq!(downloaded.status_code(), 200);
3808+
assert_eq!(downloaded.as_slice(), content);
3809+
3810+
let deleted = bucket.delete_object(&remote_path).await.unwrap();
3811+
assert_eq!(deleted.status_code(), 204);
3812+
}
3813+
}
3814+
35983815
#[ignore]
35993816
#[maybe_async::test(
36003817
feature = "sync",

0 commit comments

Comments
 (0)