Deduplicate Range header parsing (#117304)

This commit is contained in:
Nick Tindall 2024-11-30 11:33:20 +11:00 committed by GitHub
parent 0b764adbc1
commit c74c06daee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 141 additions and 63 deletions

View file

@ -41,6 +41,7 @@ import org.elasticsearch.repositories.blobstore.AbstractBlobContainerRetriesTest
import org.elasticsearch.repositories.blobstore.ESMockAPIBasedRepositoryIntegTestCase; import org.elasticsearch.repositories.blobstore.ESMockAPIBasedRepositoryIntegTestCase;
import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.RestUtils; import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import org.threeten.bp.Duration; import org.threeten.bp.Duration;
import java.io.IOException; import java.io.IOException;
@ -177,9 +178,9 @@ public class GoogleCloudStorageBlobContainerRetriesTests extends AbstractBlobCon
httpServer.createContext(downloadStorageEndpoint(blobContainer, "large_blob_retries"), exchange -> { httpServer.createContext(downloadStorageEndpoint(blobContainer, "large_blob_retries"), exchange -> {
Streams.readFully(exchange.getRequestBody()); Streams.readFully(exchange.getRequestBody());
exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.getResponseHeaders().add("Content-Type", "application/octet-stream");
final Tuple<Long, Long> range = getRange(exchange); final HttpHeaderParser.Range range = getRange(exchange);
final int offset = Math.toIntExact(range.v1()); final int offset = Math.toIntExact(range.start());
final byte[] chunk = Arrays.copyOfRange(bytes, offset, Math.toIntExact(Math.min(range.v2() + 1, bytes.length))); final byte[] chunk = Arrays.copyOfRange(bytes, offset, Math.toIntExact(Math.min(range.end() + 1, bytes.length)));
exchange.sendResponseHeaders(RestStatus.OK.getStatus(), chunk.length); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), chunk.length);
if (randomBoolean() && countDown.decrementAndGet() >= 0) { if (randomBoolean() && countDown.decrementAndGet() >= 0) {
exchange.getResponseBody().write(chunk, 0, chunk.length - 1); exchange.getResponseBody().write(chunk, 0, chunk.length - 1);

View file

@ -22,6 +22,7 @@ import org.elasticsearch.core.Nullable;
import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.RestUtils; import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import org.elasticsearch.xcontent.XContentBuilder; import org.elasticsearch.xcontent.XContentBuilder;
import org.elasticsearch.xcontent.XContentType; import org.elasticsearch.xcontent.XContentType;
@ -42,8 +43,6 @@ import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.function.Predicate; import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static fixture.azure.MockAzureBlobStore.failTestWithAssertionError; import static fixture.azure.MockAzureBlobStore.failTestWithAssertionError;
import static org.elasticsearch.repositories.azure.AzureFixtureHelper.assertValidBlockId; import static org.elasticsearch.repositories.azure.AzureFixtureHelper.assertValidBlockId;
@ -54,7 +53,6 @@ import static org.elasticsearch.repositories.azure.AzureFixtureHelper.assertVali
@SuppressForbidden(reason = "Uses a HttpServer to emulate an Azure endpoint") @SuppressForbidden(reason = "Uses a HttpServer to emulate an Azure endpoint")
public class AzureHttpHandler implements HttpHandler { public class AzureHttpHandler implements HttpHandler {
private static final Logger logger = LogManager.getLogger(AzureHttpHandler.class); private static final Logger logger = LogManager.getLogger(AzureHttpHandler.class);
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$");
static final String X_MS_LEASE_ID = "x-ms-lease-id"; static final String X_MS_LEASE_ID = "x-ms-lease-id";
static final String X_MS_PROPOSED_LEASE_ID = "x-ms-proposed-lease-id"; static final String X_MS_PROPOSED_LEASE_ID = "x-ms-proposed-lease-id";
static final String X_MS_LEASE_DURATION = "x-ms-lease-duration"; static final String X_MS_LEASE_DURATION = "x-ms-lease-duration";
@ -232,29 +230,26 @@ public class AzureHttpHandler implements HttpHandler {
final BytesReference responseContent; final BytesReference responseContent;
final RestStatus successStatus; final RestStatus successStatus;
// see Constants.HeaderConstants.STORAGE_RANGE_HEADER // see Constants.HeaderConstants.STORAGE_RANGE_HEADER
final String range = exchange.getRequestHeaders().getFirst("x-ms-range"); final String rangeHeader = exchange.getRequestHeaders().getFirst("x-ms-range");
if (range != null) { if (rangeHeader != null) {
final Matcher matcher = RANGE_HEADER_PATTERN.matcher(range); final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader);
if (matcher.matches() == false) { if (range == null) {
throw new MockAzureBlobStore.BadRequestException( throw new MockAzureBlobStore.BadRequestException(
"InvalidHeaderValue", "InvalidHeaderValue",
"Range header does not match expected format: " + range "Range header does not match expected format: " + rangeHeader
); );
} }
final long start = Long.parseLong(matcher.group(1));
final long end = Long.parseLong(matcher.group(2));
final BytesReference blobContents = blob.getContents(); final BytesReference blobContents = blob.getContents();
if (blobContents.length() <= start) { if (blobContents.length() <= range.start()) {
exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.getResponseHeaders().add("Content-Type", "application/octet-stream");
exchange.sendResponseHeaders(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), -1); exchange.sendResponseHeaders(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), -1);
return; return;
} }
responseContent = blobContents.slice( responseContent = blobContents.slice(
Math.toIntExact(start), Math.toIntExact(range.start()),
Math.toIntExact(Math.min(end - start + 1, blobContents.length() - start)) Math.toIntExact(Math.min(range.end() - range.start() + 1, blobContents.length() - range.start()))
); );
successStatus = RestStatus.PARTIAL_CONTENT; successStatus = RestStatus.PARTIAL_CONTENT;
} else { } else {

View file

@ -24,6 +24,7 @@ import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.core.Tuple; import org.elasticsearch.core.Tuple;
import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.RestUtils; import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
@ -58,8 +59,6 @@ public class GoogleCloudStorageHttpHandler implements HttpHandler {
private static final Logger logger = LogManager.getLogger(GoogleCloudStorageHttpHandler.class); private static final Logger logger = LogManager.getLogger(GoogleCloudStorageHttpHandler.class);
private static final Pattern RANGE_MATCHER = Pattern.compile("bytes=([0-9]*)-([0-9]*)");
private final ConcurrentMap<String, BytesReference> blobs; private final ConcurrentMap<String, BytesReference> blobs;
private final String bucket; private final String bucket;
@ -131,19 +130,19 @@ public class GoogleCloudStorageHttpHandler implements HttpHandler {
// Download Object https://cloud.google.com/storage/docs/request-body // Download Object https://cloud.google.com/storage/docs/request-body
BytesReference blob = blobs.get(exchange.getRequestURI().getPath().replace("/download/storage/v1/b/" + bucket + "/o/", "")); BytesReference blob = blobs.get(exchange.getRequestURI().getPath().replace("/download/storage/v1/b/" + bucket + "/o/", ""));
if (blob != null) { if (blob != null) {
final String range = exchange.getRequestHeaders().getFirst("Range"); final String rangeHeader = exchange.getRequestHeaders().getFirst("Range");
final long offset; final long offset;
final long end; final long end;
if (range == null) { if (rangeHeader == null) {
offset = 0L; offset = 0L;
end = blob.length() - 1; end = blob.length() - 1;
} else { } else {
Matcher matcher = RANGE_MATCHER.matcher(range); final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader);
if (matcher.find() == false) { if (range == null) {
throw new AssertionError("Range bytes header does not match expected format: " + range); throw new AssertionError("Range bytes header does not match expected format: " + rangeHeader);
} }
offset = Long.parseLong(matcher.group(1)); offset = range.start();
end = Long.parseLong(matcher.group(2)); end = range.end();
} }
if (offset >= blob.length()) { if (offset >= blob.length()) {

View file

@ -15,5 +15,5 @@ dependencies {
api("junit:junit:${versions.junit}") { api("junit:junit:${versions.junit}") {
transitive = false transitive = false
} }
testImplementation project(':test:framework') implementation project(':test:framework')
} }

View file

@ -28,6 +28,7 @@ import org.elasticsearch.logging.LogManager;
import org.elasticsearch.logging.Logger; import org.elasticsearch.logging.Logger;
import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.rest.RestUtils; import org.elasticsearch.rest.RestUtils;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@ -269,8 +270,8 @@ public class S3HttpHandler implements HttpHandler {
exchange.sendResponseHeaders(RestStatus.NOT_FOUND.getStatus(), -1); exchange.sendResponseHeaders(RestStatus.NOT_FOUND.getStatus(), -1);
return; return;
} }
final String range = exchange.getRequestHeaders().getFirst("Range"); final String rangeHeader = exchange.getRequestHeaders().getFirst("Range");
if (range == null) { if (rangeHeader == null) {
exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.getResponseHeaders().add("Content-Type", "application/octet-stream");
exchange.sendResponseHeaders(RestStatus.OK.getStatus(), blob.length()); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), blob.length());
blob.writeTo(exchange.getResponseBody()); blob.writeTo(exchange.getResponseBody());
@ -281,17 +282,12 @@ public class S3HttpHandler implements HttpHandler {
// requests with a header value like "Range: bytes=start-end" where both {@code start} and {@code end} are always defined // requests with a header value like "Range: bytes=start-end" where both {@code start} and {@code end} are always defined
// (sometimes to very high value for {@code end}). It would be too tedious to fully support the RFC so S3HttpHandler only // (sometimes to very high value for {@code end}). It would be too tedious to fully support the RFC so S3HttpHandler only
// supports when both {@code start} and {@code end} are defined to match the SDK behavior. // supports when both {@code start} and {@code end} are defined to match the SDK behavior.
final Matcher matcher = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$").matcher(range); final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader);
if (matcher.matches() == false) { if (range == null) {
throw new AssertionError("Bytes range does not match expected pattern: " + range); throw new AssertionError("Bytes range does not match expected pattern: " + rangeHeader);
} }
var groupStart = matcher.group(1); long start = range.start();
var groupEnd = matcher.group(2); long end = range.end();
if (groupStart == null || groupEnd == null) {
throw new AssertionError("Bytes range does not match expected pattern: " + range);
}
long start = Long.parseLong(groupStart);
long end = Long.parseLong(groupEnd);
if (end < start) { if (end < start) {
exchange.getResponseHeaders().add("Content-Type", "application/octet-stream"); exchange.getResponseHeaders().add("Content-Type", "application/octet-stream");
exchange.sendResponseHeaders(RestStatus.OK.getStatus(), blob.length()); exchange.sendResponseHeaders(RestStatus.OK.getStatus(), blob.length());

View file

@ -10,6 +10,7 @@ package fixture.url;
import org.elasticsearch.rest.RestStatus; import org.elasticsearch.rest.RestStatus;
import org.elasticsearch.test.fixture.AbstractHttpFixture; import org.elasticsearch.test.fixture.AbstractHttpFixture;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import org.junit.rules.TemporaryFolder; import org.junit.rules.TemporaryFolder;
import org.junit.rules.TestRule; import org.junit.rules.TestRule;
@ -21,15 +22,12 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** /**
* This {@link URLFixture} exposes a filesystem directory over HTTP. It is used in repository-url * This {@link URLFixture} exposes a filesystem directory over HTTP. It is used in repository-url
* integration tests to expose a directory created by a regular FS repository. * integration tests to expose a directory created by a regular FS repository.
*/ */
public class URLFixture extends AbstractHttpFixture implements TestRule { public class URLFixture extends AbstractHttpFixture implements TestRule {
private static final Pattern RANGE_PATTERN = Pattern.compile("bytes=(\\d+)-(\\d+)$");
private final TemporaryFolder temporaryFolder; private final TemporaryFolder temporaryFolder;
private Path repositoryDir; private Path repositoryDir;
@ -60,19 +58,19 @@ public class URLFixture extends AbstractHttpFixture implements TestRule {
if (normalizedPath.startsWith(normalizedRepositoryDir)) { if (normalizedPath.startsWith(normalizedRepositoryDir)) {
if (Files.exists(normalizedPath) && Files.isReadable(normalizedPath) && Files.isRegularFile(normalizedPath)) { if (Files.exists(normalizedPath) && Files.isReadable(normalizedPath) && Files.isRegularFile(normalizedPath)) {
final String range = request.getHeader("Range"); final String rangeHeader = request.getHeader("Range");
final Map<String, String> headers = new HashMap<>(contentType("application/octet-stream")); final Map<String, String> headers = new HashMap<>(contentType("application/octet-stream"));
if (range == null) { if (rangeHeader == null) {
byte[] content = Files.readAllBytes(normalizedPath); byte[] content = Files.readAllBytes(normalizedPath);
headers.put("Content-Length", String.valueOf(content.length)); headers.put("Content-Length", String.valueOf(content.length));
return new Response(RestStatus.OK.getStatus(), headers, content); return new Response(RestStatus.OK.getStatus(), headers, content);
} else { } else {
final Matcher matcher = RANGE_PATTERN.matcher(range); final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader);
if (matcher.matches() == false) { if (range == null) {
return new Response(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), TEXT_PLAIN_CONTENT_TYPE, EMPTY_BYTE); return new Response(RestStatus.REQUESTED_RANGE_NOT_SATISFIED.getStatus(), TEXT_PLAIN_CONTENT_TYPE, EMPTY_BYTE);
} else { } else {
long start = Long.parseLong(matcher.group(1)); long start = range.start();
long end = Long.parseLong(matcher.group(2)); long end = range.end();
long rangeLength = end - start + 1; long rangeLength = end - start + 1;
final long fileSize = Files.size(normalizedPath); final long fileSize = Files.size(normalizedPath);
if (start >= fileSize || start > end || rangeLength > fileSize) { if (start >= fileSize || start > end || rangeLength > fileSize) {

View file

@ -23,9 +23,9 @@ import org.elasticsearch.common.util.concurrent.CountDown;
import org.elasticsearch.core.Nullable; import org.elasticsearch.core.Nullable;
import org.elasticsearch.core.SuppressForbidden; import org.elasticsearch.core.SuppressForbidden;
import org.elasticsearch.core.TimeValue; import org.elasticsearch.core.TimeValue;
import org.elasticsearch.core.Tuple;
import org.elasticsearch.mocksocket.MockHttpServer; import org.elasticsearch.mocksocket.MockHttpServer;
import org.elasticsearch.test.ESTestCase; import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import org.junit.After; import org.junit.After;
import org.junit.Before; import org.junit.Before;
@ -40,8 +40,6 @@ import java.util.Locale;
import java.util.OptionalInt; import java.util.OptionalInt;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.elasticsearch.repositories.blobstore.BlobStoreTestUtil.randomPurpose; import static org.elasticsearch.repositories.blobstore.BlobStoreTestUtil.randomPurpose;
import static org.elasticsearch.test.NeverMatcher.never; import static org.elasticsearch.test.NeverMatcher.never;
@ -371,28 +369,24 @@ public abstract class AbstractBlobContainerRetriesTestCase extends ESTestCase {
return randomByteArrayOfLength(randomIntBetween(minSize, frequently() ? 512 : 1 << 20)); // rarely up to 1mb return randomByteArrayOfLength(randomIntBetween(minSize, frequently() ? 512 : 1 << 20)); // rarely up to 1mb
} }
private static final Pattern RANGE_PATTERN = Pattern.compile("^bytes=([0-9]+)-([0-9]+)$"); protected static HttpHeaderParser.Range getRange(HttpExchange exchange) {
protected static Tuple<Long, Long> getRange(HttpExchange exchange) {
final String rangeHeader = exchange.getRequestHeaders().getFirst("Range"); final String rangeHeader = exchange.getRequestHeaders().getFirst("Range");
if (rangeHeader == null) { if (rangeHeader == null) {
return Tuple.tuple(0L, MAX_RANGE_VAL); return new HttpHeaderParser.Range(0L, MAX_RANGE_VAL);
} }
final Matcher matcher = RANGE_PATTERN.matcher(rangeHeader); final HttpHeaderParser.Range range = HttpHeaderParser.parseRangeHeader(rangeHeader);
assertTrue(rangeHeader + " matches expected pattern", matcher.matches()); assertNotNull(rangeHeader + " matches expected pattern", range);
long rangeStart = Long.parseLong(matcher.group(1)); assertThat(range.start(), lessThanOrEqualTo(range.end()));
long rangeEnd = Long.parseLong(matcher.group(2)); return range;
assertThat(rangeStart, lessThanOrEqualTo(rangeEnd));
return Tuple.tuple(rangeStart, rangeEnd);
} }
protected static int getRangeStart(HttpExchange exchange) { protected static int getRangeStart(HttpExchange exchange) {
return Math.toIntExact(getRange(exchange).v1()); return Math.toIntExact(getRange(exchange).start());
} }
protected static OptionalInt getRangeEnd(HttpExchange exchange) { protected static OptionalInt getRangeEnd(HttpExchange exchange) {
final long rangeEnd = getRange(exchange).v2(); final long rangeEnd = getRange(exchange).end();
if (rangeEnd == MAX_RANGE_VAL) { if (rangeEnd == MAX_RANGE_VAL) {
return OptionalInt.empty(); return OptionalInt.empty();
} }

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package org.elasticsearch.test.fixture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public enum HttpHeaderParser {
;
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("bytes=([0-9]+)-([0-9]+)");
/**
* Parse a "Range" header
*
* Note: only a single bounded range is supported (e.g. <code>Range: bytes={range_start}-{range_end}</code>)
*
* @see <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range">MDN: Range header</a>
* @param rangeHeaderValue The header value as a string
* @return a {@link Range} instance representing the parsed value, or null if the header is malformed
*/
public static Range parseRangeHeader(String rangeHeaderValue) {
final Matcher matcher = RANGE_HEADER_PATTERN.matcher(rangeHeaderValue);
if (matcher.matches()) {
try {
return new Range(Long.parseLong(matcher.group(1)), Long.parseLong(matcher.group(2)));
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
public record Range(long start, long end) {}
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
package org.elasticsearch.http;
import org.elasticsearch.common.Strings;
import org.elasticsearch.test.ESTestCase;
import org.elasticsearch.test.fixture.HttpHeaderParser;
import java.math.BigInteger;
public class HttpHeaderParserTests extends ESTestCase {
public void testParseRangeHeader() {
final long start = randomLongBetween(0, 10_000);
final long end = randomLongBetween(start, start + 10_000);
assertEquals(new HttpHeaderParser.Range(start, end), HttpHeaderParser.parseRangeHeader("bytes=" + start + "-" + end));
}
public void testParseRangeHeaderInvalidLong() {
final BigInteger longOverflow = BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE).add(randomBigInteger());
assertNull(HttpHeaderParser.parseRangeHeader("bytes=123-" + longOverflow));
assertNull(HttpHeaderParser.parseRangeHeader("bytes=" + longOverflow + "-123"));
}
public void testParseRangeHeaderMultipleRangesNotMatched() {
assertNull(
HttpHeaderParser.parseRangeHeader(
Strings.format(
"bytes=%d-%d,%d-%d",
randomIntBetween(0, 99),
randomIntBetween(100, 199),
randomIntBetween(200, 299),
randomIntBetween(300, 399)
)
)
);
}
public void testParseRangeHeaderEndlessRangeNotMatched() {
assertNull(HttpHeaderParser.parseRangeHeader(Strings.format("bytes=%d-", randomLongBetween(0, Long.MAX_VALUE))));
}
public void testParseRangeHeaderSuffixLengthNotMatched() {
assertNull(HttpHeaderParser.parseRangeHeader(Strings.format("bytes=-%d", randomLongBetween(0, Long.MAX_VALUE))));
}
}