From 13f349aea2447eb6b3a636e536cfe5de5ffd0ab1 Mon Sep 17 00:00:00 2001 From: rmg-x Date: Mon, 18 Nov 2024 17:34:30 -0600 Subject: [PATCH] LibWeb/Fetch: Implement blob range section of scheme fetch specification --- Libraries/LibWeb/Fetch/Fetching/Fetching.cpp | 76 ++++-- .../wpt-import/xhr/blob-range.any.txt | 37 +++ .../input/wpt-import/xhr/blob-range.any.html | 15 ++ .../input/wpt-import/xhr/blob-range.any.js | 246 ++++++++++++++++++ 4 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 Tests/LibWeb/Text/expected/wpt-import/xhr/blob-range.any.txt create mode 100644 Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.html create mode 100644 Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.js diff --git a/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp b/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp index 8b647c1a1d8..e333f0a7f89 100644 --- a/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp +++ b/Libraries/LibWeb/Fetch/Fetching/Fetching.cpp @@ -860,33 +860,79 @@ WebIDL::ExceptionOr> scheme_fetch(JS::Realm& realm, Inf auto content_type_header = Infrastructure::Header::from_string_pair("Content-Type"sv, type); response->header_list()->append(move(content_type_header)); } - // FIXME: 9. Otherwise: + // 9. Otherwise: else { // 1. Set response’s range-requested flag. + response->set_range_requested(true); + // 2. Let rangeHeader be the result of getting `Range` from request’s header list. + auto const range_header = request->header_list()->get("Range"sv.bytes()).value_or(ByteBuffer {}); + // 3. Let rangeValue be the result of parsing a single range header value given rangeHeader and true. + auto maybe_range_value = Infrastructure::parse_single_range_header_value(range_header, true); + // 4. If rangeValue is failure, then return a network error. + if (!maybe_range_value.has_value()) + return PendingResponse::create(vm, request, Infrastructure::Response::network_error(vm, "Failed to parse single range header value"sv)); + // 5. Let (rangeStart, rangeEnd) be rangeValue. + auto& [range_start, range_end] = maybe_range_value.value(); + // 6. If rangeStart is null: - // 1. Set rangeStart to fullLength − rangeEnd. - // 2. Set rangeEnd to rangeStart + rangeEnd − 1. + if (!range_start.has_value()) { + VERIFY(range_end.has_value()); + + // 1. Set rangeStart to fullLength − rangeEnd. + range_start = full_length - *range_end; + + // 2. Set rangeEnd to rangeStart + rangeEnd − 1. + range_end = *range_start + *range_end - 1; + } // 7. Otherwise: - // 1. If rangeStart is greater than or equal to fullLength, then return a network error. - // 2. If rangeEnd is null or rangeEnd is greater than or equal to fullLength, then set rangeEnd to fullLength − 1. + else { + // 1. If rangeStart is greater than or equal to fullLength, then return a network error. + if (*range_start >= full_length) + return PendingResponse::create(vm, request, Infrastructure::Response::network_error(vm, "rangeStart is greater than or equal to fullLength"sv)); + + // 2. If rangeEnd is null or rangeEnd is greater than or equal to fullLength, then set rangeEnd to fullLength − 1. + if (!range_end.has_value() || *range_end >= full_length) + range_end = full_length - 1; + } + // 8. Let slicedBlob be the result of invoking slice blob given blob, rangeStart, rangeEnd + 1, and type. + auto sliced_blob = TRY(blob->slice(*range_start, *range_end + 1, type)); + // 9. Let slicedBodyWithType be the result of safely extracting slicedBlob. + auto sliced_body_with_type = TRY(safely_extract_body(realm, sliced_blob->raw_bytes())); + // 10. Set response’s body to slicedBodyWithType’s body. + response->set_body(sliced_body_with_type.body); + // 11. Let serializedSlicedLength be slicedBlob’s size, serialized and isomorphic encoded. - // 12. Let contentRange be `bytes `. - // 13. Append rangeStart, serialized and isomorphic encoded, to contentRange. - // 14. Append 0x2D (-) to contentRange. - // 15. Append rangeEnd, serialized and isomorphic encoded to contentRange. - // 16. Append 0x2F (/) to contentRange. - // 17. Append serializedFullLength to contentRange. - // 18. Set response’s status to 206. - // 19. Set response’s status message to `Partial Content`. - // 20. Set response’s header list to « (`Content-Length`, serializedSlicedLength), (`Content-Type`, type), (`Content-Range`, contentRange) ». - return PendingResponse::create(vm, request, Infrastructure::Response::network_error(vm, "Request has a 'blob:' URL with a Content-Range header, which is currently unsupported"sv)); + auto serialized_sliced_length = String::number(sliced_blob->size()); + + // 12. Let contentRange be the result of invoking build a content range given rangeStart, rangeEnd, and fullLength. + auto content_range = Infrastructure::build_content_range(*range_start, *range_end, full_length); + + // 13. Set response’s status to 206. + response->set_status(206); + + // 14. Set response’s status message to `Partial Content`. + response->set_status_message(MUST(ByteBuffer::copy("Partial Content"sv.bytes()))); + + // 15. Set response’s header list to « + + // (`Content-Length`, serializedSlicedLength), + auto content_length_header = Infrastructure::Header::from_string_pair("Content-Length"sv, serialized_sliced_length); + response->header_list()->append(move(content_length_header)); + + // (`Content-Type`, type), + auto content_type_header = Infrastructure::Header::from_string_pair("Content-Type"sv, type); + response->header_list()->append(move(content_type_header)); + + // (`Content-Range`, contentRange) ». + auto content_range_header = Infrastructure::Header::from_string_pair("Content-Range"sv, content_range); + response->header_list()->append(move(content_range_header)); } // 10. Return response. diff --git a/Tests/LibWeb/Text/expected/wpt-import/xhr/blob-range.any.txt b/Tests/LibWeb/Text/expected/wpt-import/xhr/blob-range.any.txt new file mode 100644 index 00000000000..b1655aec300 --- /dev/null +++ b/Tests/LibWeb/Text/expected/wpt-import/xhr/blob-range.any.txt @@ -0,0 +1,37 @@ +Summary + +Harness status: OK + +Rerun + +Found 27 tests + +27 Pass +Details +Result Test Name MessagePass A simple blob range request. +Pass A blob range request with no type. +Pass A blob range request with no end. +Pass A blob range request with no start. +Pass A simple blob range request with whitespace. +Pass Blob content with short content and a large range end +Pass Blob content with short content and a range end matching content length +Pass Blob range with whitespace before and after hyphen +Pass Blob range with whitespace after hyphen +Pass Blob range with whitespace around equals sign +Pass Blob range with no value +Pass Blob range with incorrect range header +Pass Blob range with incorrect range header #2 +Pass Blob range with incorrect range header #3 +Pass Blob range request with multiple range values +Pass Blob range request with multiple range values and whitespace +Pass Blob range request with trailing comma +Pass Blob range with no start or end +Pass Blob range request with short range end +Pass Blob range start should be an ASCII digit +Pass Blob range should have a dash +Pass Blob range end should be an ASCII digit +Pass Blob range should include '-' +Pass Blob range should include '=' +Pass Blob range should include 'bytes=' +Pass Blob content with short content and a large range start +Pass Blob content with short content and a range start matching the content length \ No newline at end of file diff --git a/Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.html b/Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.html new file mode 100644 index 00000000000..4e644f7d72e --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.html @@ -0,0 +1,15 @@ + + + + + + + +
+ diff --git a/Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.js b/Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.js new file mode 100644 index 00000000000..2a5c54fc34f --- /dev/null +++ b/Tests/LibWeb/Text/input/wpt-import/xhr/blob-range.any.js @@ -0,0 +1,246 @@ +// See also /fetch/range/blob.any.js + +const supportedBlobRange = [ + { + name: "A simple blob range request.", + data: ["A simple Hello, World! example"], + type: "text/plain", + range: "bytes=9-21", + content_length: 13, + content_range: "bytes 9-21/30", + result: "Hello, World!", + }, + { + name: "A blob range request with no type.", + data: ["A simple Hello, World! example"], + type: undefined, + range: "bytes=9-21", + content_length: 13, + content_range: "bytes 9-21/30", + result: "Hello, World!", + }, + { + name: "A blob range request with no end.", + data: ["Range with no end"], + type: "text/plain", + range: "bytes=11-", + content_length: 6, + content_range: "bytes 11-16/17", + result: "no end", + }, + { + name: "A blob range request with no start.", + data: ["Range with no start"], + type: "text/plain", + range: "bytes=-8", + content_length: 8, + content_range: "bytes 11-18/19", + result: "no start", + }, + { + name: "A simple blob range request with whitespace.", + data: ["A simple Hello, World! example"], + type: "text/plain", + range: "bytes= \t9-21", + content_length: 13, + content_range: "bytes 9-21/30", + result: "Hello, World!", + }, + { + name: "Blob content with short content and a large range end", + data: ["Not much here"], + type: "text/plain", + range: "bytes=4-100000000000", + content_length: 9, + content_range: "bytes 4-12/13", + result: "much here", + }, + { + name: "Blob content with short content and a range end matching content length", + data: ["Not much here"], + type: "text/plain", + range: "bytes=4-13", + content_length: 9, + content_range: "bytes 4-12/13", + result: "much here", + }, + { + name: "Blob range with whitespace before and after hyphen", + data: ["Valid whitespace #1"], + type: "text/plain", + range: "bytes=5 - 10", + content_length: 6, + content_range: "bytes 5-10/19", + result: " white", + }, + { + name: "Blob range with whitespace after hyphen", + data: ["Valid whitespace #2"], + type: "text/plain", + range: "bytes=-\t 5", + content_length: 5, + content_range: "bytes 14-18/19", + result: "ce #2", + }, + { + name: "Blob range with whitespace around equals sign", + data: ["Valid whitespace #3"], + type: "text/plain", + range: "bytes \t =\t 6-", + content_length: 13, + content_range: "bytes 6-18/19", + result: "whitespace #3", + }, +]; + +const unsupportedBlobRange = [ + { + name: "Blob range with no value", + data: ["Blob range should have a value"], + type: "text/plain", + range: "", + }, + { + name: "Blob range with incorrect range header", + data: ["A"], + type: "text/plain", + range: "byte=0-" + }, + { + name: "Blob range with incorrect range header #2", + data: ["A"], + type: "text/plain", + range: "bytes" + }, + { + name: "Blob range with incorrect range header #3", + data: ["A"], + type: "text/plain", + range: "bytes\t \t" + }, + { + name: "Blob range request with multiple range values", + data: ["Multiple ranges are not currently supported"], + type: "text/plain", + range: "bytes=0-5,15-", + }, + { + name: "Blob range request with multiple range values and whitespace", + data: ["Multiple ranges are not currently supported"], + type: "text/plain", + range: "bytes=0-5, 15-", + }, + { + name: "Blob range request with trailing comma", + data: ["Range with invalid trailing comma"], + type: "text/plain", + range: "bytes=0-5,", + }, + { + name: "Blob range with no start or end", + data: ["Range with no start or end"], + type: "text/plain", + range: "bytes=-", + }, + { + name: "Blob range request with short range end", + data: ["Range end should be greater than range start"], + type: "text/plain", + range: "bytes=10-5", + }, + { + name: "Blob range start should be an ASCII digit", + data: ["Range start must be an ASCII digit"], + type: "text/plain", + range: "bytes=x-5", + }, + { + name: "Blob range should have a dash", + data: ["Blob range should have a dash"], + type: "text/plain", + range: "bytes=5", + }, + { + name: "Blob range end should be an ASCII digit", + data: ["Range end must be an ASCII digit"], + type: "text/plain", + range: "bytes=5-x", + }, + { + name: "Blob range should include '-'", + data: ["Range end must include '-'"], + type: "text/plain", + range: "bytes=x", + }, + { + name: "Blob range should include '='", + data: ["Range end must include '='"], + type: "text/plain", + range: "bytes 5-", + }, + { + name: "Blob range should include 'bytes='", + data: ["Range end must include 'bytes='"], + type: "text/plain", + range: "5-", + }, + { + name: "Blob content with short content and a large range start", + data: ["Not much here"], + type: "text/plain", + range: "bytes=100000-", + }, + { + name: "Blob content with short content and a range start matching the content length", + data: ["Not much here"], + type: "text/plain", + range: "bytes=13-", + }, +]; + +supportedBlobRange.forEach(({ name, data, type, range, content_length, content_range, result }) => { + promise_test(async t => { + const blob = new Blob(data, { "type" : type }); + const blobURL = URL.createObjectURL(blob); + t.add_cleanup(() => URL.revokeObjectURL(blobURL)); + const xhr = new XMLHttpRequest(); + xhr.open("GET", blobURL); + xhr.responseType = "text"; + xhr.setRequestHeader("Range", range); + await new Promise(resolve => { + xhr.onloadend = resolve; + xhr.send(); + }); + assert_equals(xhr.status, 206, "HTTP status is 206"); + assert_equals(xhr.getResponseHeader("Content-Type"), type || "", "Content-Type is " + xhr.getResponseHeader("Content-Type")); + assert_equals(xhr.getResponseHeader("Content-Length"), content_length.toString(), "Content-Length is " + xhr.getResponseHeader("Content-Length")); + assert_equals(xhr.getResponseHeader("Content-Range"), content_range, "Content-Range is " + xhr.getResponseHeader("Content-Range")); + assert_equals(xhr.responseText, result, "Response's body is correct"); + const all = xhr.getAllResponseHeaders().toLowerCase(); + assert_true(all.includes(`content-type: ${type || ""}`), "Expected Content-Type in getAllResponseHeaders()"); + assert_true(all.includes(`content-length: ${content_length}`), "Expected Content-Length in getAllResponseHeaders()"); + assert_true(all.includes(`content-range: ${content_range}`), "Expected Content-Range in getAllResponseHeaders()") + }, name); +}); + +unsupportedBlobRange.forEach(({ name, data, type, range }) => { + promise_test(t => { + const blob = new Blob(data, { "type" : type }); + const blobURL = URL.createObjectURL(blob); + t.add_cleanup(() => URL.revokeObjectURL(blobURL)); + + const xhr = new XMLHttpRequest(); + xhr.open("GET", blobURL, false); + xhr.setRequestHeader("Range", range); + assert_throws_dom("NetworkError", () => xhr.send()); + + xhr.open("GET", blobURL); + xhr.setRequestHeader("Range", range); + xhr.responseType = "text"; + return new Promise((resolve, reject) => { + xhr.onload = reject; + xhr.onerror = resolve; + xhr.send(); + }); + }, name); +});