diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c7bd9f..1483d80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,9 @@ include(FetchContent) FetchContent_Declare(arcana.cpp GIT_REPOSITORY https://github.com/microsoft/arcana.cpp.git GIT_TAG 7c6be8aaa29effbc8ee1a2217388fb6e399150d2) +FetchContent_Declare(base-n + GIT_REPOSITORY https://github.com/azawadzki/base-n.git + GIT_TAG 7573e77c0b9b0e8a5fb63d96dbde212c921993b4) FetchContent_Declare(AndroidExtensions GIT_REPOSITORY https://github.com/BabylonJS/AndroidExtensions.git GIT_TAG 2e85a8d43b89246c460112c9e5546ad54b6e87b4) @@ -31,6 +34,7 @@ endif() project(UrlLib) FetchContent_MakeAvailable_With_Message(arcana.cpp) +FetchContent_MakeAvailable_With_Message(base-n) if(ANDROID) FetchContent_MakeAvailable_With_Message(AndroidExtensions) @@ -81,6 +85,7 @@ endif() set(SOURCES "Include/UrlLib/UrlLib.h" "Source/UrlRequest_Base.h" + "Source/DataUrl.h" "Source/UrlRequest_Shared.h" "Source/WebSocket_Base.h" "Source/WebSocket_Shared.h" @@ -90,6 +95,7 @@ add_library(UrlLib ${SOURCES}) target_include_directories(UrlLib PUBLIC "Include") target_include_directories(UrlLib PRIVATE "Source") +target_include_directories(UrlLib PRIVATE "${base-n_SOURCE_DIR}/include") target_link_libraries(UrlLib PUBLIC arcana diff --git a/Include/UrlLib/UrlLib.h b/Include/UrlLib/UrlLib.h index 9ded3b7..f19fe52 100644 --- a/Include/UrlLib/UrlLib.h +++ b/Include/UrlLib/UrlLib.h @@ -61,6 +61,7 @@ namespace UrlLib void Abort(); + // data: GET URLs are decoded in the shared layer (see README.md for the supported contract). void Open(UrlMethod method, const std::string& url); // Registers a resolver for a non-transport URL scheme such as "blob". Registration is @@ -69,6 +70,8 @@ namespace UrlLib // SendAsync() time, so every consumer (fetch, XMLHttpRequest, image / video src, texture // loaders, ...) resolves such URLs uniformly through UrlRequest instead of each carrying // its own branch. + // A registered "data" resolver overrides the built-in decoder until unregistered. + // Shared resolvers observe Abort() before invocation, returning a cancelled task. // // A resolver that reports the URL as not handled -- or that throws -- surfaces as a // transport-style failure (status stays None, with an error symbol recorded); an exception diff --git a/NOTICE.md b/NOTICE.md index ae060fa..f1f88a2 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -71,6 +71,30 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE ``` +## base-n + +``` +Copyright (C) 2012 Andrzej Zawadzki (azawadzki@gmail.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ## boost ``` diff --git a/README.md b/README.md index a7d0434..ee2d415 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,71 @@ Platform support: the Apple (`NSURLSession`) and Linux (`libcurl`) backends popu these accessors today; the Windows and Android backends currently always report empty/zero (contributions welcome — the plumbing in `UrlRequest_Base.h` is shared). +## In-memory data URLs + +`UrlRequest` provides a built-in, platform-independent `data:` GET decoder through the +same shared scheme-resolution path as registered resolvers. No network transport or +consumer-specific image/fetch wrapper is involved. The supported contract is: + +* `data:[type/subtype[;name=value...]][;base64],payload`, with a required comma. + An omitted type defaults to `text/plain;charset=US-ASCII`; the `;charset=...` + shorthand uses `text/plain`. Explicit MIME types use ASCII HTTP token syntax; + parameter values are nonempty tokens or ASCII quoted strings with backslash escapes. + Type/subtype and parameter names are lowercased; parameter values are retained. + Invalid or unsupported MIME syntax is rejected, not replaced by a guessed MIME type. +* Scheme and the final `;base64` marker are ASCII case-insensitive. Surrounding ASCII + whitespace in the metadata, and spaces between that semicolon and `base64`, are allowed. + The payload is percent-decoded to **bytes** before optional base64 decoding. Literal + `+` is preserved; incomplete or non-hex percent escapes remain literal, as in URL percent + decoding. Raw non-ASCII payload bytes are preserved; callers should supply UTF-8 or + percent-encoded bytes, not expect locale-dependent URI unescaping. +* Base64 uses the standard alphabet and forgiving-base64 rules: ASCII whitespace is ignored, + padding may be omitted, and unused trailing bits are ignored. Bad alphabet characters, + misplaced/excess padding, and lengths congruent to one modulo four are errors. Base64url + (`-`/`_`) is not supported. +* A literal `?` belongs to the payload (or to the metadata if before the comma), not a + separate query to discard. A literal `#` starts a fragment, which is excluded from decoding + and `ResponseUrl()`. Encode these bytes as `%3F`/`%23` to include them literally. +* Success is status 200 / `"OK"`, with a `content-type` header and the decoded body, including + empty bodies. `Buffer` returns exact bytes; `String` also preserves those bytes (including + NUL), without charset transcoding, matching the shared resolver contract. +* Decoding is synchronous at `SendAsync()`, once per `Open()`. Malformed/unsupported input + completes normally with status 0, empty body/headers, and a diagnostic `DataUrlInvalid` + error. Non-GET requests similarly report `DataUrlUnsupportedMethod`; other decoder + exceptions report `DataUrlFailed`. No invalid payload is passed to the platform transport. + `Abort()` before sending prevents shared resolution and returns an operation-cancelled task, + as for cancellation-guarded Windows continuations. There is no asynchronous decoding to + interrupt; aborting an already-completed request does not erase its response. As with the + existing transport cancellation source, reopening an aborted request does not reset cancellation. + +This is a bounded byte/MIME transport, not an implementation of all Fetch behavior: it +does not implement browser URL canonicalization, permissive MIME-parser recovery, +charset conversion, streaming, CORS, or a payload-size policy. Supply a serialized URL +without surrounding control characters. A resolver explicitly registered for `"data"` +overrides the built-in decoder; unregistering it restores the built-in behavior. +Base64 decoding reuses the pinned `base-n` helper, with validation before invoking its +otherwise permissive decoder. + +On Windows, a successful HTTP response without a Content-Type header leaves that header +absent rather than dereferencing the optional WinRT value or inventing a MIME type. +Existing Windows HTTP response behavior is otherwise unchanged: successful 2xx responses normalize +to 200 after body reading; non-success responses retain their wire status and do not read +their body or publish response headers. + +Windows POST matches Content-Type case-insensitively and sets it on the content headers, +not the request headers. Bodies are sent byte-for-byte without UTF-8 transcoding or an +automatically added charset. Omitted Content-Type stays absent; supplied MIME parameters +are preserved, and an invalid supplied MIME type reports a transport error rather than success. + +Apple POST also preserves body bytes, using length-aware NSData rather than a +NUL-terminated UTF-8 string. The shared POST wire tests run on Windows and Apple; +Linux explicitly skips them because its backend does not implement POST yet. + +Android POST writes raw byte-array chunks to the connection's output stream instead of +converting the body to a Java string. This preserves embedded NULs and UTF-8 bytes while +keeping Content-Length consistent with the supplied body. Android coverage comes from +JsRuntimeHost's Fetch/XHR HTTP transport tests. + ## Contributing Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for details on our code of conduct, and diff --git a/Source/DataUrl.h b/Source/DataUrl.h new file mode 100644 index 0000000..11d3821 --- /dev/null +++ b/Source/DataUrl.h @@ -0,0 +1,225 @@ +#pragma once + +#include +#include + +#include +#include +#include + +namespace UrlLib::Detail +{ + inline char AsciiLower(char value) + { + return value >= 'A' && value <= 'Z' ? static_cast(value + ('a' - 'A')) : value; + } + + inline bool IsAsciiWhitespace(char value) + { + return value == ' ' || value == '\t' || value == '\n' || value == '\r' || value == '\f'; + } + + inline std::string_view TrimAsciiWhitespace(std::string_view value) + { + while (!value.empty() && IsAsciiWhitespace(value.front())) + { + value.remove_prefix(1); + } + while (!value.empty() && IsAsciiWhitespace(value.back())) + { + value.remove_suffix(1); + } + return value; + } + + inline int HexDigit(char value) + { + if (value >= '0' && value <= '9') + { + return value - '0'; + } + value = AsciiLower(value); + return value >= 'a' && value <= 'f' ? value - 'a' + 10 : -1; + } + + template + inline void PercentDecodeBytes(std::string_view value, Container& bytes) + { + bytes.reserve(value.size()); + for (size_t index = 0; index < value.size(); ++index) + { + if (value[index] == '%' && index + 2 < value.size()) + { + const int high = HexDigit(value[index + 1]); + const int low = HexDigit(value[index + 2]); + if (high >= 0 && low >= 0) + { + bytes.push_back(static_cast((high << 4) | low)); + index += 2; + continue; + } + } + // URL percent decoding preserves incomplete/non-hex escapes and does not map '+' to space. + bytes.push_back(static_cast(static_cast(value[index]))); + } + } + + inline bool IsMimeToken(char value) + { + return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || + (value >= '0' && value <= '9') || + std::string_view{"!#$%&'*+-.^_`|~"}.find(value) != std::string_view::npos; + } + + inline std::string DataContentType(std::string_view metadata) + { + if (metadata.empty()) + { + return "text/plain;charset=US-ASCII"; + } + std::string type{metadata.front() == ';' ? "text/plain" : ""}; + type.append(metadata); + const auto separator = type.find(';'); + const std::string_view essence{type.data(), separator == std::string::npos ? type.size() : separator}; + const auto slash = essence.find('/'); + if (slash == std::string_view::npos || slash == 0 || slash + 1 == essence.size() || + !std::all_of(essence.begin(), essence.begin() + slash, IsMimeToken) || + !std::all_of(essence.begin() + slash + 1, essence.end(), IsMimeToken)) + { + throw std::invalid_argument{"data URL requires a valid MIME type"}; + } + std::transform(type.begin(), type.begin() + essence.size(), type.begin(), AsciiLower); + + size_t index = essence.size(); + while (index < type.size()) + { + ++index; // semicolon + while (index < type.size() && type[index] == ' ') + { + ++index; + } + const size_t name = index; + while (index < type.size() && IsMimeToken(type[index])) + { + type[index] = AsciiLower(type[index]); + ++index; + } + if (index == name || index == type.size() || type[index++] != '=') + { + throw std::invalid_argument{"data URL requires name=value MIME parameters"}; + } + if (index < type.size() && type[index] == '"') + { + ++index; + bool closed = false; + while (index < type.size()) + { + auto value = static_cast(type[index++]); + if (value == '"') + { + closed = true; + break; + } + if (value == '\\' && index < type.size()) + { + value = static_cast(type[index++]); + } + if (value < 0x20 || value >= 0x7f) + { + throw std::invalid_argument{"data URL MIME parameter contains a non-ASCII/control byte"}; + } + } + if (!closed) + { + throw std::invalid_argument{"data URL MIME parameter has an unterminated quote"}; + } + } + else + { + const size_t start = index; + while (index < type.size() && IsMimeToken(type[index])) + { + ++index; + } + if (index == start) + { + throw std::invalid_argument{"data URL MIME parameter has an empty/invalid value"}; + } + } + if (index < type.size() && type[index] != ';') + { + throw std::invalid_argument{"data URL MIME parameter has an invalid delimiter"}; + } + } + return type; + } + + inline UrlSchemeResolverResult ResolveDataUrl(const std::string& url) + { + const std::string_view source = std::string_view{url}.substr(0, url.find('#')); + const auto comma = source.find(',', 5); + if (comma == std::string_view::npos) + { + throw std::invalid_argument{"data URL is missing its comma separator"}; + } + auto metadata = TrimAsciiWhitespace(source.substr(5, comma - 5)); + bool base64 = false; + if (metadata.size() >= 6) + { + auto marker = metadata.substr(metadata.size() - 6); + if (std::equal(marker.begin(), marker.end(), "base64", + [](char left, char right) { return AsciiLower(left) == right; })) + { + const auto prefix = TrimAsciiWhitespace(metadata.substr(0, metadata.size() - 6)); + if (!prefix.empty() && prefix.back() == ';') + { + base64 = true; + metadata = prefix.substr(0, prefix.size() - 1); + } + } + } + + UrlSchemeResolverResult result; + result.contentType = DataContentType(metadata); + const auto payload = source.substr(comma + 1); + std::vector body; + if (base64) + { + std::string normalized; + std::string_view encoded = payload; + if (payload.find('%') != std::string_view::npos || + std::any_of(payload.begin(), payload.end(), IsAsciiWhitespace)) + { + PercentDecodeBytes(payload, normalized); + normalized.erase(std::remove_if(normalized.begin(), normalized.end(), IsAsciiWhitespace), normalized.end()); + encoded = normalized; + } + if (encoded.size() % 4 == 0) + { + for (int padding = 0; padding < 2 && !encoded.empty() && encoded.back() == '='; ++padding) + { + encoded.remove_suffix(1); + } + } + if (encoded.size() % 4 == 1 || + !std::all_of(encoded.begin(), encoded.end(), [](char value) { + return (value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z') || + (value >= '0' && value <= '9') || value == '+' || value == '/'; + })) + { + throw std::invalid_argument{"data URL contains invalid base64"}; + } + // base-n skips malformed input, so validate the forgiving-base64 grammar before decoding. + body.resize(encoded.size() / 4 * 3 + encoded.size() % 4 * 3 / 4); + bn::decode_b64(encoded.begin(), encoded.end(), reinterpret_cast(body.data())); + } + else + { + PercentDecodeBytes(payload, body); + } + result.body = std::make_shared>(std::move(body)); + result.handled = true; + result.statusCode = UrlStatusCode::Ok; + return result; + } +} diff --git a/Source/UrlRequest_Android.cpp b/Source/UrlRequest_Android.cpp index 8f97d95..101216b 100644 --- a/Source/UrlRequest_Android.cpp +++ b/Source/UrlRequest_Android.cpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include using namespace android::global; using namespace android::net; @@ -15,6 +17,47 @@ namespace UrlLib { namespace { + void ThrowIfJavaFaulted(JNIEnv* env) + { + if (env->ExceptionCheck()) + { + const auto exception = env->ExceptionOccurred(); + env->ExceptionClear(); + const auto releaseException = gsl::finally([&] { env->DeleteLocalRef(exception); }); + throw Throwable{exception}; + } + } + + void WriteRequestBody(OutputStream& stream, const std::string& body) + { + auto* env = GetEnvForCurrentThread(); + const auto streamClass = stream.GetClass(); + const auto writeMethod = env->GetMethodID(streamClass, "write", "([BII)V"); + ThrowIfJavaFaulted(env); + const auto closeMethod = env->GetMethodID(streamClass, "close", "()V"); + ThrowIfJavaFaulted(env); + + if (!body.empty()) + { + // Write bytes directly: NewStringUTF is NUL-terminated and uses modified UTF-8. + const auto capacity = std::min(body.size(), size_t{16384}); + const auto buffer = env->NewByteArray(static_cast(capacity)); + ThrowIfJavaFaulted(env); + const auto releaseBuffer = gsl::finally([&] { env->DeleteLocalRef(buffer); }); + for (size_t offset{}; offset < body.size();) + { + const auto count = static_cast(std::min(body.size() - offset, capacity)); + env->SetByteArrayRegion(buffer, 0, count, reinterpret_cast(body.data() + offset)); + ThrowIfJavaFaulted(env); + env->CallVoidMethod(stream, writeMethod, buffer, 0, count); + ThrowIfJavaFaulted(env); + offset += static_cast(count); + } + } + env->CallVoidMethod(stream, closeMethod); + ThrowIfJavaFaulted(env); + } + template void LoadAsset(AAssetManager* assetManager, const char* url, T& data) { AAsset* asset = AAssetManager_open(assetManager, url, AASSET_MODE_UNKNOWN); @@ -115,9 +158,7 @@ namespace UrlLib connection.SetRequestProperty("Content-Length", std::to_string(numBytes)); OutputStream outputStream{connection.GetOutputStream()}; - OutputStreamWriter writer{outputStream}; - writer.Write(m_requestBody); - writer.Close(); + WriteRequestBody(outputStream, m_requestBody); } connection.Connect(); diff --git a/Source/UrlRequest_Apple.mm b/Source/UrlRequest_Apple.mm index f6e8ea6..3f0c9ce 100644 --- a/Source/UrlRequest_Apple.mm +++ b/Source/UrlRequest_Apple.mm @@ -126,10 +126,7 @@ void Open(UrlMethod method, const std::string& url) if (m_method == UrlMethod::Post) { mutableRequest.HTTPMethod = @"POST"; - // set the body - NSString* stringBody = [NSString stringWithUTF8String:m_requestBody.data()]; - NSData* requestBodyData = [stringBody dataUsingEncoding:NSUTF8StringEncoding]; - mutableRequest.HTTPBody = requestBodyData; + mutableRequest.HTTPBody = [NSData dataWithBytes:m_requestBody.data() length:m_requestBody.size()]; } request = [mutableRequest copy]; diff --git a/Source/UrlRequest_Base.h b/Source/UrlRequest_Base.h index 4cd1888..15b2f3a 100644 --- a/Source/UrlRequest_Base.h +++ b/Source/UrlRequest_Base.h @@ -1,6 +1,7 @@ #pragma once #include +#include "DataUrl.h" #include #include #include @@ -64,8 +65,8 @@ namespace UrlLib } // Returns true (and defers the actual work to ResolveScheme()) when `url`'s scheme has a - // registered resolver, in which case the caller must not touch the platform transport. - bool BeginSchemeResolution(const std::string& url) + // registered or built-in resolver, in which case the platform transport is bypassed. + bool BeginSchemeResolution(UrlMethod method, const std::string& url) { const std::string scheme = SchemeOf(url); if (scheme.empty()) @@ -78,15 +79,20 @@ namespace UrlLib auto& registry = Registry(); const std::lock_guard lock{registry.mutex}; const auto it = registry.resolvers.find(scheme); - if (it == registry.resolvers.end()) + if (it != registry.resolvers.end()) + { + resolver = it->second; + } + else if (scheme != "data") { return false; } - resolver = it->second; } ResetForOpen(); - m_pendingResolver = std::move(resolver); + m_method = method; + m_builtinData = !resolver && scheme == "data"; + m_pendingResolver = resolver ? std::move(resolver) : Detail::ResolveDataUrl; m_pendingResolverUrl = url; m_usingSchemeResolver = true; return true; @@ -97,6 +103,13 @@ namespace UrlLib return m_usingSchemeResolver; } + arcana::task ResolveSchemeAsync() + { + return arcana::make_task(arcana::inline_scheduler, m_cancellationSource, [this] { + ResolveScheme(); + }); + } + // Invokes the registered resolver and populates the response state. A resolver that reports // the URL as not handled (e.g. a revoked blob: URL) -- or that throws -- leaves the status at // 0 (None) and records a transport-style error, mirroring how a genuine network failure @@ -113,11 +126,29 @@ namespace UrlLib // Move the resolver/url out and clear them before invoking, so this runs exactly once. const UrlSchemeResolver resolver = std::move(m_pendingResolver); - const std::string url = std::move(m_pendingResolverUrl); + std::string url = std::move(m_pendingResolverUrl); m_pendingResolver = nullptr; m_pendingResolverUrl.clear(); - m_responseUrl = url; + if (m_builtinData) + { + m_responseUrl = std::move(url); + const auto fragment = m_responseUrl.find('#'); + if (fragment != std::string::npos) + { + m_responseUrl.erase(fragment); + } + } + else + { + m_responseUrl = url; + } + const std::string& resolverUrl = m_builtinData ? m_responseUrl : url; + if (m_builtinData && m_method != UrlMethod::Get) + { + SetError("urllib", "DataUrlUnsupportedMethod", 0, "data URLs support GET only"); + return; + } // A throwing resolver is contained here and reported through the same error surface as // any other failure, so it cannot escape SendAsync() synchronously -- callers observe a @@ -125,11 +156,16 @@ namespace UrlLib UrlSchemeResolverResult result{}; try { - result = resolver(url); + result = resolver(resolverUrl); + } + catch (const std::invalid_argument& e) + { + SetError("urllib", m_builtinData ? "DataUrlInvalid" : "SchemeResolverThrew", 0, e.what()); + return; } catch (const std::exception& e) { - SetError("urllib", "SchemeResolverThrew", 0, e.what()); + SetError("urllib", m_builtinData ? "DataUrlFailed" : "SchemeResolverThrew", 0, e.what()); return; } catch (...) @@ -140,7 +176,7 @@ namespace UrlLib if (!result.handled) { - SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + url + "'"); + SetError("urllib", "SchemeResolverNotFound", 0, "no live entry for '" + resolverUrl + "'"); return; } @@ -155,7 +191,7 @@ namespace UrlLib } m_resolvedBuffer = result.body ? result.body : std::make_shared>(); - if (m_responseType == UrlResponseType::String) + if (m_responseType == UrlResponseType::String && !m_resolvedBuffer->empty()) { m_responseString.assign(reinterpret_cast(m_resolvedBuffer->data()), m_resolvedBuffer->size()); } @@ -377,6 +413,7 @@ namespace UrlLib m_errorSymbol.clear(); m_errorString.clear(); m_usingSchemeResolver = false; + m_builtinData = false; m_pendingResolver = nullptr; m_pendingResolverUrl.clear(); m_resolvedBuffer.reset(); @@ -399,6 +436,7 @@ namespace UrlLib // Custom-scheme (e.g. blob:) resolution state. Populated by BeginSchemeResolution() / // ResolveScheme(); inert for ordinary transport requests. bool m_usingSchemeResolver{false}; + bool m_builtinData{false}; UrlSchemeResolver m_pendingResolver{}; std::string m_pendingResolverUrl{}; std::shared_ptr> m_resolvedBuffer{}; diff --git a/Source/UrlRequest_Shared.h b/Source/UrlRequest_Shared.h index f0b10e8..8f32572 100644 --- a/Source/UrlRequest_Shared.h +++ b/Source/UrlRequest_Shared.h @@ -24,9 +24,9 @@ namespace UrlLib void UrlRequest::Open(UrlMethod method, const std::string& url) { - // Divert URLs whose scheme has a registered resolver (e.g. blob:) away from the platform + // Divert URLs whose scheme has a shared resolver (e.g. blob:, data:) away from the platform // transport; the resolver supplies the response in SendAsync(). - if (m_impl->BeginSchemeResolution(url)) + if (m_impl->BeginSchemeResolution(method, url)) { return; } @@ -76,13 +76,12 @@ namespace UrlLib arcana::task UrlRequest::SendAsync() { - // Registered-scheme requests (e.g. blob:) are served synchronously from the resolver; the + // Shared-scheme requests (e.g. blob:, data:) are served synchronously from the resolver; the // resolution is deferred to here (rather than Open) so a blob: URL revoked between open() // and send() is honored. if (m_impl->IsSchemeResolution()) { - m_impl->ResolveScheme(); - return arcana::task_from_result(); + return m_impl->ResolveSchemeAsync(); } return m_impl->SendAsync(); diff --git a/Source/UrlRequest_Windows_Shared.h b/Source/UrlRequest_Windows_Shared.h index 02a2ec5..7c0f21d 100644 --- a/Source/UrlRequest_Windows_Shared.h +++ b/Source/UrlRequest_Windows_Shared.h @@ -1,5 +1,7 @@ #include "UrlRequest_Base.h" +#include +#include #include #include #include @@ -82,14 +84,14 @@ namespace UrlLib requestMessage.RequestUri(m_uri); requestMessage.Method(ConvertHttpMethod(m_method)); - std::string contentType; + std::optional contentType; - for (auto request : m_requestHeaders) + for (auto& request : m_requestHeaders) { // content type needs to be set separately - if (request.first == "Content-Type") + if (ToLower(request.first.c_str()) == "content-type") { - contentType = request.second; + contentType = std::move(request.second); } else { @@ -99,15 +101,22 @@ namespace UrlLib m_requestHeaders.clear(); - // check the method if (m_method == UrlMethod::Post) { - // if post, set the content type - requestMessage.Content(Web::Http::HttpStringContent( - winrt::to_hstring(m_requestBody), - winrt::Windows::Storage::Streams::UnicodeEncoding::Utf8, - winrt::to_hstring(contentType)) - ); + Storage::Streams::Buffer buffer{gsl::narrow(m_requestBody.size())}; + buffer.Length(buffer.Capacity()); + if (!m_requestBody.empty()) + { + uint8_t* bytes{}; + winrt::check_hresult(buffer.as<::Windows::Storage::Streams::IBufferByteAccess>()->Buffer(&bytes)); + std::memcpy(bytes, m_requestBody.data(), m_requestBody.size()); + } + Web::Http::HttpBufferContent content{buffer}; + if (contentType) + { + content.Headers().ContentType(Web::Http::Headers::HttpMediaTypeHeaderValue::Parse(winrt::to_hstring(*contentType))); + } + requestMessage.Content(content); } Web::Http::HttpClient client; @@ -128,9 +137,11 @@ namespace UrlLib m_headers.insert(std::make_pair(winrt::to_string(iter.Key()), winrt::to_string(iter.Value()))); } // process the content type response header - std::string contentTypeValue = winrt::to_string(responseMessage.Content().Headers().ContentType().ToString()); - std::string contentTypeKey = "content-type"; - m_headers.insert(std::make_pair(contentTypeKey, contentTypeValue)); + const auto contentType = responseMessage.Content().Headers().ContentType(); + if (contentType) + { + m_headers.emplace("content-type", winrt::to_string(contentType.ToString())); + } switch (m_responseType) { diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index aea1951..df59ff7 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -9,11 +9,11 @@ FetchContent_MakeAvailable(googletest) set_property(TARGET gtest PROPERTY FOLDER Dependencies) set_property(TARGET gtest_main PROPERTY FOLDER Dependencies) -add_executable(UrlLibTests UrlRequestErrorReporting.cpp SchemeResolver.cpp) +add_executable(UrlLibTests Main.cpp UrlRequestErrorReporting.cpp SchemeResolver.cpp DataUrl.cpp) target_link_libraries(UrlLibTests PRIVATE UrlLib - PRIVATE GTest::gtest_main) + PRIVATE GTest::gtest) if(APPLE) # The UrlLib static library's NSURLSession-based backend needs Foundation at link time. diff --git a/Tests/DataUrl.cpp b/Tests/DataUrl.cpp new file mode 100644 index 0000000..10df9b0 --- /dev/null +++ b/Tests/DataUrl.cpp @@ -0,0 +1,210 @@ +#include +#include + +#include +#include +#include + +namespace +{ + void Send(UrlLib::UrlRequest& request, bool cancelled = false) + { + auto complete = std::make_shared>(false); + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [complete, cancelled](const arcana::expected& result) { + EXPECT_EQ(result.has_error(), cancelled); + complete->store(true); + }); + ASSERT_TRUE(complete->load()); + } + + struct DataCase + { + const char* url; + std::string bytes; + const char* contentType; + }; + + class DataUrl : public testing::TestWithParam {}; +} + +TEST_P(DataUrl, DecodesStringAndBuffer) +{ + const auto& value = GetParam(); + for (auto type : {UrlLib::UrlResponseType::String, UrlLib::UrlResponseType::Buffer}) + { + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, value.url); + request.ResponseType(type); + Send(request); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(request.StatusText(), "OK"); + EXPECT_EQ(request.GetResponseHeader("Content-Type"), std::optional{value.contentType}); + EXPECT_TRUE(request.ErrorString().empty()); + EXPECT_TRUE(request.ErrorSymbol().empty()); + EXPECT_EQ(request.ErrorCode(), 0); + if (type == UrlLib::UrlResponseType::String) + { + EXPECT_EQ(request.ResponseString(), value.bytes); + } + else + { + const auto body = request.ResponseBuffer(); + ASSERT_EQ(body.size(), value.bytes.size()); + if (!body.empty()) + { + EXPECT_EQ(std::string(reinterpret_cast(body.data()), body.size()), value.bytes); + } + } + } +} + +INSTANTIATE_TEST_SUITE_P(Supported, DataUrl, testing::Values( + DataCase{"data:,hello%20world", "hello world", "text/plain;charset=US-ASCII"}, + DataCase{"data:,", "", "text/plain;charset=US-ASCII"}, + DataCase{"data:;base64,", "", "text/plain;charset=US-ASCII"}, + DataCase{"data:text/plain,hello+world%2C%25", "hello+world,%", "text/plain"}, + DataCase{"DATA:TEXT/PLAIN;CHARSET=UTF-8,%C3%A9", "\xC3\xA9", "text/plain;charset=UTF-8"}, + DataCase{"data:;charset=utf-8,%E2%82%AC", "\xE2\x82\xAC", "text/plain;charset=utf-8"}, + DataCase{"data:application/octet-stream,%00%7f%80%ff", std::string{"\0\x7f\x80\xff", 4}, "application/octet-stream"}, + DataCase{"data:image/png;base64,iVBORw0KGgo=", "\x89PNG\r\n\x1a\n", "image/png"}, + DataCase{"data:;BASE64,Zg==", "f", "text/plain;charset=US-ASCII"}, + DataCase{"data:;base64,Zg", "f", "text/plain;charset=US-ASCII"}, + DataCase{"data:;base64,Zm8", "fo", "text/plain;charset=US-ASCII"}, + DataCase{"data:;base64,Zm9v", "foo", "text/plain;charset=US-ASCII"}, + DataCase{"data:;base64,Zh==", "f", "text/plain;charset=US-ASCII"}, + DataCase{"data:;base64,Z%20g%3D%3D%0D%0A%09%0C", "f", "text/plain;charset=US-ASCII"}, + DataCase{"data:application/octet-stream; base64,%2F%2B%2F%2B", "\xff\xef\xfe", "application/octet-stream"}, + DataCase{"data:text/plain,x?y#ignored", "x?y", "text/plain"}, + DataCase{"data:text/plain,%23fragment%3Fquery", "#fragment?query", "text/plain"}, + DataCase{"data:text/plain,a,b,c", "a,b,c", "text/plain"}, + DataCase{"data:text/plain,%oops%2%", "%oops%2%", "text/plain"}, + DataCase{"data:text/plain,\xC3\xA9", "\xC3\xA9", "text/plain"}, + DataCase{"data:text/plain;note=\"a;b\",x", "x", "text/plain;note=\"a;b\""}, + DataCase{"data:text/plain;note=\"a\\\"b\",x", "x", "text/plain;note=\"a\\\"b\""}, + DataCase{"data:text/plain;base64=not-a-marker,Zg==", "Zg==", "text/plain;base64=not-a-marker"}, + DataCase{"data:;base64,Zg==#ignored", "f", "text/plain;charset=US-ASCII"} +)); + +TEST(DataUrlFailures, MalformedUrlsReportErrorsWithoutPartialResponse) +{ + for (const auto* url : { + "data:", "data:text/plain", "data:text/plain#fragment,not-body", + "data:invalid,x", "data:text/,x", "data:/plain,x", "data:text/plain;broken,x", + "data:text/plain;charset=,x", "data:text/plain;charset=\"broken,x", + "data:;base64,A", "data:;base64,Zg=", "data:;base64,Zg===", "data:;base64,Zm=8", + "data:;base64,!!!!", "data:;base64,Zg==junk", "data:;base64,_w==", + "data:;base64,%FF", "data:;base64,====", "data:;base64,Zg==?query", + "data:text/plain;note=\"a\\\nb\",x"}) + { + SCOPED_TRACE(url); + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, url); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_TRUE(request.StatusText().empty()); + EXPECT_EQ(request.ErrorSymbol(), "DataUrlInvalid"); + EXPECT_FALSE(request.ErrorString().empty()); + EXPECT_TRUE(request.GetAllResponseHeaders().empty()); + EXPECT_TRUE(request.ResponseBuffer().empty()); + EXPECT_TRUE(request.ResponseString().empty()); + } +} + +TEST(DataUrlLifecycle, RejectsPost) +{ + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Post, "data:,hello"); + request.SetRequestBody("ignored"); + Send(request); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_EQ(request.ErrorSymbol(), "DataUrlUnsupportedMethod"); + EXPECT_TRUE(request.GetAllResponseHeaders().empty()); + EXPECT_TRUE(request.ResponseString().empty()); +} + +TEST(DataUrlLifecycle, AbortBeforeSendReturnsCancelledTask) +{ + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, "data:,hello"); + request.Abort(); + Send(request, true); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_TRUE(request.ResponseString().empty()); + EXPECT_TRUE(request.GetAllResponseHeaders().empty()); +} + +TEST(DataUrlLifecycle, ReopenClearsErrorBodyAndHeaders) +{ + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, "data:;base64,!!!"); + Send(request); + ASSERT_FALSE(request.ErrorString().empty()); + request.Open(UrlLib::UrlMethod::Get, "data:,hello#fragment"); + Send(request); + EXPECT_TRUE(request.ErrorString().empty()); + EXPECT_EQ(request.ResponseUrl(), "data:,hello"); + EXPECT_EQ(request.ResponseString(), "hello"); + Send(request); + EXPECT_EQ(request.ResponseString(), "hello"); + request.Abort(); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + request.Open(UrlLib::UrlMethod::Get, "data:,"); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_TRUE(request.ResponseString().empty()); + EXPECT_TRUE(request.ResponseBuffer().empty()); + EXPECT_TRUE(request.GetAllResponseHeaders().empty()); +} + +TEST(DataUrlLifecycle, UnregisterDoesNotRemoveBuiltin) +{ + UrlLib::UrlRequest::UnregisterSchemeResolver("data"); + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, "data:,builtin"); + Send(request); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(request.ResponseString(), "builtin"); +} + +TEST(DataUrlLifecycle, CustomOverrideAndRestoration) +{ + UrlLib::UrlRequest::RegisterSchemeResolver("DATA", [](const std::string&) { + UrlLib::UrlSchemeResolverResult result; + result.handled = true; + result.statusCode = static_cast(201); + return result; + }); + UrlLib::UrlRequest overridden; + overridden.Open(UrlLib::UrlMethod::Get, "data:,builtin"); + UrlLib::UrlRequest::UnregisterSchemeResolver("data"); + Send(overridden); + EXPECT_EQ(static_cast(overridden.StatusCode()), 201); + EXPECT_TRUE(overridden.ResponseString().empty()); + overridden.Open(UrlLib::UrlMethod::Get, "data:,builtin"); + Send(overridden); + EXPECT_EQ(overridden.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_EQ(overridden.ResponseString(), "builtin"); +} + +TEST(DataUrlBytes, EntireByteRange) +{ + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, + "data:application/octet-stream;base64," + "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4v" + "MDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5f" + "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6P" + "kJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/" + "wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v" + "8PHy8/T19vf4+fr7/P3+/w=="); + request.ResponseType(UrlLib::UrlResponseType::Buffer); + Send(request); + ASSERT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + const auto bytes = request.ResponseBuffer(); + ASSERT_EQ(bytes.size(), 256u); + for (size_t index = 0; index < bytes.size(); ++index) + { + EXPECT_EQ(bytes[index], static_cast(index)); + } +} diff --git a/Tests/Main.cpp b/Tests/Main.cpp new file mode 100644 index 0000000..3c471aa --- /dev/null +++ b/Tests/Main.cpp @@ -0,0 +1,18 @@ +#include + +#if defined(_WIN32) +#include +#endif + +int main(int argc, char** argv) +{ +#if defined(_WIN32) + winrt::init_apartment(winrt::apartment_type::multi_threaded); +#endif + testing::InitGoogleTest(&argc, argv); + const int result = RUN_ALL_TESTS(); +#if defined(_WIN32) + winrt::uninit_apartment(); +#endif + return result; +} diff --git a/Tests/SchemeResolver.cpp b/Tests/SchemeResolver.cpp index dbc24fb..96dd62a 100644 --- a/Tests/SchemeResolver.cpp +++ b/Tests/SchemeResolver.cpp @@ -230,6 +230,29 @@ TEST(SchemeResolver, RegisteringNullResolverThrows) UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); } +TEST(SchemeResolver, AbortBeforeSendDoesNotInvokeResolver) +{ + const std::string scheme = "urllibtest-cancel"; + int calls = 0; + UrlLib::UrlRequest::RegisterSchemeResolver(scheme, [&calls](const std::string&) { + ++calls; + return UrlLib::UrlSchemeResolverResult{}; + }); + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, scheme + ":anything"); + request.Abort(); + bool completed = false; + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [&completed](const arcana::expected& result) { + EXPECT_TRUE(result.has_error()); + completed = true; + }); + EXPECT_TRUE(completed); + EXPECT_EQ(calls, 0); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + UrlLib::UrlRequest::UnregisterSchemeResolver(scheme); +} + // After unregistering, the scheme is no longer diverted: the resolver is never invoked again and the // request falls through to the platform transport. The transport's outcome for an unknown scheme is // platform-specific (it may throw at Open, fail synchronously, or fail asynchronously), so this diff --git a/Tests/UrlRequestErrorReporting.cpp b/Tests/UrlRequestErrorReporting.cpp index 812116e..d67e864 100644 --- a/Tests/UrlRequestErrorReporting.cpp +++ b/Tests/UrlRequestErrorReporting.cpp @@ -8,7 +8,10 @@ #include #include +#include +#include +#include #include #include #include @@ -202,15 +205,16 @@ namespace uint16_t m_port; }; - // A loopback TCP server that accepts connections but never responds, so an HTTP request to it + // A loopback TCP server that accepts connections but, by default, never responds, so a request // hangs until it is aborted. A background thread accept()s connections and holds them open // until teardown. Used to verify that UrlRequest::Abort() interrupts an in-flight request // rather than waiting for the transport's own timeout. Non-movable: the accept thread captures - // `this`. + // `this`. An optional wire response also supports deterministic HTTP response tests. class HangingServer { public: - HangingServer() + explicit HangingServer(std::string response = {}, std::promise* capturedRequest = nullptr, + size_t requestBodyLength = 0, std::promise* connectionAccepted = nullptr) { if (!EnsureSocketsInitialized()) { @@ -238,7 +242,8 @@ namespace m_listener = listener; m_port = ntohs(address.sin_port); - m_acceptThread = std::thread{[this]() { + m_acceptThread = std::thread{[this, response = std::move(response), capturedRequest, + requestBodyLength, connectionAccepted]() mutable { for (;;) { NativeSocket connection = ::accept(m_listener, nullptr, nullptr); @@ -246,7 +251,77 @@ namespace { break; // listener closed during teardown } - m_accepted.push_back(connection); // hold open, never respond + if (connectionAccepted) + { + connectionAccepted->set_value(); + connectionAccepted = nullptr; + } + if (response.empty()) + { + m_accepted.push_back(connection); // hold open, never respond + } + else + { +#if defined(__APPLE__) + const int noSignal = 1; + ::setsockopt(connection, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, sizeof(noSignal)); +#endif +#if defined(_WIN32) + const DWORD timeout = 5000; +#else + const timeval timeout{5, 0}; +#endif + if (::setsockopt(connection, SOL_SOCKET, SO_RCVTIMEO, + reinterpret_cast(&timeout), sizeof(timeout)) != 0) + { + ShutdownSocket(connection); + CloseSocket(connection); + continue; + } + char chunk[4096]; + std::string request; + bool received = false; + for (;;) + { + const auto count = ::recv(connection, chunk, sizeof(chunk), 0); + if (count <= 0) + { + break; + } + received = true; + if (!capturedRequest) + { + break; + } + request.append(chunk, static_cast(count)); + const auto headerEnd = request.find("\r\n\r\n"); + if (headerEnd != std::string::npos && request.size() - headerEnd - 4 >= requestBodyLength) + { + break; + } + } + if (capturedRequest) + { + capturedRequest->set_value(std::move(request)); + capturedRequest = nullptr; + } + if (received) + { + size_t sent = 0; + while (sent < response.size()) + { + const auto count = ::send(connection, response.data() + sent, + static_cast(response.size() - sent), 0); + if (count <= 0) + { + break; + } + sent += static_cast(count); + } + } + ShutdownSocket(connection); + CloseSocket(connection); + } } }}; } @@ -285,6 +360,11 @@ namespace return "http://127.0.0.1:" + std::to_string(m_port) + "/"; } + uint16_t Port() const + { + return m_port; + } + private: NativeSocket m_listener{InvalidSocket}; uint16_t m_port{0}; @@ -330,6 +410,255 @@ namespace }; } +namespace +{ + class HttpPost : public testing::Test + { + protected: + void SetUp() override + { +#if !defined(_WIN32) && !defined(__APPLE__) + GTEST_SKIP() << "the Linux backend does not implement POST yet"; +#endif + } + + void CheckPost(std::optional headerName, const std::string& body, + const std::string& contentType = "application/json") + { + std::promise capturedRequest; + auto wire = capturedRequest.get_future(); + HangingServer server{ + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + &capturedRequest, body.size()}; + ASSERT_TRUE(server.Valid()); + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Post, server.Url()); + if (headerName) + { + request.SetRequestHeader(*headerName, contentType); + } + request.SetRequestHeader("x-urllib-test", "post"); + request.SetRequestBody(body); + ASSERT_TRUE(SendAndWait(request)); + ASSERT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok) << request.ErrorString(); + EXPECT_EQ(request.ResponseString(), "ok"); + EXPECT_TRUE(request.ErrorString().empty()); + ASSERT_EQ(wire.wait_for(std::chrono::seconds{5}), std::future_status::ready); + const auto sent = wire.get(); + EXPECT_EQ(sent.find("POST / HTTP/1.1\r\n"), 0u); + const auto headerEnd = sent.find("\r\n\r\n"); + ASSERT_NE(headerEnd, std::string::npos); + EXPECT_EQ(sent.substr(headerEnd + 4), body); + std::string headers = sent.substr(0, headerEnd + 2); + std::transform(headers.begin(), headers.end(), headers.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + EXPECT_NE(headers.find("\r\nx-urllib-test: post\r\n"), std::string::npos); + if (headerName) + { + EXPECT_NE(headers.find("\r\ncontent-type: " + contentType + "\r\n"), std::string::npos); + } +#if defined(_WIN32) + else + { + EXPECT_EQ(headers.find("\r\ncontent-type:"), std::string::npos); + } +#endif + } + }; +} + +TEST_F(HttpPost, JsonContentTypeIsCaseInsensitive) +{ + for (const auto* name : {"Content-Type", "content-type", "CoNtEnT-TyPe"}) + { + SCOPED_TRACE(name); + CheckPost(name, "{\"message\":\"caf\xC3\xA9\"}\n"); + } +} + +TEST_F(HttpPost, WithoutContentType) +{ + CheckPost(std::nullopt, "plain text\r\n"); + CheckPost(std::nullopt, ""); +} + +TEST_F(HttpPost, PreservesContentTypeParameters) +{ + CheckPost("content-type", "{\"ready\":true}", "application/json; charset=utf-8"); +} + +TEST_F(HttpPost, PreservesRawBodyBytes) +{ + for (const auto& body : {std::string{"a\0b", 3}, std::string{"\x80\xff\r\n", 4}, + std::string{"\0\x80\xff\r\n", 5}}) + { + CheckPost("content-type", body, "application/octet-stream"); + } +} + +#if defined(_WIN32) +namespace +{ + class WindowsHttp : public testing::Test + { + protected: + void CheckResponse(const std::string& wire, int status, std::string_view body, + std::optional contentType) + { + HangingServer server{wire}; + ASSERT_TRUE(server.Valid()); + for (auto type : {UrlLib::UrlResponseType::String, UrlLib::UrlResponseType::Buffer}) + { + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, server.Url()); + request.ResponseType(type); + auto settled = std::make_shared>(); + auto future = settled->get_future(); + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [settled](const arcana::expected& result) { + settled->set_value(result.has_error() ? result.error() : nullptr); + }); + ASSERT_EQ(future.wait_for(std::chrono::seconds{30}), std::future_status::ready); + const auto error = future.get(); + ASSERT_EQ(error, nullptr) << "HTTP task failed"; + EXPECT_EQ(static_cast(request.StatusCode()), status); + EXPECT_EQ(request.GetResponseHeader("content-type"), contentType); + EXPECT_TRUE(request.ErrorString().empty()); + if (type == UrlLib::UrlResponseType::String) + { + EXPECT_EQ(request.ResponseString(), body); + } + else + { + const auto bytes = request.ResponseBuffer(); + ASSERT_EQ(bytes.size(), body.size()); + if (!bytes.empty()) + { + EXPECT_EQ(std::string_view(reinterpret_cast(bytes.data()), bytes.size()), body); + } + } + } + } + }; +} + +TEST_F(WindowsHttp, MissingContentTypePreservesBody) +{ + CheckResponse("HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + 200, "hello", std::nullopt); +} + +TEST_F(WindowsHttp, PostRejectsInvalidContentTypeAndCanReopen) +{ + const std::string body = "reopened without Content-Type"; + std::promise capturedRequest; + auto wire = capturedRequest.get_future(); + HangingServer server{"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + &capturedRequest, body.size()}; + ASSERT_TRUE(server.Valid()); + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Post, server.Url()); + request.SetRequestHeader("content-type", "not a media type"); + request.SetRequestBody("unchanged"); + ASSERT_TRUE(SendAndWait(request)); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_FALSE(request.ErrorString().empty()); + EXPECT_TRUE(request.ResponseString().empty()); + EXPECT_TRUE(request.GetAllResponseHeaders().empty()); + + request.Open(UrlLib::UrlMethod::Post, server.Url()); + request.SetRequestBody(body); + ASSERT_TRUE(SendAndWait(request)); + ASSERT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok) << request.ErrorString(); + EXPECT_TRUE(request.ErrorString().empty()); + ASSERT_EQ(wire.wait_for(std::chrono::seconds{5}), std::future_status::ready); + const auto sent = wire.get(); + const auto headerEnd = sent.find("\r\n\r\n"); + ASSERT_NE(headerEnd, std::string::npos); + EXPECT_EQ(sent.substr(headerEnd + 4), body); + std::string headers = sent.substr(0, headerEnd); + std::transform(headers.begin(), headers.end(), headers.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + EXPECT_EQ(headers.find("\r\ncontent-type:"), std::string::npos); +} + +TEST_F(WindowsHttp, MissingContentTypeWithEmptyBody) +{ + CheckResponse("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + 200, "", std::nullopt); +} + +TEST_F(WindowsHttp, NoContentRetainsExistingSuccessNormalization) +{ + CheckResponse("HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n", + 200, "", std::nullopt); +} + +TEST_F(WindowsHttp, ContentTypePreserved) +{ + CheckResponse("HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + 200, "hello", std::string{"text/plain; charset=utf-8"}); +} + +TEST_F(WindowsHttp, NonSuccessRetainsExistingStatusAndEmptyBody) +{ + CheckResponse("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 7\r\nConnection: close\r\n\r\nmissing", + 404, "", std::nullopt); + CheckResponse("HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + 500, "", std::nullopt); + CheckResponse("HTTP/1.1 304 Not Modified\r\nConnection: close\r\n\r\n", + 304, "", std::nullopt); +} + +TEST_F(WindowsHttp, ConnectionFailureIsNotSuccess) +{ + const auto port = RefusingPort::Acquire(); + ASSERT_TRUE(port); + UrlLib::UrlRequest request; + request.Open(UrlLib::UrlMethod::Get, port->Url()); + auto settled = std::make_shared>(); + auto future = settled->get_future(); + request.SendAsync().then(arcana::inline_scheduler, arcana::cancellation::none(), + [settled](const arcana::expected& result) { + settled->set_value(result.has_error()); + }); + ASSERT_EQ(future.wait_for(std::chrono::seconds{30}), std::future_status::ready); + EXPECT_TRUE(future.get() || !request.ErrorString().empty()); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::None); + EXPECT_TRUE(request.ResponseString().empty()); + EXPECT_TRUE(request.GetAllResponseHeaders().empty()); +} +#endif + +TEST(LoopbackServer, IdleResponseConnectionDoesNotBlockTeardown) +{ + std::promise accepted; + auto acceptedFuture = accepted.get_future(); + auto server = std::make_unique( + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", nullptr, 0, &accepted); + ASSERT_TRUE(server->Valid()); + const NativeSocket connection = ::socket(AF_INET, SOCK_STREAM, 0); + ASSERT_NE(connection, InvalidSocket); + auto closeConnection = gsl::finally([connection] { CloseSocket(connection); }); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(server->Port()); + ASSERT_EQ(::connect(connection, reinterpret_cast(&address), sizeof(address)), 0); + ASSERT_EQ(acceptedFuture.wait_for(std::chrono::seconds{5}), std::future_status::ready); + + std::promise destroyed; + auto destroyedFuture = destroyed.get_future(); + std::thread teardown{[server = std::move(server), &destroyed]() mutable { + server.reset(); + destroyed.set_value(); + }}; + const auto completion = destroyedFuture.wait_for(std::chrono::seconds{10}); + ShutdownSocket(connection); + teardown.join(); + EXPECT_EQ(completion, std::future_status::ready); +} + TEST(UrlRequestErrorReporting, SuccessfulLocalFileReportsNoError) { const TempFile file{"ok", "hello urllib"}; @@ -346,6 +675,27 @@ TEST(UrlRequestErrorReporting, SuccessfulLocalFileReportsNoError) EXPECT_EQ(request.ErrorCode(), 0); } +TEST(UrlRequestErrorReporting, SwitchingBetweenDataAndPlatformTransportClearsResponse) +{ + const TempFile file{"scheme_reuse", "file body"}; + UrlLib::UrlRequest request; + request.ResponseType(UrlLib::UrlResponseType::Buffer); + request.Open(UrlLib::UrlMethod::Get, "data:application/octet-stream,previous"); + ASSERT_TRUE(SendAndWait(request)); + ASSERT_EQ(request.ResponseBuffer().size(), 8u); + request.Open(UrlLib::UrlMethod::Get, file.Url()); + ASSERT_TRUE(SendAndWait(request)); + EXPECT_EQ(request.StatusCode(), UrlLib::UrlStatusCode::Ok); + EXPECT_FALSE(request.GetResponseHeader("content-type")); + const auto body = request.ResponseBuffer(); + ASSERT_EQ(body.size(), 9u); + EXPECT_EQ(std::string_view(reinterpret_cast(body.data()), body.size()), "file body"); + request.Open(UrlLib::UrlMethod::Get, "data:,"); + ASSERT_TRUE(SendAndWait(request)); + EXPECT_TRUE(request.ResponseBuffer().empty()); + EXPECT_EQ(request.GetResponseHeader("content-type"), std::optional{"text/plain;charset=US-ASCII"}); +} + TEST(UrlRequestErrorReporting, MissingLocalFileReportsError) { SKIP_WITHOUT_TRANSPORT_ERROR_DETAIL();