Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
import static org.apache.hadoop.ozone.s3.util.S3Utils.validateSignatureHeader;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
Expand Down Expand Up @@ -118,6 +119,22 @@
public abstract class EndpointBase {

protected static final String ETAG_CUSTOM = "etag-custom";
protected static final String CONTENT_TYPE_CUSTOM = "content-type-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<String, String> RESERVED_METADATA_KEYS =
ImmutableMap.of(
ETAG, ETAG_CUSTOM,
HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_CUSTOM);

// Custom key -> lower-cased header, to rebuild remapped user metadata on read.
private static final Map<String, String> REBUILT_RESERVED_KEYS =
RESERVED_METADATA_KEYS.entrySet().stream()
.collect(ImmutableMap.toImmutableMap(
Map.Entry::getValue, e -> e.getKey().toLowerCase()));

private static final ThreadLocal<MessageDigest> MD5_PROVIDER;
private static final ThreadLocal<MessageDigest> SHA_256_PROVIDER;
Expand Down Expand Up @@ -319,35 +336,41 @@ protected Map<String, String> getCustomMetadataFromHeaders(
}
}

// If the request contains a custom metadata header "x-amz-meta-ETag",
// replace the metadata key to "etag-custom" to prevent key metadata collision with
// the ETag calculated by hashing the object when storing the key in OM table.
// The custom ETag metadata header will be rebuilt during the headObject operation.
if (customMetadata.containsKey(HttpHeaders.ETAG)
|| customMetadata.containsKey(HttpHeaders.ETAG.toLowerCase())) {
String customETag = customMetadata.get(HttpHeaders.ETAG) != null ?
customMetadata.get(HttpHeaders.ETAG) : customMetadata.get(HttpHeaders.ETAG.toLowerCase());
customMetadata.remove(HttpHeaders.ETAG);
customMetadata.remove(HttpHeaders.ETAG.toLowerCase());
customMetadata.put(ETAG_CUSTOM, customETag);
}
// Remap user values that collide with a reserved system key.
RESERVED_METADATA_KEYS.forEach((headerName, customKey) ->
remapReservedMetadataKey(customMetadata, headerName, customKey));

return customMetadata;
}

/**
* Move a user value under {@code headerName} (canonical or lower-case) to
* {@code customKey} so it does not collide with the system value.
*/
private static void remapReservedMetadataKey(Map<String, String> metadata,
String headerName, String customKey) {
String lowerName = headerName.toLowerCase();
if (metadata.containsKey(headerName) || metadata.containsKey(lowerName)) {
String value = metadata.get(headerName) != null
? metadata.get(headerName) : metadata.get(lowerName);
metadata.remove(headerName);
metadata.remove(lowerName);
metadata.put(customKey, value);
}
}

protected void addCustomMetadataHeaders(
Response.ResponseBuilder responseBuilder, OzoneKey key) {

Map<String, String> metadata = key.getMetadata();
for (Map.Entry<String, String> entry : metadata.entrySet()) {
if (entry.getKey().equals(ETAG)) {
continue;
}
String metadataKey = entry.getKey();
if (metadataKey.equals(ETAG_CUSTOM)) {
// Rebuild the ETag custom metadata header
metadataKey = ETAG.toLowerCase();
// System ETag / Content-Type are returned via their own response header.
if (RESERVED_METADATA_KEYS.containsKey(metadataKey)) {
continue;
}
// Rebuild a remapped user value (e.g. content-type-custom -> content-type).
metadataKey = REBUILT_RESERVED_KEYS.getOrDefault(metadataKey, metadataKey);
responseBuilder
.header(CUSTOM_METADATA_HEADER_PREFIX + metadataKey,
entry.getValue());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ public class ObjectEndpoint extends ObjectOperationHandler {

private static final String BUCKET = "bucket";
private static final String PATH = "path";
// Default Content-Type for objects stored without one, matching S3.
private static final String DEFAULT_CONTENT_TYPE = "binary/octet-stream";

private static final Logger LOG =
LoggerFactory.getLogger(ObjectEndpoint.class);
Expand All @@ -131,7 +133,6 @@ public class ObjectEndpoint extends ObjectOperationHandler {

public ObjectEndpoint() {
overrideQueryParameter = ImmutableMap.<String, String>builder()
.put(HttpHeaders.CONTENT_TYPE, "response-content-type")
.put(HttpHeaders.CONTENT_LANGUAGE, "response-content-language")
.put(HttpHeaders.EXPIRES, "response-expires")
.put(HttpHeaders.CACHE_CONTROL, "response-cache-control")
Expand Down Expand Up @@ -267,6 +268,7 @@ Response handlePutRequest(ObjectRequestContext context, String keyPath, InputStr

Map<String, String> customMetadata =
getCustomMetadataFromHeaders(getHeaders().getRequestHeaders());
putContentType(customMetadata);
Map<String, String> tags = getTaggingFromHeaders(getHeaders());

long putLength;
Expand Down Expand Up @@ -461,6 +463,14 @@ Response handleGetRequest(ObjectRequestContext context, String keyPath)
MultivaluedMap<String, String> queryParams =
getContext().getUriInfo().getQueryParameters();

// Content-Type comes from the stored object, not the request header;
// response-content-type still overrides it.
String contentType = queryParams.getFirst("response-content-type");
if (contentType == null) {
contentType = contentTypeOf(keyDetails);
}
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());
Expand Down Expand Up @@ -497,6 +507,24 @@ static void addLastModifiedDate(
RFC1123Util.FORMAT.format(lastModificationTime));
}

/**
* Store the request's Content-Type (preserved by {@link HeaderPreprocessor}
* as {@code X-Ozone-Original-Content-Type}) in the key metadata.
*/
private void putContentType(Map<String, String> metadata) {
String contentType =
getHeaders().getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE);
if (contentType != null) {
metadata.put(HttpHeaders.CONTENT_TYPE, contentType);
}
}

/** 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);
return contentType != null ? contentType : DEFAULT_CONTENT_TYPE;
}

static void addTagCountIfAny(
ResponseBuilder responseBuilder, OzoneKey key) {
// See x-amz-tagging-count in https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html
Expand Down Expand Up @@ -572,7 +600,7 @@ public Response head(

ResponseBuilder response = Response.ok().status(HttpStatus.SC_OK)
.header(HttpHeaders.CONTENT_LENGTH, key.getDataSize())
.header(HttpHeaders.CONTENT_TYPE, "binary/octet-stream")
.header(HttpHeaders.CONTENT_TYPE, contentTypeOf(key))
.header(STORAGE_CLASS_HEADER, s3StorageType.toString());
addEntityTagHeader(response, key);

Expand Down Expand Up @@ -675,6 +703,7 @@ public Response initializeMultipartUpload(

Map<String, String> customMetadata =
getCustomMetadataFromHeaders(getHeaders().getRequestHeaders());
putContentType(customMetadata);

Map<String, String> tags = getTaggingFromHeaders(getHeaders());

Expand Down Expand Up @@ -1089,6 +1118,8 @@ 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.
putContentType(customMetadata);
} else {
OS3Exception ex = newError(INVALID_ARGUMENT, metadataCopyDirective);
ex.setErrorMessage("An error occurred (InvalidArgument) " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.apache.hadoop.ozone.OzoneConsts;
import org.apache.hadoop.ozone.client.OzoneClient;
import org.apache.hadoop.ozone.client.OzoneClientStub;
import org.apache.hadoop.ozone.s3.HeaderPreprocessor;
import org.apache.hadoop.ozone.s3.endpoint.CompleteMultipartUploadRequest.Part;
import org.apache.hadoop.ozone.s3.exception.OS3Exception;
import org.apache.hadoop.ozone.s3.exception.S3ErrorTable;
Expand Down Expand Up @@ -122,6 +123,23 @@ public void testMultipartWithCustomMetadata() throws Exception {
assertEquals("custom-value2", headResponse.getHeaderString(CUSTOM_METADATA_HEADER_PREFIX + "custom-key2"));
}

@Test
public void testMultipartStoresContentType() throws Exception {
String key = UUID.randomUUID().toString();

// Content-Type set on CreateMultipartUpload is preserved as
// ORIGINAL_CONTENT_TYPE and must survive to the completed object.
when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE))
.thenReturn("video/webm");

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));

assertEquals("video/webm", rest.head(OzoneConsts.S3_BUCKET, key)
.getHeaderString(HttpHeaders.CONTENT_TYPE));
}

@Test
public void testMultipartInvalidPartOrderError() throws Exception {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import org.apache.hadoop.ozone.client.OzoneClient;
import org.apache.hadoop.ozone.client.OzoneClientStub;
import org.apache.hadoop.ozone.client.OzoneClientTestUtils;
import org.apache.hadoop.ozone.s3.HeaderPreprocessor;
import org.apache.hadoop.ozone.s3.exception.OS3Exception;
import org.apache.hadoop.ozone.s3.util.RFC1123Util;
import org.junit.jupiter.api.BeforeEach;
Expand Down Expand Up @@ -216,7 +217,8 @@ public void inheritRequestHeader() throws IOException, OS3Exception {

Response response = get(rest, BUCKET_NAME, KEY_NAME);

assertEquals(CONTENT_TYPE1,
// Content-Type is not inherited from the request; key1 has none stored.
assertEquals("binary/octet-stream",
response.getHeaderString("Content-Type"));
assertEquals(CONTENT_LANGUAGE1,
response.getHeaderString("Content-Language"));
Expand Down Expand Up @@ -298,6 +300,55 @@ public void getStatusCode() throws IOException, OS3Exception {
assertNull(response.getHeaderString(TAG_COUNT_HEADER));
}

@Test
public void getAndHeadReturnStoredContentType()
throws IOException, OS3Exception {
// Store a Content-Type, then read with no request Content-Type.
when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE))
.thenReturn(CONTENT_TYPE1);
assertSucceeds(() -> put(rest, BUCKET_NAME, "typed-key", CONTENT));
when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE))
.thenReturn(null);

// GET and HEAD return the stored Content-Type.
assertEquals(CONTENT_TYPE1,
get(rest, BUCKET_NAME, "typed-key").getHeaderString("Content-Type"));
assertEquals(CONTENT_TYPE1,
rest.head(BUCKET_NAME, "typed-key").getHeaderString("Content-Type"));

// An object stored without a Content-Type falls back to the default.
assertEquals("binary/octet-stream",
get(rest, BUCKET_NAME, KEY_NAME).getHeaderString("Content-Type"));
assertEquals("binary/octet-stream",
rest.head(BUCKET_NAME, KEY_NAME).getHeaderString("Content-Type"));
}

@Test
public void getIgnoresRequestContentTypeUsesStored()
throws IOException, OS3Exception {
when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE))
.thenReturn(CONTENT_TYPE1);
assertSucceeds(() -> put(rest, BUCKET_NAME, "typed-key", CONTENT));
when(headers.getHeaderString(HeaderPreprocessor.ORIGINAL_CONTENT_TYPE))
.thenReturn(null);

// A request Content-Type is ignored; the stored value is returned.
doReturn(CONTENT_TYPE2).when(headers).getHeaderString("Content-Type");
assertEquals(CONTENT_TYPE1,
get(rest, BUCKET_NAME, "typed-key").getHeaderString("Content-Type"));

// No stored Content-Type: request still ignored, default returned.
assertEquals("binary/octet-stream",
get(rest, BUCKET_NAME, KEY_NAME).getHeaderString("Content-Type"));

// response-content-type still overrides.
MultivaluedMap<String, String> queryParameter =
rest.getContext().getUriInfo().getQueryParameters();
queryParameter.putSingle("response-content-type", CONTENT_TYPE2);
assertEquals(CONTENT_TYPE2,
get(rest, BUCKET_NAME, "typed-key").getHeaderString("Content-Type"));
}

private void setDefaultHeader() {
doReturn(CONTENT_TYPE1)
.when(headers).getHeaderString("Content-Type");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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.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;
import static org.apache.hadoop.ozone.s3.util.S3Consts.IF_NONE_MATCH_HEADER;
import static org.apache.hadoop.ozone.s3.util.S3Consts.IF_UNMODIFIED_SINCE_HEADER;
Expand All @@ -42,13 +43,16 @@
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MultivaluedHashMap;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import org.apache.commons.lang3.RandomStringUtils;
import org.apache.hadoop.hdds.conf.OzoneConfiguration;
import org.apache.hadoop.ozone.OzoneConsts;
import org.apache.hadoop.ozone.client.OzoneBucket;
import org.apache.hadoop.ozone.client.OzoneClient;
import org.apache.hadoop.ozone.client.OzoneClientStub;
import org.apache.hadoop.ozone.s3.HeaderPreprocessor;
import org.apache.hadoop.ozone.s3.exception.OS3Exception;
import org.apache.hadoop.ozone.s3.util.RFC1123Util;
import org.apache.http.HttpStatus;
Expand Down Expand Up @@ -231,6 +235,37 @@ public void testHeadObjectIncludesTagCount()
assertEquals("2", response.getHeaderString(TAG_COUNT_HEADER));
}

@Test
public void testHeadSeparatesUserContentTypeMetadataFromObjectContentType()
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);
MultivaluedMap<String, String> requestHeaders = new MultivaluedHashMap<>();
requestHeaders.putSingle(
CUSTOM_METADATA_HEADER_PREFIX + "content-type", userContentType);
when(headers.getRequestHeaders()).thenReturn(requestHeaders);
assertSucceeds(() -> put(keyEndpoint, bucketName, keyName, "head-content"));

// The user value is remapped, so the object's Content-Type is preserved.
assertEquals(objectContentType,
bucket.getKey(keyName).getMetadata().get(HttpHeaders.CONTENT_TYPE));

// 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"));
}

private byte[] createKey(String keyPath) throws IOException {
byte[] bytes = RandomStringUtils.secure().nextAlphanumeric(32).getBytes(UTF_8);
try (OutputStream out = bucket.createKey(keyPath, bytes.length)) {
Expand Down
Loading