This is an automated email from the ASF dual-hosted git repository.

Gargi-jais11 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new 3dbca34cdb4 HDDS-15606. Support S3 object metadata headers 
(Cache-Control, Expires, Content-Encoding) (#10786).
3dbca34cdb4 is described below

commit 3dbca34cdb4f9114430589c7e772e8c02604c772
Author: Gargi Jaiswal <[email protected]>
AuthorDate: Thu Jul 30 15:06:34 2026 +0530

    HDDS-15606. Support S3 object metadata headers (Cache-Control, Expires, 
Content-Encoding) (#10786).
---
 .../ozone/s3/awssdk/v2/AbstractS3SDKV2Tests.java   | 106 +++++++++++++++++++++
 .../hadoop/ozone/s3/endpoint/EndpointBase.java     |  38 ++++++--
 .../hadoop/ozone/s3/endpoint/ObjectEndpoint.java   |  56 +++++++++--
 .../org/apache/hadoop/ozone/s3/util/S3Consts.java  |   5 +
 .../org/apache/hadoop/ozone/s3/util/S3Utils.java   |  19 +++-
 .../hadoop/ozone/s3/endpoint/TestEndpointBase.java |  26 +++++
 .../s3/endpoint/TestMultipartUploadComplete.java   |  22 +++++
 .../hadoop/ozone/s3/endpoint/TestObjectGet.java    |  20 +++-
 .../hadoop/ozone/s3/endpoint/TestObjectHead.java   |  89 ++++++++++++-----
 .../hadoop/ozone/s3/endpoint/TestObjectPut.java    |  45 +++++++++
 .../apache/hadoop/ozone/s3/util/TestS3Utils.java   |  20 ++++
 11 files changed, 405 insertions(+), 41 deletions(-)

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 2fe78577576..8b893919cb0 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;
@@ -3199,6 +3201,110 @@ private void 
verifyBucketOwnershipVerificationAccessDenied(Executable function)
     }
   }
 
+  static Stream<Arguments> 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 3f960cf0e67..124fed461ff 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
@@ -37,6 +37,7 @@
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.newError;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.AWS_TAG_PREFIX;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.RESERVED_USER_METADATA_KEY_PREFIX;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CLASS_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.STORAGE_CONFIG_HEADER;
 import static org.apache.hadoop.ozone.s3.util.S3Consts.TAG_HEADER;
@@ -122,17 +123,35 @@
  */
 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 ETAG_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "etag";
+  protected static final String CONTENT_TYPE_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "content-type";
+  protected static final String CACHE_CONTROL_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "cache-control";
+  protected static final String EXPIRES_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "expires";
+  protected static final String CONTENT_ENCODING_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "content-encoding";
+  protected static final String CONTENT_LANGUAGE_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "content-language";
+  protected static final String CONTENT_DISPOSITION_CUSTOM =
+      RESERVED_USER_METADATA_KEY_PREFIX + "content-disposition";
 
   // 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<String, String> RESERVED_METADATA_KEYS =
-      ImmutableMap.of(
-          ETAG, ETAG_CUSTOM,
-          HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_CUSTOM);
+      ImmutableMap.<String, String>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<String, String> REBUILT_RESERVED_KEYS =
@@ -356,6 +375,13 @@ protected Map<String, String> getCustomMetadataFromHeaders(
       for (String key : customMetadataKeys) {
         String mapKey =
             key.substring(CUSTOM_METADATA_HEADER_PREFIX.length());
+        if (mapKey.regionMatches(true, 0, RESERVED_USER_METADATA_KEY_PREFIX, 0,
+            RESERVED_USER_METADATA_KEY_PREFIX.length())) {
+          OS3Exception ex = newError(INVALID_ARGUMENT, key);
+          ex.setErrorMessage("User metadata keys must not start with the 
reserved prefix "
+              + RESERVED_USER_METADATA_KEY_PREFIX);
+          throw ex;
+        }
         List<String> values = requestHeaders.get(key);
         String value = StringUtils.join(values, ",");
         sizeInBytes += mapKey.getBytes(UTF_8).length;
@@ -402,7 +428,7 @@ protected void addCustomMetadataHeaders(
       if (RESERVED_METADATA_KEYS.containsKey(metadataKey)) {
         continue;
       }
-      // Rebuild a remapped user value (e.g. content-type-custom -> 
content-type).
+      // Rebuild a remapped user value (e.g. ozone-s3-internal-content-type -> 
content-type).
       metadataKey = REBUILT_RESERVED_KEYS.getOrDefault(metadataKey, 
metadataKey);
       responseBuilder
           .header(CUSTOM_METADATA_HEADER_PREFIX + metadataKey,
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 ed9602d0fd0..ad798e36989 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<String, String> customMetadata =
           getCustomMetadataFromHeaders(getHeaders().getRequestHeaders());
       putContentType(customMetadata);
+      putStandardObjectHeaders(customMetadata);
       Map<String, String> tags = getTaggingFromHeaders(getHeaders());
 
       long putLength;
@@ -480,14 +482,8 @@ Response handleGetRequest(ObjectRequestContext context, 
String keyPath)
       responseBuilder.header(HttpHeaders.CONTENT_TYPE, contentType);
 
       for (Map.Entry<String, String> 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);
@@ -528,6 +524,45 @@ private void putContentType(Map<String, String> metadata) {
     }
   }
 
+  /**
+   * Store standard S3 object headers from the PUT request in key metadata.
+   */
+  private void putStandardObjectHeaders(Map<String, String> metadata) {
+    for (String headerName : overrideQueryParameter.keySet()) {
+      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<String, String> 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<String, String> queryParams =
+        getContext().getUriInfo().getQueryParameters();
+    for (Map.Entry<String, String> 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);
@@ -643,6 +678,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);
@@ -756,6 +792,7 @@ public Response initializeMultipartUpload(
       Map<String, String> customMetadata =
           getCustomMetadataFromHeaders(getHeaders().getRequestHeaders());
       putContentType(customMetadata);
+      putStandardObjectHeaders(customMetadata);
 
       Map<String, String> tags = getTaggingFromHeaders(getHeaders());
 
@@ -1184,8 +1221,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/S3Consts.java
 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
index a2ed3a84c25..d253e526386 100644
--- 
a/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
+++ 
b/hadoop-ozone/s3gateway/src/main/java/org/apache/hadoop/ozone/s3/util/S3Consts.java
@@ -75,6 +75,11 @@ public final class S3Consts {
 
   // Constants related to custom metadata
   public static final String CUSTOM_METADATA_HEADER_PREFIX = "x-amz-meta-";
+  /**
+   * Prefix for internal metadata keys that store remapped user x-amz-meta 
values
+   * colliding with system headers. Users must not set metadata keys with this 
prefix.
+   */
+  public static final String RESERVED_USER_METADATA_KEY_PREFIX = 
"ozone-s3-internal-";
   public static final String CUSTOM_METADATA_COPY_DIRECTIVE_HEADER = 
"x-amz-metadata-directive";
   public static final String STORAGE_CONFIG_HEADER = "storage-config";
 
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 4f9fe6c2a27..3379d2d244d 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.equalsIgnoreCase(value))
+        .collect(Collectors.joining(", "));
+    return normalized.isEmpty() ? null : normalized;
+  }
+
   private S3Utils() {
     // no instances
   }
@@ -162,7 +179,7 @@ public static void validateMultiChunksUpload(HttpHeaders 
headers, String amzDeco
       // We are only interested on "aws-chunked"
       boolean containsAwsChunked = Arrays.stream(contentEncoding.split(","))
           .map(String::trim)
-          .anyMatch(AWS_CHUNKED::equals);
+          .anyMatch(AWS_CHUNKED::equalsIgnoreCase);
       if (!containsAwsChunked) {
         OS3Exception ex = S3ErrorTable.newError(S3ErrorTable.INVALID_ARGUMENT, 
resource);
         ex.setErrorMessage("An error occurred (InvalidArgument) for multi 
chunks upload: " +
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java
index 2910aa22a51..7002fbd0999 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestEndpointBase.java
@@ -17,7 +17,9 @@
 
 package org.apache.hadoop.ozone.s3.endpoint;
 
+import static 
org.apache.hadoop.ozone.s3.exception.S3ErrorTable.INVALID_ARGUMENT;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX;
+import static 
org.apache.hadoop.ozone.s3.util.S3Consts.RESERVED_USER_METADATA_KEY_PREFIX;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -25,11 +27,14 @@
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 import java.util.Map;
+import java.util.stream.Stream;
 import javax.ws.rs.core.MultivaluedHashMap;
 import javax.ws.rs.core.MultivaluedMap;
 import org.apache.hadoop.ozone.OzoneConsts;
 import org.apache.hadoop.ozone.s3.exception.OS3Exception;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
 
 /**
  * Tests the s3 EndpointBase class methods.
@@ -108,4 +113,25 @@ public void 
testCustomMetadataHeadersWithUpperCaseHeaders() throws OS3Exception
     assertEquals(value, customMetadata.get(key));
   }
 
+  @ParameterizedTest
+  @MethodSource("reservedInternalMetadataKeyPrefixCases")
+  public void testRejectReservedInternalMetadataKeyPrefix(String metadataKey) {
+    MultivaluedMap<String, String> s3requestHeaders = new 
MultivaluedHashMap<>();
+    s3requestHeaders.add(CUSTOM_METADATA_HEADER_PREFIX + metadataKey, 
"user-value");
+
+    EndpointBase endpointBase = new EndpointBase() {
+    };
+
+    OS3Exception e = assertThrows(OS3Exception.class, () -> endpointBase
+        .getCustomMetadataFromHeaders(s3requestHeaders));
+    assertThat(e.getCode()).contains(INVALID_ARGUMENT.getCode());
+    
assertThat(e.getErrorMessage()).contains(RESERVED_USER_METADATA_KEY_PREFIX);
+  }
+
+  private static Stream<String> reservedInternalMetadataKeyPrefixCases() {
+    return Stream.of(
+        RESERVED_USER_METADATA_KEY_PREFIX + "cache-control",
+        RESERVED_USER_METADATA_KEY_PREFIX.toUpperCase(Locale.ROOT) + 
"cache-control");
+  }
+
 }
diff --git 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestMultipartUploadComplete.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestMultipartUploadComplete.java
index 816e8e10ddf..e95d8085c2d 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestMultipartUploadComplete.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestMultipartUploadComplete.java
@@ -143,6 +143,28 @@ public void testMultipartStoresContentType() throws 
Exception {
         .getHeaderString(HttpHeaders.CONTENT_TYPE));
   }
 
+  @Test
+  public void testMultipartStoresStandardObjectHeaders() throws Exception {
+    String key = UUID.randomUUID().toString();
+    
when(headers.getHeaderString(HttpHeaders.CACHE_CONTROL)).thenReturn("no-cache");
+    when(headers.getHeaderString(HttpHeaders.EXPIRES)).thenReturn("Wed, 21 Oct 
2015 07:29:00 GMT");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_ENCODING)).thenReturn("gzip");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_LANGUAGE)).thenReturn("en-CA");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_DISPOSITION)).thenReturn("inline");
+
+    String uploadID = initiateMultipartUpload(key);
+    Part part1 = uploadPart(rest, OzoneConsts.S3_BUCKET, key, 1, uploadID, 
"Multipart Upload 1");
+    completeMultipartUpload(rest, OzoneConsts.S3_BUCKET, key, uploadID, 
singletonList(part1));
+
+    Response headResponse = rest.head(OzoneConsts.S3_BUCKET, key);
+    assertEquals("no-cache", 
headResponse.getHeaderString(HttpHeaders.CACHE_CONTROL));
+    assertEquals("Wed, 21 Oct 2015 07:29:00 GMT",
+        headResponse.getHeaderString(HttpHeaders.EXPIRES));
+    assertEquals("gzip", 
headResponse.getHeaderString(HttpHeaders.CONTENT_ENCODING));
+    assertEquals("en-CA", 
headResponse.getHeaderString(HttpHeaders.CONTENT_LANGUAGE));
+    assertEquals("inline", 
headResponse.getHeaderString(HttpHeaders.CONTENT_DISPOSITION));
+  }
+
   @Test
   public void testMultipartInvalidPartOrderError() throws Exception {
 
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 741cd766739..d33e506362c 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
@@ -259,12 +259,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,
@@ -277,11 +277,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<String, String> queryParameter = 
rest.getContext().getUriInfo().getQueryParameters();
     // overrider request header
@@ -411,6 +416,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 ccce932d0d7..2c1b7f8699c 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.INVALID_ARGUMENT;
 import static org.apache.hadoop.ozone.s3.exception.S3ErrorTable.PRECOND_FAILED;
 import static 
org.apache.hadoop.ozone.s3.util.S3Consts.CUSTOM_METADATA_HEADER_PREFIX;
@@ -43,6 +50,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;
@@ -60,6 +68,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.
@@ -245,35 +256,69 @@ 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<String, String> 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);
+    }
 
-    // The user value is remapped, so the object's Content-Type is preserved.
-    assertEquals(objectContentType,
-        bucket.getKey(keyName).getMetadata().get(HttpHeaders.CONTENT_TYPE));
+    assertSucceeds(() -> put(keyEndpoint, bucketName, keyName, "body"));
+
+    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()));
+  }
+
+  @Test
+  public void testUserMetadataSuffixDoesNotCollideWithInternalKey() throws 
Exception {
+    String keyName = "reserved-cache-control-suffix";
+    MultivaluedMap<String, String> requestHeaders = new MultivaluedHashMap<>();
+    requestHeaders.putSingle(CUSTOM_METADATA_HEADER_PREFIX + "cache-control", 
"user-cache");
+    requestHeaders.putSingle(CUSTOM_METADATA_HEADER_PREFIX + 
"cache-control-custom", "suffix-value");
+    when(headers.getRequestHeaders()).thenReturn(requestHeaders);
+    
when(headers.getHeaderString(HttpHeaders.CACHE_CONTROL)).thenReturn("no-cache");
+
+    assertSucceeds(() -> put(keyEndpoint, bucketName, keyName, "body"));
+
+    assertEquals("no-cache",
+        bucket.getKey(keyName).getMetadata().get(HttpHeaders.CACHE_CONTROL));
+    assertEquals("user-cache",
+        bucket.getKey(keyName).getMetadata().get(CACHE_CONTROL_CUSTOM));
+    assertEquals("suffix-value",
+        bucket.getKey(keyName).getMetadata().get("cache-control-custom"));
+
+    Response response = keyEndpoint.head(bucketName, keyName);
+    assertEquals("no-cache", 
response.getHeaderString(HttpHeaders.CACHE_CONTROL));
+    assertEquals("user-cache",
+        response.getHeaderString(CUSTOM_METADATA_HEADER_PREFIX + 
"cache-control"));
+    assertEquals("suffix-value",
+        response.getHeaderString(CUSTOM_METADATA_HEADER_PREFIX + 
"cache-control-custom"));
+  }
+
+  private static Stream<Arguments> 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/endpoint/TestObjectPut.java
 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
index 2af6cbcfdcc..6b4927d1340 100644
--- 
a/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
+++ 
b/hadoop-ozone/s3gateway/src/test/java/org/apache/hadoop/ozone/s3/endpoint/TestObjectPut.java
@@ -418,6 +418,51 @@ void testContentTypeStoredAndCopied() throws Exception {
         
destBucket.getKey(DEST_KEY).getMetadata().get(HttpHeaders.CONTENT_TYPE));
   }
 
+  @Test
+  void testStandardObjectHeadersCopiedAndReplaced() throws Exception {
+    
when(headers.getHeaderString(HttpHeaders.CACHE_CONTROL)).thenReturn("no-cache");
+    when(headers.getHeaderString(HttpHeaders.EXPIRES)).thenReturn("Wed, 21 Oct 
2015 07:29:00 GMT");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_ENCODING)).thenReturn("gzip");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_LANGUAGE)).thenReturn("en-CA");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_DISPOSITION)).thenReturn("inline");
+    when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE))
+        .thenReturn("audio/mpeg");
+
+    assertSucceeds(() -> putObject(CONTENT));
+
+    when(headers.getHeaderString(CUSTOM_METADATA_COPY_DIRECTIVE_HEADER))
+        .thenReturn("COPY");
+    when(headers.getHeaderString(COPY_SOURCE_HEADER))
+        .thenReturn(BUCKET_NAME + "/" + urlEncode(KEY_NAME));
+    
when(headers.getHeaderString(HttpHeaders.CACHE_CONTROL)).thenReturn("max-age=0");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_ENCODING)).thenReturn("compress");
+
+    assertSucceeds(() -> put(objectEndpoint, DEST_BUCKET_NAME, DEST_KEY, 
CONTENT));
+    OzoneKeyDetails copied = destBucket.getKey(DEST_KEY);
+    assertEquals("no-cache", 
copied.getMetadata().get(HttpHeaders.CACHE_CONTROL));
+    assertEquals("gzip", 
copied.getMetadata().get(HttpHeaders.CONTENT_ENCODING));
+    assertEquals("en-CA", 
copied.getMetadata().get(HttpHeaders.CONTENT_LANGUAGE));
+
+    when(headers.getHeaderString(CUSTOM_METADATA_COPY_DIRECTIVE_HEADER))
+        .thenReturn("REPLACE");
+    when(headers.getRequestHeaders()).thenReturn(new MultivaluedHashMap<>());
+    
when(headers.getHeaderString(HttpHeaders.CACHE_CONTROL)).thenReturn("private");
+    when(headers.getHeaderString(HttpHeaders.EXPIRES)).thenReturn("Wed, 21 Oct 
2015 07:28:00 GMT");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_ENCODING)).thenReturn("deflate");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_LANGUAGE)).thenReturn("de-DE");
+    
when(headers.getHeaderString(HttpHeaders.CONTENT_DISPOSITION)).thenReturn("attachment");
+
+    assertSucceeds(() -> put(objectEndpoint, DEST_BUCKET_NAME, DEST_KEY, 
CONTENT));
+    OzoneKeyDetails replaced = destBucket.getKey(DEST_KEY);
+    assertEquals("private", 
replaced.getMetadata().get(HttpHeaders.CACHE_CONTROL));
+    assertEquals("Wed, 21 Oct 2015 07:28:00 GMT",
+        replaced.getMetadata().get(HttpHeaders.EXPIRES));
+    assertEquals("deflate", 
replaced.getMetadata().get(HttpHeaders.CONTENT_ENCODING));
+    assertEquals("de-DE", 
replaced.getMetadata().get(HttpHeaders.CONTENT_LANGUAGE));
+    assertEquals("attachment",
+        replaced.getMetadata().get(HttpHeaders.CONTENT_DISPOSITION));
+  }
+
   @Test
   public void testCopyObjectMessageDigestResetDuringException() throws 
Exception {
     assertSucceeds(() -> putObject(CONTENT));
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 d4736171130..2dbf3c65ba6 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,24 @@ 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<Arguments> 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),
+        Arguments.of("gzip, AWS-CHUNKED", "gzip"),
+        Arguments.of("Aws-Chunked", null));
+  }
+
 }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to