Skip to content

Commit 0e5dc16

Browse files
authored
Return ETag from S3 fixture GetObject API (#138514)
AWS S3 uses the `ETag` header to identify the object contents in various API responses. `S3HttpHandler` doesn't today return this header from its `GetObject` API, and its APIs which do return an `ETag` do not properly conform to its spec (particularly, they are not surrounded by `"` characters). This commit adds the missing response header to the `GetObject` API, fixes its format, and uses SHA256 rather than MD5 to compute the result.
1 parent 0f7046d commit 0e5dc16

File tree

3 files changed

+76
-29
lines changed

3 files changed

+76
-29
lines changed

modules/repository-s3/src/test/java/org/elasticsearch/repositories/s3/S3BlobContainerRetriesTests.java

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@
3232
import org.elasticsearch.common.blobstore.OptionalBytesReference;
3333
import org.elasticsearch.common.bytes.BytesArray;
3434
import org.elasticsearch.common.bytes.BytesReference;
35-
import org.elasticsearch.common.hash.MessageDigests;
3635
import org.elasticsearch.common.io.Streams;
3736
import org.elasticsearch.common.lucene.store.ByteArrayIndexInput;
3837
import org.elasticsearch.common.lucene.store.InputStreamIndexInput;
@@ -430,7 +429,7 @@ public void testWriteLargeBlob() throws Exception {
430429
assertThat(contentLength, anyOf(equalTo(lastPartSize), equalTo(bufferSize.getBytes())));
431430

432431
if (countDownUploads.decrementAndGet() % 2 == 0) {
433-
exchange.getResponseHeaders().add("ETag", getBase16MD5Digest(bytes));
432+
exchange.getResponseHeaders().add("ETag", S3HttpHandler.getEtagFromContents(bytes));
434433
exchange.sendResponseHeaders(HttpStatus.SC_OK, -1);
435434
exchange.close();
436435
return;
@@ -529,7 +528,7 @@ public void testWriteLargeBlobStreaming() throws Exception {
529528

530529
if (counterUploads.incrementAndGet() % 2 == 0) {
531530
bytesReceived.addAndGet(bytes.length());
532-
exchange.getResponseHeaders().add("ETag", getBase16MD5Digest(bytes));
531+
exchange.getResponseHeaders().add("ETag", S3HttpHandler.getEtagFromContents(bytes));
533532
exchange.sendResponseHeaders(HttpStatus.SC_OK, -1);
534533
exchange.close();
535534
return;
@@ -1382,21 +1381,6 @@ public void handle(HttpExchange exchange) throws IOException {
13821381
);
13831382
}
13841383

1385-
private static String getBase16MD5Digest(BytesReference bytesReference) {
1386-
return MessageDigests.toHexString(MessageDigests.digest(bytesReference, MessageDigests.md5()));
1387-
}
1388-
1389-
public void testGetBase16MD5Digest() {
1390-
// from Wikipedia, see also org.elasticsearch.common.hash.MessageDigestsTests.testMd5
1391-
assertBase16MD5Digest("", "d41d8cd98f00b204e9800998ecf8427e");
1392-
assertBase16MD5Digest("The quick brown fox jumps over the lazy dog", "9e107d9d372bb6826bd81d3542a419d6");
1393-
assertBase16MD5Digest("The quick brown fox jumps over the lazy dog.", "e4d909c290d0fb1ca068ffaddf22cbd0");
1394-
}
1395-
1396-
private static void assertBase16MD5Digest(String input, String expectedDigestString) {
1397-
assertEquals(expectedDigestString, getBase16MD5Digest(new BytesArray(input)));
1398-
}
1399-
14001384
@Override
14011385
protected Matcher<Integer> getMaxRetriesMatcher(int maxRetries) {
14021386
// some attempts make meaningful progress and do not count towards the max retry limit

test/fixtures/s3-fixture/src/main/java/fixture/s3/S3HttpHandler.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ public S3HttpHandler(final String bucket, @Nullable final String basePath) {
8484
*/
8585
private static final Set<String> METHODS_HAVING_NO_REQUEST_BODY = Set.of("GET", "HEAD", "DELETE");
8686

87+
private static final String SHA_256_ETAG_PREFIX = "es-test-sha-256-";
88+
8789
@Override
8890
public void handle(final HttpExchange exchange) throws IOException {
8991
// Remove custom query parameters before processing the request. This simulates how S3 ignores them.
@@ -322,6 +324,9 @@ public void handle(final HttpExchange exchange) throws IOException {
322324
exchange.sendResponseHeaders(RestStatus.NOT_FOUND.getStatus(), -1);
323325
return;
324326
}
327+
328+
exchange.getResponseHeaders().add("ETag", getEtagFromContents(blob));
329+
325330
final String rangeHeader = exchange.getRequestHeaders().getFirst("Range");
326331
if (rangeHeader == null) {
327332
exchange.getResponseHeaders().add("Content-Type", "application/octet-stream");
@@ -413,6 +418,15 @@ private boolean updateBlobContents(HttpExchange exchange, String path, BytesRefe
413418
}
414419
}
415420

421+
/**
422+
* Etags are opaque identifiers for the contents of an object.
423+
*
424+
* @see <a href="https://en.wikipedia.org/wiki/HTTP_ETag">HTTP ETag on Wikipedia</a>.
425+
*/
426+
public static String getEtagFromContents(BytesReference blobContents) {
427+
return '"' + SHA_256_ETAG_PREFIX + MessageDigests.toHexString(MessageDigests.digest(blobContents, MessageDigests.sha256())) + '"';
428+
}
429+
416430
public Map<String, BytesReference> blobs() {
417431
return blobs;
418432
}
@@ -490,7 +504,7 @@ private static Tuple<String, BytesReference> parseRequestBody(final HttpExchange
490504
);
491505
}
492506
}
493-
return Tuple.tuple(MessageDigests.toHexString(MessageDigests.digest(bytesReference, MessageDigests.md5())), bytesReference);
507+
return Tuple.tuple(getEtagFromContents(bytesReference), bytesReference);
494508
} catch (Exception e) {
495509
logger.error("exception in parseRequestBody", e);
496510
exchange.sendResponseHeaders(500, 0);

test/fixtures/s3-fixture/src/test/java/fixture/s3/S3HttpHandlerTests.java

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,12 @@ public void testSimpleObjectOperations() {
8383
<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Prefix></Prefix><IsTruncated>false</IsTruncated>\
8484
</ListBucketResult>""");
8585

86-
final var body = randomAlphaOfLength(50);
86+
final var body = new BytesArray(randomAlphaOfLength(50).getBytes(StandardCharsets.UTF_8));
8787
assertEquals(RestStatus.OK, handleRequest(handler, "PUT", "/bucket/path/blob", body).status());
88-
assertEquals(new TestHttpResponse(RestStatus.OK, body), handleRequest(handler, "GET", "/bucket/path/blob"));
88+
assertEquals(
89+
new TestHttpResponse(RestStatus.OK, body, addETag(S3HttpHandler.getEtagFromContents(body), TestHttpExchange.EMPTY_HEADERS)),
90+
handleRequest(handler, "GET", "/bucket/path/blob")
91+
);
8992

9093
assertListObjectsResponse(handler, "", null, """
9194
<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Prefix></Prefix><IsTruncated>false</IsTruncated>\
@@ -135,39 +138,53 @@ public void testGetWithBytesRange() {
135138
final var blobBytes = randomBytesReference(256);
136139
assertEquals(RestStatus.OK, handleRequest(handler, "PUT", blobPath, blobBytes).status());
137140

141+
final var expectedEtag = S3HttpHandler.getEtagFromContents(blobBytes);
142+
138143
assertEquals(
139144
"No Range",
140-
new TestHttpResponse(RestStatus.OK, blobBytes, TestHttpExchange.EMPTY_HEADERS),
145+
new TestHttpResponse(RestStatus.OK, blobBytes, addETag(expectedEtag, TestHttpExchange.EMPTY_HEADERS)),
141146
handleRequest(handler, "GET", blobPath)
142147
);
143148

144149
var end = blobBytes.length() - 1;
145150
assertEquals(
146151
"Exact Range: bytes=0-" + end,
147-
new TestHttpResponse(RestStatus.PARTIAL_CONTENT, blobBytes, contentRangeHeader(0, end, blobBytes.length())),
152+
new TestHttpResponse(
153+
RestStatus.PARTIAL_CONTENT,
154+
blobBytes,
155+
addETag(expectedEtag, contentRangeHeader(0, end, blobBytes.length()))
156+
),
148157
handleRequest(handler, "GET", blobPath, BytesArray.EMPTY, bytesRangeHeader(0, end))
149158
);
150159

151160
end = randomIntBetween(blobBytes.length() - 1, Integer.MAX_VALUE);
152161
assertEquals(
153162
"Larger Range: bytes=0-" + end,
154-
new TestHttpResponse(RestStatus.PARTIAL_CONTENT, blobBytes, contentRangeHeader(0, blobBytes.length() - 1, blobBytes.length())),
163+
new TestHttpResponse(
164+
RestStatus.PARTIAL_CONTENT,
165+
blobBytes,
166+
addETag(expectedEtag, contentRangeHeader(0, blobBytes.length() - 1, blobBytes.length()))
167+
),
155168
handleRequest(handler, "GET", blobPath, BytesArray.EMPTY, bytesRangeHeader(0, end))
156169
);
157170

158171
var start = randomIntBetween(blobBytes.length(), Integer.MAX_VALUE - 1);
159172
end = randomIntBetween(start, Integer.MAX_VALUE);
160173
assertEquals(
161174
"Invalid Range: bytes=" + start + '-' + end,
162-
new TestHttpResponse(RestStatus.REQUESTED_RANGE_NOT_SATISFIED, BytesArray.EMPTY, TestHttpExchange.EMPTY_HEADERS),
175+
new TestHttpResponse(
176+
RestStatus.REQUESTED_RANGE_NOT_SATISFIED,
177+
BytesArray.EMPTY,
178+
addETag(expectedEtag, TestHttpExchange.EMPTY_HEADERS)
179+
),
163180
handleRequest(handler, "GET", blobPath, BytesArray.EMPTY, bytesRangeHeader(start, end))
164181
);
165182

166183
start = randomIntBetween(2, Integer.MAX_VALUE - 1);
167184
end = randomIntBetween(0, start - 1);
168185
assertEquals(
169186
"Weird Valid Range: bytes=" + start + '-' + end,
170-
new TestHttpResponse(RestStatus.OK, blobBytes, TestHttpExchange.EMPTY_HEADERS),
187+
new TestHttpResponse(RestStatus.OK, blobBytes, addETag(expectedEtag, TestHttpExchange.EMPTY_HEADERS)),
171188
handleRequest(handler, "GET", blobPath, BytesArray.EMPTY, bytesRangeHeader(start, end))
172189
);
173190

@@ -179,7 +196,7 @@ public void testGetWithBytesRange() {
179196
new TestHttpResponse(
180197
RestStatus.PARTIAL_CONTENT,
181198
blobBytes.slice(start, length),
182-
contentRangeHeader(start, end, blobBytes.length())
199+
addETag(expectedEtag, contentRangeHeader(start, end, blobBytes.length()))
183200
),
184201
handleRequest(handler, "GET", blobPath, BytesArray.EMPTY, bytesRangeHeader(start, end))
185202
);
@@ -245,7 +262,15 @@ public void testSingleMultipartUpload() {
245262
<Contents><Key>path/blob</Key><Size>100</Size></Contents>\
246263
</ListBucketResult>""");
247264

248-
assertEquals(new TestHttpResponse(RestStatus.OK, part1 + part2), handleRequest(handler, "GET", "/bucket/path/blob"));
265+
final var expectedContents = new BytesArray((part1 + part2).getBytes(StandardCharsets.UTF_8));
266+
assertEquals(
267+
new TestHttpResponse(
268+
RestStatus.OK,
269+
expectedContents,
270+
addETag(S3HttpHandler.getEtagFromContents(expectedContents), TestHttpExchange.EMPTY_HEADERS)
271+
),
272+
handleRequest(handler, "GET", "/bucket/path/blob")
273+
);
249274

250275
assertEquals(new TestHttpResponse(RestStatus.OK, """
251276
<?xml version='1.0' encoding='UTF-8'?>\
@@ -416,7 +441,11 @@ public void testPreventObjectOverwrite() throws InterruptedException {
416441
});
417442

418443
assertEquals(
419-
new TestHttpResponse(RestStatus.OK, successfulTasks.getFirst().body, TestHttpExchange.EMPTY_HEADERS),
444+
new TestHttpResponse(
445+
RestStatus.OK,
446+
successfulTasks.getFirst().body,
447+
addETag(S3HttpHandler.getEtagFromContents(successfulTasks.getFirst().body), TestHttpExchange.EMPTY_HEADERS)
448+
),
420449
handleRequest(handler, "GET", "/bucket/path/blob")
421450
);
422451
}
@@ -459,6 +488,20 @@ private static TestWriteTask createMultipartUploadTask(S3HttpHandler handler) {
459488
return multipartUploadTask;
460489
}
461490

491+
public void testGetETagFromContents() {
492+
// empty-string value from Wikipedia, see also org.elasticsearch.common.hash.MessageDigestsTests.testSha256
493+
assertETag("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
494+
assertETag("The quick brown fox jumps over the lazy dog", "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592");
495+
assertETag("The quick brown fox jumps over the lazy cog", "e4c4d8f3bf76b692de791a173e05321150f7a345b46484fe427f6acc7ecc81be");
496+
}
497+
498+
private static void assertETag(String input, String expectedHash) {
499+
assertEquals(
500+
"\"es-test-sha-256-" + expectedHash + '"',
501+
S3HttpHandler.getEtagFromContents(new BytesArray(input.getBytes(StandardCharsets.UTF_8)))
502+
);
503+
}
504+
462505
private static class TestWriteTask {
463506
final BytesReference body;
464507
final Runnable consumer;
@@ -562,6 +605,12 @@ private static Headers ifNoneMatchHeader() {
562605
return headers;
563606
}
564607

608+
private static Headers addETag(String eTag, Headers headers) {
609+
final var newHeaders = new Headers(headers);
610+
newHeaders.add("ETag", eTag);
611+
return newHeaders;
612+
}
613+
565614
private static class TestHttpExchange extends HttpExchange {
566615

567616
private static final Headers EMPTY_HEADERS = new Headers();

0 commit comments

Comments
 (0)