diff --git a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java index a3d396fc736f..f8b83e7d46d1 100644 --- a/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java +++ b/hadoop-ozone/integration-test-s3/src/test/java/org/apache/hadoop/ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java @@ -48,6 +48,8 @@ import java.nio.file.Path; import java.security.MessageDigest; import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -2890,6 +2892,110 @@ private void verifyBucketOwnershipVerificationAccessDenied(Executable function) } } + static Stream standardObjectHeaderContentEncodingCases() { + return Stream.of( + Arguments.of("gzip", "gzip"), + Arguments.of("deflate, gzip", "deflate, gzip"), + Arguments.of("gzip, aws-chunked", "gzip"), + Arguments.of("aws-chunked, gzip", "gzip"), + Arguments.of("aws-chunked", null), + Arguments.of("aws-chunked, aws-chunked", null)); + } + + /** + * ceph s3-tests coverage for standard object headers persisted on PUT and returned on HEAD/GET. + */ + @Nested + class StandardObjectHeaderTests { + + private static final String CONTENT = "bar"; + private static final String CACHE_CONTROL = "public, max-age=14400"; + + /** + * s3-tests: test_object_write_cache_control. + */ + @Test + public void testObjectWriteCacheControl() { + final String bucketName = getBucketName("cache-control"); + final String keyName = getKeyName("cache-control"); + s3Client.createBucket(b -> b.bucket(bucketName)); + + s3Client.putObject(b -> b.bucket(bucketName).key(keyName).cacheControl(CACHE_CONTROL), + RequestBody.fromString(CONTENT)); + + HeadObjectResponse head = s3Client.headObject(b -> b.bucket(bucketName).key(keyName)); + assertEquals(CACHE_CONTROL, head.cacheControl()); + + GetObjectResponse getObject = s3Client.getObject(b -> b.bucket(bucketName).key(keyName)).response(); + assertEquals(CACHE_CONTROL, getObject.cacheControl()); + } + + /** + * s3-tests: test_object_write_expires. + */ + @Test + public void testObjectWriteExpires() { + final String bucketName = getBucketName("expires"); + final String keyName = getKeyName("expires"); + final Instant expires = Instant.now().plusSeconds(6000).truncatedTo(ChronoUnit.SECONDS); + s3Client.createBucket(b -> b.bucket(bucketName)); + + s3Client.putObject(b -> b.bucket(bucketName).key(keyName).expires(expires), + RequestBody.fromString(CONTENT)); + + HeadObjectResponse head = s3Client.headObject(b -> b.bucket(bucketName).key(keyName)); + assertEquals(expires, head.expires()); + + GetObjectResponse getObject = s3Client.getObject(b -> b.bucket(bucketName).key(keyName)).response(); + assertEquals(expires, getObject.expires()); + } + + /** + * s3-tests: test_object_content_encoding_aws_chunked. + */ + @ParameterizedTest + @MethodSource("org.apache.hadoop.ozone.s3.awssdk.v2.AbstractS3SDKV2Tests#standardObjectHeaderContentEncodingCases") + public void testObjectContentEncodingAwsChunked(String requestEncoding, + String expectedEncoding) { + final String bucketName = getBucketName("content-encoding"); + final String keyName = getKeyName("encoding"); + s3Client.createBucket(b -> b.bucket(bucketName)); + + s3Client.putObject(b -> b.bucket(bucketName).key(keyName) + .contentEncoding(requestEncoding), + RequestBody.fromString(CONTENT)); + + HeadObjectResponse head = s3Client.headObject(b -> b.bucket(bucketName).key(keyName)); + assertEquals(expectedEncoding, head.contentEncoding()); + + GetObjectResponse getObject = s3Client.getObject(b -> b.bucket(bucketName).key(keyName)).response(); + assertEquals(expectedEncoding, getObject.contentEncoding()); + } + + @Test + public void testObjectWriteContentLanguageAndDisposition() { + final String bucketName = getBucketName("lang-disp"); + final String keyName = getKeyName("lang-disp"); + final String language = "en-CA"; + final String disposition = "attachment; filename=\"test.txt\""; + s3Client.createBucket(b -> b.bucket(bucketName)); + + s3Client.putObject(b -> b.bucket(bucketName).key(keyName) + .contentLanguage(language) + .contentDisposition(disposition), + RequestBody.fromString(CONTENT)); + + HeadObjectResponse head = s3Client.headObject(b -> b.bucket(bucketName).key(keyName)); + assertEquals(language, head.contentLanguage()); + assertEquals(disposition, head.contentDisposition()); + + GetObjectResponse getObject = + s3Client.getObject(b -> b.bucket(bucketName).key(keyName)).response(); + assertEquals(language, getObject.contentLanguage()); + assertEquals(disposition, getObject.contentDisposition()); + } + } + /** * Integration tests for ListBuckets (GET / ListAllMyBuckets). */ diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java index 452303dc4c03..29bf5550c329 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/EndpointBase.java @@ -120,15 +120,26 @@ public abstract class EndpointBase { protected static final String ETAG_CUSTOM = "etag-custom"; protected static final String CONTENT_TYPE_CUSTOM = "content-type-custom"; + protected static final String CACHE_CONTROL_CUSTOM = "cache-control-custom"; + protected static final String EXPIRES_CUSTOM = "expires-custom"; + protected static final String CONTENT_ENCODING_CUSTOM = "content-encoding-custom"; + protected static final String CONTENT_LANGUAGE_CUSTOM = "content-language-custom"; + protected static final String CONTENT_DISPOSITION_CUSTOM = "content-disposition-custom"; // System metadata key -> custom key. A user x-amz-meta-{etag,content-type} // collides with the system ETag / Content-Type stored under the same key, so // it is remapped on write and rebuilt on read; the system value is returned // via its own ETag / Content-Type response header. private static final Map RESERVED_METADATA_KEYS = - ImmutableMap.of( - ETAG, ETAG_CUSTOM, - HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_CUSTOM); + ImmutableMap.builder() + .put(ETAG, ETAG_CUSTOM) + .put(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_CUSTOM) + .put(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_CUSTOM) + .put(HttpHeaders.EXPIRES, EXPIRES_CUSTOM) + .put(HttpHeaders.CONTENT_ENCODING, CONTENT_ENCODING_CUSTOM) + .put(HttpHeaders.CONTENT_LANGUAGE, CONTENT_LANGUAGE_CUSTOM) + .put(HttpHeaders.CONTENT_DISPOSITION, CONTENT_DISPOSITION_CUSTOM) + .build(); // Custom key -> lower-cased header, to rebuild remapped user metadata on read. private static final Map REBUILT_RESERVED_KEYS = diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java index f943042a0b71..253cf2761f2f 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/endpoint/ObjectEndpoint.java @@ -43,6 +43,7 @@ import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER; import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_COUNT_HEADER; import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_DIRECTIVE_HEADER; +import static org.apache.hadoop.ozone.s3.util.S3Utils.normalizeContentEncoding; import static org.apache.hadoop.ozone.s3.util.S3Utils.stripQuotes; import static org.apache.hadoop.ozone.s3.util.S3Utils.validateSignatureHeader; import static org.apache.hadoop.ozone.s3.util.S3Utils.wrapInQuotes; @@ -272,6 +273,7 @@ Response handlePutRequest(ObjectRequestContext context, String keyPath, InputStr Map customMetadata = getCustomMetadataFromHeaders(getHeaders().getRequestHeaders()); putContentType(customMetadata); + putStandardObjectHeaders(customMetadata); Map tags = getTaggingFromHeaders(getHeaders()); long putLength; @@ -475,14 +477,8 @@ Response handleGetRequest(ObjectRequestContext context, String keyPath) responseBuilder.header(HttpHeaders.CONTENT_TYPE, contentType); for (Map.Entry entry : overrideQueryParameter.entrySet()) { - String headerValue = getHeaders().getHeaderString(entry.getKey()); - String queryValue = queryParams.getFirst(entry.getValue()); - if (queryValue != null) { - headerValue = queryValue; - } - if (headerValue != null) { - responseBuilder.header(entry.getKey(), headerValue); - } + addStoredObjectHeader(responseBuilder, keyDetails, queryParams, entry.getKey(), + entry.getValue()); } addLastModifiedDate(responseBuilder, keyDetails); @@ -522,6 +518,46 @@ private void putContentType(Map metadata) { } } + /** + * Store standard S3 object headers from the PUT request in key metadata. + */ + private void putStandardObjectHeaders(Map metadata) { + for (Map.Entry entry : overrideQueryParameter.entrySet()) { + String headerName = entry.getKey(); + String value = getHeaders().getHeaderString(headerName); + if (HttpHeaders.CONTENT_ENCODING.equals(headerName)) { + value = normalizeContentEncoding(value); + } + if (value != null) { + metadata.put(headerName, value); + } + } + } + + private void addStoredObjectHeader(ResponseBuilder responseBuilder, OzoneKey key, + MultivaluedMap queryParams, String headerName, + String responseOverrideParam) { + String headerValue = queryParams.getFirst(responseOverrideParam); + if (headerValue == null) { + headerValue = key.getMetadata().get(headerName); + } + if (HttpHeaders.CONTENT_ENCODING.equals(headerName)) { + headerValue = normalizeContentEncoding(headerValue); + } + if (headerValue != null) { + responseBuilder.header(headerName, headerValue); + } + } + + private void addStoredObjectHeaders(ResponseBuilder responseBuilder, OzoneKey key) { + MultivaluedMap queryParams = + getContext().getUriInfo().getQueryParameters(); + for (Map.Entry entry : overrideQueryParameter.entrySet()) { + addStoredObjectHeader(responseBuilder, key, queryParams, entry.getKey(), + entry.getValue()); + } + } + /** Returns the object's stored Content-Type, or the default if absent. */ private static String contentTypeOf(OzoneKey key) { String contentType = key.getMetadata().get(HttpHeaders.CONTENT_TYPE); @@ -606,6 +642,7 @@ public Response head( .header(HttpHeaders.CONTENT_TYPE, contentTypeOf(key)) .header(STORAGE_CLASS_HEADER, s3StorageType.toString()); addEntityTagHeader(response, key); + addStoredObjectHeaders(response, key); addLastModifiedDate(response, key); addTagCountIfAny(response, key); @@ -719,6 +756,7 @@ public Response initializeMultipartUpload( Map customMetadata = getCustomMetadataFromHeaders(getHeaders().getRequestHeaders()); putContentType(customMetadata); + putStandardObjectHeaders(customMetadata); Map tags = getTaggingFromHeaders(getHeaders()); @@ -1143,8 +1181,9 @@ private CopyObjectResponse copyObject(OzoneVolume volume, } else if (metadataCopyDirective.equals(CopyDirective.REPLACE.name())) { // Replace the metadata with the metadata form the request headers customMetadata = getCustomMetadataFromHeaders(getHeaders().getRequestHeaders()); - // REPLACE: Content-Type comes from the request, not the source. + // REPLACE: Content-Type and standard object headers come from the request. putContentType(customMetadata); + putStandardObjectHeaders(customMetadata); } else { OS3Exception ex = newError(INVALID_ARGUMENT, metadataCopyDirective); ex.setErrorMessage("An error occurred (InvalidArgument) " + diff --git a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Utils.java b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Utils.java index 4f9fe6c2a277..4a6aea05226b 100644 --- a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Utils.java +++ b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Utils.java @@ -36,6 +36,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.Objects; +import java.util.stream.Collectors; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; @@ -73,6 +74,22 @@ public static String s3urlEncode(String str) return urlEncode(str).replace("+", "%20"); } + /** + * Returns the persisted S3 {@code Content-Encoding} value, with {@code aws-chunked} + * removed per AWS semantics. + */ + public static String normalizeContentEncoding(String contentEncoding) { + if (contentEncoding == null || contentEncoding.isEmpty()) { + return null; + } + String normalized = Arrays.stream(contentEncoding.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .filter(value -> !AWS_CHUNKED.equals(value)) + .collect(Collectors.joining(", ")); + return normalized.isEmpty() ? null : normalized; + } + private S3Utils() { // no instances } diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectGet.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectGet.java index 54d28c08b8b3..0be5fb7b1fb9 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectGet.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectGet.java @@ -212,12 +212,12 @@ public void getKeyWithTag() throws IOException, OS3Exception { } @Test - public void inheritRequestHeader() throws IOException, OS3Exception { + public void storedObjectHeadersOnGetAndHead() throws IOException, OS3Exception { setDefaultHeader(); + assertSucceeds(() -> put(rest, BUCKET_NAME, KEY_NAME, CONTENT)); + clearRequestHeaderMocks(); Response response = get(rest, BUCKET_NAME, KEY_NAME); - - // Content-Type is not inherited from the request; key1 has none stored. assertEquals("binary/octet-stream", response.getHeaderString("Content-Type")); assertEquals(CONTENT_LANGUAGE1, @@ -230,11 +230,16 @@ public void inheritRequestHeader() throws IOException, OS3Exception { response.getHeaderString("Content-Disposition")); assertEquals(CONTENT_ENCODING1, response.getHeaderString("Content-Encoding")); + + assertEquals(CONTENT_ENCODING1, + rest.head(BUCKET_NAME, KEY_NAME).getHeaderString("Content-Encoding")); } @Test public void overrideResponseHeader() throws IOException, OS3Exception { setDefaultHeader(); + assertSucceeds(() -> put(rest, BUCKET_NAME, KEY_NAME, CONTENT)); + clearRequestHeaderMocks(); MultivaluedMap queryParameter = rest.getContext().getUriInfo().getQueryParameters(); // overrider request header @@ -364,6 +369,15 @@ private void setDefaultHeader() { .when(headers).getHeaderString("Content-Encoding"); } + private void clearRequestHeaderMocks() { + doReturn(null).when(headers).getHeaderString("Content-Type"); + doReturn(null).when(headers).getHeaderString("Content-Language"); + doReturn(null).when(headers).getHeaderString("Expires"); + doReturn(null).when(headers).getHeaderString("Cache-Control"); + doReturn(null).when(headers).getHeaderString("Content-Disposition"); + doReturn(null).when(headers).getHeaderString("Content-Encoding"); + } + @Test public void testGetWhenKeyIsDirectoryAndDoesNotEndWithASlash() throws IOException { diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java index 158d856f7d4d..4833c670d60e 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectHead.java @@ -19,10 +19,17 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.hadoop.ozone.s3.S3GatewayConfigKeys.OZONE_S3G_FSO_DIRECTORY_CREATION_ENABLED; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointBase.CACHE_CONTROL_CUSTOM; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointBase.CONTENT_DISPOSITION_CUSTOM; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointBase.CONTENT_ENCODING_CUSTOM; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointBase.CONTENT_LANGUAGE_CUSTOM; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointBase.CONTENT_TYPE_CUSTOM; +import static org.apache.hadoop.ozone.s3.endpoint.EndpointBase.EXPIRES_CUSTOM; import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertErrorResponse; import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertStatus; import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.assertSucceeds; import static org.apache.hadoop.ozone.s3.endpoint.EndpointTestUtils.put; +import static org.apache.hadoop.ozone.s3.endpoint.TestObjectGet.EXPIRES1; import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.PRECOND_FAILED; import static org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX; import static org.apache.hadoop.ozone.s3.util.S3Consts.IF_MATCH_HEADER; @@ -42,6 +49,7 @@ import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.stream.Stream; import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.MultivaluedHashMap; import javax.ws.rs.core.MultivaluedMap; @@ -58,6 +66,9 @@ import org.apache.http.HttpStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; /** * Test head object. @@ -235,35 +246,43 @@ public void testHeadObjectIncludesTagCount() assertEquals("2", response.getHeaderString(TAG_COUNT_HEADER)); } - @Test - public void testHeadSeparatesUserContentTypeMetadataFromObjectContentType() + @ParameterizedTest + @MethodSource("reservedMetadataCollisionCases") + public void testHeadSeparatesUserMetadataFromSystemHeader( + String headerName, String customKey, String systemValue, String userValue) throws Exception { - String keyName = "typed-with-user-meta"; - String objectContentType = "image/jpeg"; - String userContentType = "user/custom-type"; - - // PUT with both the object's Content-Type and a colliding user - // x-amz-meta-content-type. - when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE)) - .thenReturn(objectContentType); + String keyName = "reserved-" + customKey; MultivaluedMap requestHeaders = new MultivaluedHashMap<>(); - requestHeaders.putSingle( - CUSTOM_METADATA_HEADER_PREFIX + "content-type", userContentType); + requestHeaders.putSingle(CUSTOM_METADATA_HEADER_PREFIX + headerName.toLowerCase(), userValue); when(headers.getRequestHeaders()).thenReturn(requestHeaders); - assertSucceeds(() -> put(keyEndpoint, bucketName, keyName, "head-content")); + if (HttpHeaders.CONTENT_TYPE.equals(headerName)) { + when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE)) + .thenReturn(systemValue); + } else { + when(headers.getHeaderString(headerName)).thenReturn(systemValue); + } + + assertSucceeds(() -> put(keyEndpoint, bucketName, keyName, "body")); - // The user value is remapped, so the object's Content-Type is preserved. - assertEquals(objectContentType, - bucket.getKey(keyName).getMetadata().get(HttpHeaders.CONTENT_TYPE)); + assertEquals(systemValue, bucket.getKey(keyName).getMetadata().get(headerName)); + assertEquals(userValue, + bucket.getKey(keyName).getMetadata().get(customKey)); - // HEAD returns the object Content-Type as the standard header and the user - // value as x-amz-meta-content-type. Response response = keyEndpoint.head(bucketName, keyName); assertEquals(HttpStatus.SC_OK, response.getStatus()); - assertEquals(objectContentType, - response.getHeaderString(HttpHeaders.CONTENT_TYPE)); - assertEquals(userContentType, - response.getHeaderString(CUSTOM_METADATA_HEADER_PREFIX + "content-type")); + assertEquals(systemValue, response.getHeaderString(headerName)); + assertEquals(userValue, + response.getHeaderString(CUSTOM_METADATA_HEADER_PREFIX + headerName.toLowerCase())); + } + + private static Stream reservedMetadataCollisionCases() { + return Stream.of( + Arguments.of(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_CUSTOM, "image/jpeg", "user/custom-type"), + Arguments.of(HttpHeaders.CACHE_CONTROL, CACHE_CONTROL_CUSTOM, "no-cache", "user-cache"), + Arguments.of(HttpHeaders.EXPIRES, EXPIRES_CUSTOM, EXPIRES1, "user-expires"), + Arguments.of(HttpHeaders.CONTENT_ENCODING, CONTENT_ENCODING_CUSTOM, "gzip", "user-encoding"), + Arguments.of(HttpHeaders.CONTENT_LANGUAGE, CONTENT_LANGUAGE_CUSTOM, "en-CA", "user-lang"), + Arguments.of(HttpHeaders.CONTENT_DISPOSITION, CONTENT_DISPOSITION_CUSTOM, "inline", "user-disp")); } private byte[] createKey(String keyPath) throws IOException { diff --git a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3Utils.java b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3Utils.java index d47361711304..6425507022d1 100644 --- a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3Utils.java +++ b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/util/TestS3Utils.java @@ -189,4 +189,22 @@ public void testValidateContentMD5WithValidInput() throws Exception { assertDoesNotThrow(() -> S3Utils.validateContentMD5(md5Base64, md5Hex, "test-resource")); } + @ParameterizedTest + @MethodSource("contentEncodingProvider") + public void testNormalizeContentEncoding(String input, String expected) { + assertEquals(expected, S3Utils.normalizeContentEncoding(input)); + } + + private static Stream contentEncodingProvider() { + return Stream.of( + Arguments.of(null, null), + Arguments.of("", null), + Arguments.of("gzip", "gzip"), + Arguments.of("deflate, gzip", "deflate, gzip"), + Arguments.of("gzip, aws-chunked", "gzip"), + Arguments.of("aws-chunked, gzip", "gzip"), + Arguments.of("aws-chunked", null), + Arguments.of("aws-chunked, aws-chunked", null)); + } + }