From 925ce9e9084a1a9e3dbd9954fc3bc3117a038915 Mon Sep 17 00:00:00 2001 From: Juha Vuolle Date: Thu, 14 Sep 2023 11:09:06 +0300 Subject: [PATCH] Add QHttpHeaders class New QHttpHeaders class for use in place of std::pair, QMap, and QMultiMap/Hash to represent HTTP headers. [ChangeLog][QtNetwork][QHttpHeaders] New QHttpHeaders class Task-number: QTBUG-107042 Change-Id: I54766886a491acfc9a813a3414322a75011acb9d Reviewed-by: Marc Mutz --- src/network/CMakeLists.txt | 1 + src/network/access/qhttpheaders.cpp | 1104 +++++++++++++++++ src/network/access/qhttpheaders.h | 276 +++++ tests/auto/network/access/CMakeLists.txt | 1 + .../access/qhttpheaders/CMakeLists.txt | 10 + .../access/qhttpheaders/tst_qhttpheaders.cpp | 517 ++++++++ 6 files changed, 1909 insertions(+) create mode 100644 src/network/access/qhttpheaders.cpp create mode 100644 src/network/access/qhttpheaders.h create mode 100644 tests/auto/network/access/qhttpheaders/CMakeLists.txt create mode 100644 tests/auto/network/access/qhttpheaders/tst_qhttpheaders.cpp diff --git a/src/network/CMakeLists.txt b/src/network/CMakeLists.txt index 502ba16987..949cf3ee21 100644 --- a/src/network/CMakeLists.txt +++ b/src/network/CMakeLists.txt @@ -123,6 +123,7 @@ qt_internal_extend_target(Network CONDITION QT_FEATURE_http access/qhttp1configuration.cpp access/qhttp1configuration.h access/qhttp2configuration.cpp access/qhttp2configuration.h access/qhttp2protocolhandler.cpp access/qhttp2protocolhandler_p.h + access/qhttpheaders.cpp access/qhttpheaders.h access/qhttpmultipart.cpp access/qhttpmultipart.h access/qhttpmultipart_p.h access/qhttpnetworkconnection.cpp access/qhttpnetworkconnection_p.h access/qhttpnetworkconnectionchannel.cpp access/qhttpnetworkconnectionchannel_p.h diff --git a/src/network/access/qhttpheaders.cpp b/src/network/access/qhttpheaders.cpp new file mode 100644 index 0000000000..a66adf8161 --- /dev/null +++ b/src/network/access/qhttpheaders.cpp @@ -0,0 +1,1104 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#include "qhttpheaders.h" + +#include + +#include +#include +#include +#include +#include + +QT_BEGIN_NAMESPACE + +Q_LOGGING_CATEGORY(lcQHttpHeaders, "qt.network.http.headers"); + +/*! + \class QHttpHeaders + \since 6.7 + \ingroup + \inmodule QtNetwork + + \brief QHttpHeaders is a class for holding HTTP headers. + + The primary use case for the class is to provide an interface type + for Qt networking APIs that use/consume such headers. Historically + various QList, QMultiMap, and QMultiHash constructs have been used. + + \section1 Allowed field name and value characters + + An HTTP header consists of \e name and \e value. + When setting these, QHttpHeaders validates \e name and \e value + to only contain characters allowed by the HTTP RFCs. For detailed + information see + \l {https://datatracker.ietf.org/doc/html/rfc9110#name-field-values} + {RFC 9110 Chapters 5.1 and 5.5}. + + Broadly speaking, this means: + \list + \li \c name must consist of visible ASCII characters, and must not be + empty + \li \c value may consist of arbitrary bytes, as long as header + and use case specific encoding rules are adhered to. \c value + may be empty + \endlist + + Furthermore, \e value may have historically contained leading or + trailing whitespace, which has to be ignored while processing such + values. The setters of this class automatically remove any such + whitespace. + + \section1 Combining values + + Most HTTP header values can be combined with a single comma \c {','}, + and the semantic meaning is preserved. As an example, these two should be + semantically similar: + \badcode + // Values as separate header entries + myheadername: myheadervalue1 + myheadername: myheadervalue2 + // Combined value + myheadername: myheadervalue1,myheadervalue2 + \endcode + + However there is a notable exception to this rule: + \l {https://datatracker.ietf.org/doc/html/rfc9110#name-field-order} + {Set-Cookie}. Due to this, as well as due to the possibility + of custom use cases, QHttpHeaders does not automatically combine + the values. +*/ + +// A clarification on case-sensitivity: +// - Header *names* are case-insensitive; Content-Type and content-type are considered equal +// - Header *values* are case-sensitive +// (In addition, the HTTP/2 and HTTP/3 standards mandate that all headers must be lower-cased when +// encoded into transmission) +struct Header { + QByteArray name; + QByteArray value; + +private: + friend bool operator==(const Header &lhs, const Header &rhs) noexcept + { + return lhs.value == rhs.value && lhs.name == rhs.name; + } +}; + +class QHttpHeadersPrivate : public QSharedData +{ +public: + QHttpHeadersPrivate() = default; + + bool equals(const QHttpHeadersPrivate &other, + QHttpHeaders::CompareOptions options) const noexcept; + + QList
headers; + + Q_ALWAYS_INLINE void verify([[maybe_unused]] qsizetype pos = 0, + [[maybe_unused]] qsizetype n = 1) const + { + Q_ASSERT(pos >= 0); + Q_ASSERT(pos <= headers.size()); + Q_ASSERT(n >= 0); + Q_ASSERT(n <= headers.size() - pos); + } +}; + +QT_DEFINE_QESDP_SPECIALIZATION_DTOR(QHttpHeadersPrivate) + +bool QHttpHeadersPrivate::equals(const QHttpHeadersPrivate &other, + QHttpHeaders::CompareOptions options) const noexcept +{ + if (headers.size() != other.headers.size()) + return false; + + if (options & QHttpHeaders::CompareOption::OrderSensitive) + return headers == other.headers; + else + return std::is_permutation(headers.begin(), headers.end(), other.headers.begin()); +} + +// This list is from IANA HTTP Field Name Registry +// https://www.iana.org/assignments/http-fields +// It contains entries that are either "permanent" +// or "deprecated" as of October 2023. +// Usage relies on enum values keeping in same order. +// ### Qt7 check if some of these headers have been obsoleted, +// and also check if the enums benefit from reordering +static constexpr auto headerNames = qOffsetStringArray( + // IANA Permanent status: + "a-im", + "accept", + "accept-additions", + "accept-ch", + "accept-datetime", + "accept-encoding", + "accept-features", + "accept-language", + "accept-patch", + "accept-post", + "accept-ranges", + "accept-signature", + "access-control-allow-credentials", + "access-control-allow-headers", + "access-control-allow-methods", + "access-control-allow-origin", + "access-control-expose-headers", + "access-control-max-age", + "access-control-request-headers", + "access-control-request-method", + "age", + "allow", + "alpn", + "alt-svc", + "alt-used", + "alternates", + "apply-to-redirect-ref", + "authentication-control", + "authentication-info", + "authorization", + "cache-control", + "cache-status", + "cal-managed-id", + "caldav-timezones", + "capsule-protocol", + "cdn-cache-control", + "cdn-loop", + "cert-not-after", + "cert-not-before", + "clear-site-data", + "client-cert", + "client-cert-chain", + "close", + "connection", + "content-digest", + "content-disposition", + "content-encoding", + "content-id", + "content-language", + "content-length", + "content-location", + "content-range", + "content-security-policy", + "content-security-policy-report-only", + "content-type", + "cookie", + "cross-origin-embedder-policy", + "cross-origin-embedder-policy-report-only", + "cross-origin-opener-policy", + "cross-origin-opener-policy-report-only", + "cross-origin-resource-policy", + "dasl", + "date", + "dav", + "delta-base", + "depth", + "destination", + "differential-id", + "dpop", + "dpop-nonce", + "early-data", + "etag", + "expect", + "expect-ct", + "expires", + "forwarded", + "from", + "hobareg", + "host", + "if", + "if-match", + "if-modified-since", + "if-none-match", + "if-range", + "if-schedule-tag-match", + "if-unmodified-since", + "im", + "include-referred-token-binding-id", + "keep-alive", + "label", + "last-event-id", + "last-modified", + "link", + "location", + "lock-token", + "max-forwards", + "memento-datetime", + "meter", + "mime-version", + "negotiate", + "nel", + "odata-entityid", + "odata-isolation", + "odata-maxversion", + "odata-version", + "optional-www-authenticate", + "ordering-type", + "origin", + "origin-agent-cluster", + "oscore", + "oslc-core-version", + "overwrite", + "ping-from", + "ping-to", + "position", + "prefer", + "preference-applied", + "priority", + "proxy-authenticate", + "proxy-authentication-info", + "proxy-authorization", + "proxy-status", + "public-key-pins", + "public-key-pins-report-only", + "range", + "redirect-ref", + "referer", + "refresh", + "replay-nonce", + "repr-digest", + "retry-after", + "schedule-reply", + "schedule-tag", + "sec-purpose", + "sec-token-binding", + "sec-websocket-accept", + "sec-websocket-extensions", + "sec-websocket-key", + "sec-websocket-protocol", + "sec-websocket-version", + "server", + "server-timing", + "set-cookie", + "signature", + "signature-input", + "slug", + "soapaction", + "status-uri", + "strict-transport-security", + "sunset", + "surrogate-capability", + "surrogate-control", + "tcn", + "te", + "timeout", + "topic", + "traceparent", + "tracestate", + "trailer", + "transfer-encoding", + "ttl", + "upgrade", + "urgency", + "user-agent", + "variant-vary", + "vary", + "via", + "want-content-digest", + "want-repr-digest", + "www-authenticate", + "x-content-type-options", + "x-frame-options", + // IANA Deprecated status: + "accept-charset", + "c-pep-info", + "pragma", + "protocol-info", + "protocol-query" +); + +/*! + \enum QHttpHeaders::WellKnownHeader + + List of well known headers as per + \l {https://www.iana.org/assignments/http-fields}{IANA registry}. + + \value AIM + \value Accept + \value AcceptAdditions + \value AcceptCH + \value AcceptDatetime + \value AcceptEncoding + \value AcceptFeatures + \value AcceptLanguage + \value AcceptPatch + \value AcceptPost + \value AcceptRanges + \value AcceptSignature + \value AccessControlAllowCredentials + \value AccessControlAllowHeaders + \value AccessControlAllowMethods + \value AccessControlAllowOrigin + \value AccessControlExposeHeaders + \value AccessControlMaxAge + \value AccessControlRequestHeaders + \value AccessControlRequestMethod + \value Age + \value Allow + \value ALPN + \value AltSvc + \value AltUsed + \value Alternates + \value ApplyToRedirectRef + \value AuthenticationControl + \value AuthenticationInfo + \value Authorization + \value CacheControl + \value CacheStatus + \value CalManagedID + \value CalDAVTimezones + \value CapsuleProtocol + \value CDNCacheControl + \value CDNLoop + \value CertNotAfter + \value CertNotBefore + \value ClearSiteData + \value ClientCert + \value ClientCertChain + \value Close + \value Connection + \value ContentDigest + \value ContentDisposition + \value ContentEncoding + \value ContentID + \value ContentLanguage + \value ContentLength + \value ContentLocation + \value ContentRange + \value ContentSecurityPolicy + \value ContentSecurityPolicyReportOnly + \value ContentType + \value Cookie + \value CrossOriginEmbedderPolicy + \value CrossOriginEmbedderPolicyReportOnly + \value CrossOriginOpenerPolicy + \value CrossOriginOpenerPolicyReportOnly + \value CrossOriginResourcePolicy + \value DASL + \value Date + \value DAV + \value DeltaBase + \value Depth + \value Destination + \value DifferentialID + \value DPoP + \value DPoPNonce + \value EarlyData + \value ETag + \value Expect + \value ExpectCT + \value Expires + \value Forwarded + \value From + \value Hobareg + \value Host + \value If + \value IfMatch + \value IfModifiedSince + \value IfNoneMatch + \value IfRange + \value IfScheduleTagMatch + \value IfUnmodifiedSince + \value IM + \value IncludeReferredTokenBindingID + \value KeepAlive + \value Label + \value LastEventID + \value LastModified + \value Link + \value Location + \value LockToken + \value MaxForwards + \value MementoDatetime + \value Meter + \value MIMEVersion + \value Negotiate + \value NEL + \value ODataEntityId + \value ODataIsolation + \value ODataMaxVersion + \value ODataVersion + \value OptionalWWWAuthenticate + \value OrderingType + \value Origin + \value OriginAgentCluster + \value OSCORE + \value OSLCCoreVersion + \value Overwrite + \value PingFrom + \value PingTo + \value Position + \value Prefer + \value PreferenceApplied + \value Priority + \value ProxyAuthenticate + \value ProxyAuthenticationInfo + \value ProxyAuthorization + \value ProxyStatus + \value PublicKeyPins + \value PublicKeyPinsReportOnly + \value Range + \value RedirectRef + \value Referer + \value Refresh + \value ReplayNonce + \value ReprDigest + \value RetryAfter + \value ScheduleReply + \value ScheduleTag + \value SecPurpose + \value SecTokenBinding + \value SecWebSocketAccept + \value SecWebSocketExtensions + \value SecWebSocketKey + \value SecWebSocketProtocol + \value SecWebSocketVersion + \value Server + \value ServerTiming + \value SetCookie + \value Signature + \value SignatureInput + \value SLUG + \value SoapAction + \value StatusURI + \value StrictTransportSecurity + \value Sunset + \value SurrogateCapability + \value SurrogateControl + \value TCN + \value TE + \value Timeout + \value Topic + \value Traceparent + \value Tracestate + \value Trailer + \value TransferEncoding + \value TTL + \value Upgrade + \value Urgency + \value UserAgent + \value VariantVary + \value Vary + \value Via + \value WantContentDigest + \value WantReprDigest + \value WWWAuthenticate + \value XContentTypeOptions + \value XFrameOptions + \value AcceptCharset + \value CPEPInfo + \value Pragma + \value ProtocolInfo + \value ProtocolQuery +*/ + +/*! + \enum QHttpHeaders::CompareOption + + This enum type contains the options for comparing two + QHttpHeaders instances. + + \value OrderInsensitive + Specifies that the order of headers is not significant in the comparison. + With this option, two QHttpHeaders instances will be considered equal + if they contain the same headers regardless of their order. This is + true with most HTTP headers and use cases. + + \value OrderSensitive + Specifies that the order of headers is significant in the comparison. + With this option, two QHttpHeaders instances will be considered equal + only if they contain the same headers in the same exact order. +*/ + +/*! + Creates a new QHttpHeaders object. +*/ +QHttpHeaders::QHttpHeaders() : d(new QHttpHeadersPrivate) +{ +} + +/*! + Creates a new QHttpHeaders object that is populated with + \a headers. + + \sa {Allowed field name and value characters} +*/ +QHttpHeaders QHttpHeaders::fromListOfPairs(const QList> &headers) +{ + QHttpHeaders h; + for (const auto &header : headers) + h.append(header.first, header.second); + return h; +} + +/*! + Creates a new QHttpHeaders object that is populated with + \a headers. + + \sa {Allowed field name and value characters} +*/ +QHttpHeaders QHttpHeaders::fromMultiMap(const QMultiMap &headers) +{ + QHttpHeaders h; + for (const auto &[name,value] : headers.asKeyValueRange()) + h.append(name, value); + return h; +} + +/*! + Creates a new QHttpHeaders object that is populated with + \a headers. + + \sa {Allowed field name and value characters} +*/ +QHttpHeaders QHttpHeaders::fromMultiHash(const QMultiHash &headers) +{ + QHttpHeaders h; + for (const auto &[name,value] : headers.asKeyValueRange()) + h.append(name, value); + return h; +} + +/*! + Disposes of the headers object. +*/ +QHttpHeaders::~QHttpHeaders() + = default; + +/*! + Creates a copy of \a other. +*/ +QHttpHeaders::QHttpHeaders(const QHttpHeaders &other) + = default; + +/*! + Assigns the contents of \a other and returns a reference to this object. +*/ +QHttpHeaders &QHttpHeaders::operator=(const QHttpHeaders &other) + = default; + +/*! + \fn QHttpHeaders::QHttpHeaders(QHttpHeaders &&other) noexcept + + Move-constructs the object from \a other. + + \note The moved-from object \a other is placed in a + partially-formed state, in which the only valid operations are + destruction and assignment of a new value. +*/ + +/*! + \fn QHttpHeaders &QHttpHeaders::operator=(QHttpHeaders &&other) noexcept + + Move-assigns \a other and returns a reference to this object. + + \note The moved-from object \a other is placed in a + partially-formed state, in which the only valid operations are + destruction and assignment of a new value. +*/ + +/*! + \fn void QHttpHeaders::swap(QHttpHeaders &other) + + Swaps this QHttpHeaders with \a other. This function is very fast and + never fails. +*/ + +#ifndef QT_NO_DEBUG_STREAM +/*! + \fn QDebug QHttpHeaders::operator<<(QDebug debug, + const QHttpHeaders &headers) + + Writes \a headers into \a debug stream. +*/ +QDebug operator<<(QDebug debug, const QHttpHeaders &headers) +{ + const QDebugStateSaver saver(debug); + debug.resetFormat().nospace(); + + debug << "QHttpHeaders(headers = "; + const char *separator = ""; + for (const auto &h : headers.d->headers) { + debug << separator << h.name << ':' << h.value; + separator = " | "; + } + debug << ")"; + return debug; +} +#endif + +// A clarification on string encoding: +// Setters and getters only accept names and values that are Latin-1 representable: +// Either they are directly ASCII/Latin-1, or if they are UTF-X, they only use first 256 +// of the unicode points. For example using a '€' (U+20AC) in value would yield a warning +// and the call is ignored. +// Furthermore the 'name' has more strict rules than the 'value' + +// TODO FIXME REMOVEME once this is merged: +// https://codereview.qt-project.org/c/qt/qtbase/+/508829 +static bool isUtf8Latin1Representable(QUtf8StringView s) noexcept +{ + // L1 encoded in UTF8 has at most the form + // - 0b0XXX'XXXX - US-ASCII + // - 0b1100'00XX 0b10XX'XXXX - at most 8 non-zero LSB bits allowed in L1 + bool inMultibyte = false; + for (unsigned char c : s) { + if (c < 128) { // US-ASCII + if (inMultibyte) + return false; // invalid sequence + } else { + // decode as UTF-8: + if ((c & 0b1110'0000) == 0b1100'0000) { // two-octet UTF-8 leader + if (inMultibyte) + return false; // invalid sequence + inMultibyte = true; + const auto bits_7_to_11 = c & 0b0001'1111; + if (bits_7_to_11 < 0b10) + return false; // invalid sequence (US-ASCII encoded in two octets) + if (bits_7_to_11 > 0b11) // more than the two LSB + return false; // outside L1 + } else if ((c & 0b1100'0000) == 0b1000'0000) { // trailing UTF-8 octet + if (!inMultibyte) + return false; // invalid sequence + inMultibyte = false; // only one continuation allowed + } else { + return false; // invalid sequence or outside of L1 + } + } + } + if (inMultibyte) + return false; // invalid sequence: premature end + return true; +} + +static constexpr auto isValidHttpHeaderNameChar = [](uchar c) noexcept +{ + // RFC 9110 Chapters "5.1 Field Names" and "5.6.2 Tokens" + // field-name = token + // token = 1*tchar + // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / + // "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" + // / DIGIT / ALPHA + // ; any VCHAR, except delimiters + // (for explanation on VCHAR see isValidHttpHeaderValueChar) + return (('A' <= c && c <= 'Z') + || ('a' <= c && c <= 'z') + || ('0' <= c && c <= '9') + || ('#' <= c && c <= '\'') + || ('^' <= c && c <= '`') + || c == '|' || c == '~' || c == '!' || c == '*' || c == '+' || c == '-' || c == '.'); +}; + +static bool headerNameValidImpl(QLatin1StringView name) noexcept +{ + return std::all_of(name.begin(), name.end(), isValidHttpHeaderNameChar); +} + +static bool headerNameValidImpl(QUtf8StringView name) noexcept +{ + // Traversing the UTF-8 string char-by-char is fine in this case as + // the isValidHttpHeaderNameChar rejects any value above 0x7E. UTF-8 + // only has bytes <= 0x7F if they truly represent that ASCII character. + return headerNameValidImpl(QLatin1StringView(QByteArrayView(name))); +} + +static bool headerNameValidImpl(QStringView name) noexcept +{ + return std::all_of(name.begin(), name.end(), [](QChar c) { + return isValidHttpHeaderNameChar(c.toLatin1()); + }); +} + +static bool isValidHttpHeaderNameField(QAnyStringView name) noexcept +{ + if (name.isEmpty()) { + qCWarning(lcQHttpHeaders, "HTTP header name cannot be empty"); + return false; + } + const bool valid = name.visit([](auto name){ return headerNameValidImpl(name); }); + if (!valid) + qCWarning(lcQHttpHeaders, "HTTP header name contained illegal character(s)"); + return valid; +} + +static constexpr auto isValidHttpHeaderValueChar = [](uchar c) noexcept +{ + // RFC 9110 Chapter 5.5, Field Values + // field-value = *field-content + // field-content = field-vchar + // [ 1*( SP / HTAB / field-vchar ) field-vchar ] + // field-vchar = VCHAR / obs-text + // obs-text = %x80-FF + // VCHAR is defined as "any visible US-ASCII character", and RFC 5234 B.1. + // defines it as %x21-7E + // Note: The ABNF above states that field-content and thus field-value cannot + // start or end with SP/HTAB. The caller should handle this. + return (c >= 0x80 // obs-text (extended ASCII) + || (0x20 <= c && c <= 0x7E) // SP (0x20) + VCHAR + || (c == 0x09)); // HTAB +}; + +static bool headerValueValidImpl(QLatin1StringView value) noexcept +{ + return std::all_of(value.begin(), value.end(), isValidHttpHeaderValueChar); +} + +static bool headerValueValidImpl(QUtf8StringView value) noexcept +{ + if (!isUtf8Latin1Representable(value)) // TODO FIXME see the function + return false; + return std::all_of(value.begin(), value.end(), isValidHttpHeaderValueChar); +} + +static bool headerValueValidImpl(QStringView value) noexcept +{ + return std::all_of(value.begin(), value.end(), [](QChar c) { + return isValidHttpHeaderValueChar(c.toLatin1()); + }); +} + +static bool isValidHttpHeaderValueField(QAnyStringView value) noexcept +{ + const bool valid = value.visit([](auto value){ return headerValueValidImpl(value); }); + if (!valid) + qCWarning(lcQHttpHeaders, "HTTP header value contained illegal character(s)"); + return valid; +} + +static QByteArray fieldToByteArray(QLatin1StringView s) noexcept +{ + return QByteArray(s.data(), s.size()); +} + +static QByteArray fieldToByteArray(QUtf8StringView s) noexcept +{ + return QByteArray(s.data(), s.size()); +} + +static QByteArray fieldToByteArray(QStringView s) +{ + return s.toLatin1(); +} + +static QByteArray normalizedName(QAnyStringView name) +{ + return name.visit([](auto name){ return fieldToByteArray(name); }).toLower(); +} + +static QByteArray normalizedValue(QAnyStringView value) +{ + // Note on trimming away any leading or trailing whitespace of 'value': + // RFC 9110 (HTTP 1.1, 2022, Chapter 5.5) does not allow leading or trailing whitespace + // RFC 7230 (HTTP 1.1, 2014, Chapter 3.2) allows them optionally, but also mandates that + // they are ignored during processing + // RFC 7540 (HTTP/2) does not seem explicit about it + // => for maximum compatibility, trim away any leading or trailing whitespace + return value.visit([](auto value){ return fieldToByteArray(value); }).trimmed(); +} + +static bool headerNameIs(const Header &header, QAnyStringView name) +{ + return header.name == normalizedName(name); +} + +/*! + Appends a header entry with \a name and \a value and returns \c true + if successful. + + \sa append(QHttpHeaders::WellKnownHeader, QAnyStringView) + \sa {Allowed field name and value characters} +*/ +bool QHttpHeaders::append(QAnyStringView name, QAnyStringView value) +{ + if (!isValidHttpHeaderNameField(name) || !isValidHttpHeaderValueField(value)) + return false; + + d.detach(); + d->headers.push_back({normalizedName(name), normalizedValue(value)}); + return true; +} + +/*! + \overload append(QAnyStringView, QAnyStringView) +*/ +bool QHttpHeaders::append(WellKnownHeader name, QAnyStringView value) +{ + if (!isValidHttpHeaderValueField(value)) + return false; + + d.detach(); + d->headers.push_back({headerNames[qToUnderlying(name)], normalizedValue(value)}); + return true; +} + +/*! + Inserts a header entry at index \a i, with \a name and \a value. The index + must be valid (see \l size()). Returns whether the insert succeeded. + + \sa append(), + insert(qsizetype, QHttpHeaders::WellKnownHeader, QAnyStringView), size() + \sa {Allowed field name and value characters} +*/ +bool QHttpHeaders::insert(qsizetype i, QAnyStringView name, QAnyStringView value) +{ + d->verify(i, 0); + if (!isValidHttpHeaderNameField(name) || !isValidHttpHeaderValueField(value)) + return false; + + d.detach(); + d->headers.insert(i, {normalizedName(name), normalizedValue(value)}); + return true; +} + +/*! + \overload insert(qsizetype, QAnyStringView, QAnyStringView) +*/ +bool QHttpHeaders::insert(qsizetype i, WellKnownHeader name, QAnyStringView value) +{ + d->verify(i, 0); + if (!isValidHttpHeaderValueField(value)) + return false; + + d.detach(); + d->headers.insert(i, {headerNames[qToUnderlying(name)], normalizedValue(value)}); + return true; +} + +/*! + Replaces the header entry at index \a i, with \a name and \a value. + The index must be valid (see \l size()). Returns whether the replace + succeeded. + + \sa append(), + replace(qsizetype, QHttpHeaders::WellKnownHeader, QAnyStringView), size() + \sa {Allowed field name and value characters} +*/ +bool QHttpHeaders::replace(qsizetype i, QAnyStringView name, QAnyStringView value) +{ + d->verify(i); + if (!isValidHttpHeaderNameField(name) || !isValidHttpHeaderValueField(value)) + return false; + + d.detach(); + d->headers.replace(i, {normalizedName(name), normalizedValue(value)}); + return true; +} + +/*! + \overload replace(qsizetype, QAnyStringView, QAnyStringView) +*/ +bool QHttpHeaders::replace(qsizetype i, WellKnownHeader name, QAnyStringView value) +{ + d->verify(i); + if (!isValidHttpHeaderValueField(value)) + return false; + + d.detach(); + d->headers.replace(i, {headerNames[qToUnderlying(name)], normalizedValue(value)}); + return true; +} + +/*! + Returns whether the headers contain header with \a name. + + \sa has(QHttpHeaders::WellKnownHeader) +*/ +bool QHttpHeaders::has(QAnyStringView name) const +{ + return std::any_of(d->headers.cbegin(), d->headers.cend(), + [&name](const Header &header) { return headerNameIs(header, name); }); +} + +/*! + \overload has(QAnyStringView) +*/ +bool QHttpHeaders::has(WellKnownHeader name) const +{ + return has(headerNames[qToUnderlying(name)]); +} + +/*! + Returns a list of unique header names. + Header names are case-insensitive, and the returned + names are lower-cased. +*/ +QList QHttpHeaders::names() const +{ + QList names; + for (const Header &header: d->headers) { + if (!names.contains(header.name)) + names.append(header.name); + } + return names; +} + +/*! + Removes the header \a name. + + \sa removeAt(), removeAll(QHttpHeaders::WellKnownHeader) +*/ +void QHttpHeaders::removeAll(QAnyStringView name) +{ + if (has(name)) { + d.detach(); + d->headers.removeIf([&name](const Header &header){ + return headerNameIs(header, name); + }); + } +} + +/*! + \overload removeAll(QAnyStringView) +*/ +void QHttpHeaders::removeAll(WellKnownHeader name) +{ + removeAll(headerNames[qToUnderlying(name)]); +} + +/*! + Removes the header at index \a i. The index \a i must be valid + (see \l size()). + + \sa removeAll(QHttpHeaders::WellKnownHeader), + removeAll(QAnyStringView), size() +*/ +void QHttpHeaders::removeAt(qsizetype i) +{ + d->verify(i); + d.detach(); + d->headers.removeAt(i); +} + +/*! + Returns the values of header \a name in a list. Returns an empty + list if header with \a name doesn't exist. + + \sa values(QHttpHeaders::WellKnownHeader) +*/ +QList QHttpHeaders::values(QAnyStringView name) const +{ + QList values; + for (const auto &h : std::as_const(d->headers)) { + if (headerNameIs(h, name)) + values.append(h.value); + } + return values; +} + +/*! + \overload values(QAnyStringView) +*/ +QList QHttpHeaders::values(WellKnownHeader name) const +{ + return values(headerNames[qToUnderlying(name)]); +} + +/*! + Returns the values of header \a name in a comma-combined string. + Returns a \c null QByteArray if the header with \a name doesn't + exist. + + \note Accessing the value(s) of 'Set-Cookie' header this way may not work + as intended. It is a notable exception in the + \l {https://datatracker.ietf.org/doc/html/rfc9110#name-field-order}{HTTP RFC} + in that its values cannot be combined this way. Prefer \l values() instead. + + \sa values(QAnyStringView) +*/ +QByteArray QHttpHeaders::combinedValue(QAnyStringView name) const +{ + QByteArray result; + + const char* separator = ""; + auto valueList = values(name); + for (const auto &v : valueList) { + result.append(separator); + result.append(v); + separator = ","; + } + return result; +} + +/*! + \overload combinedValue(QAnyStringView) +*/ +QByteArray QHttpHeaders::combinedValue(WellKnownHeader name) const +{ + return combinedValue(headerNames[qToUnderlying(name)]); +} + +/*! + Returns the number of header entries. +*/ +qsizetype QHttpHeaders::size() const noexcept +{ + return d->headers.size(); +} + +/*! + Compares this instance with \a other and returns \c true if they + are considered equal in accordance with the provided \a options. + + The header names are always compared as case-insensitive, and values + as case-sensitive. For example \e Accept and \e ACCEPT header names + are considered equal, while values \e something and \e SOMETHING are not. +*/ +bool QHttpHeaders::equals(const QHttpHeaders &other, CompareOptions options) const noexcept +{ + return d == other.d || d->equals(*other.d, options); +} + +/*! + Returns the header entries as a list of (name, value) pairs. + Header names are case-insensitive, and the returned names are lower-cased. +*/ +QList> QHttpHeaders::toListOfPairs() const +{ + QList> list; + list.reserve(size()); + for (const auto & h : std::as_const(d->headers)) + list.append({h.name, h.value}); + return list; +} + +/*! + Returns the header entries as a map from name to value(s). + Header names are case-insensitive, and the returned names are lower-cased. +*/ +QMultiMap QHttpHeaders::toMultiMap() const +{ + QMultiMap map; + for (const auto &h : std::as_const(d->headers)) + map.insert(h.name, h.value); + return map; +} + +/*! + Returns the header entries as a hash from name to value(s). + Header names are case-insensitive, and the returned names are lower-cased. +*/ +QMultiHash QHttpHeaders::toMultiHash() const +{ + QMultiHash hash; + hash.reserve(size()); + for (const auto &h : std::as_const(d->headers)) + hash.insert(h.name, h.value); + return hash; +} + +/*! + Clears all header entries. + + \sa size() +*/ +void QHttpHeaders::clear() +{ + if (d->headers.isEmpty()) + return; + d.detach(); + d->headers.clear(); +} + +QT_END_NAMESPACE diff --git a/src/network/access/qhttpheaders.h b/src/network/access/qhttpheaders.h new file mode 100644 index 0000000000..636f535c88 --- /dev/null +++ b/src/network/access/qhttpheaders.h @@ -0,0 +1,276 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +#ifndef QHTTPHEADERS_H +#define QHTTPHEADERS_H + +#include +#include +#include + +#include + +QT_BEGIN_NAMESPACE + +class QDebug; + +class QHttpHeadersPrivate; +QT_DECLARE_QESDP_SPECIALIZATION_DTOR_WITH_EXPORT(QHttpHeadersPrivate, Q_NETWORK_EXPORT) +class QHttpHeaders +{ + Q_GADGET_EXPORT(Q_NETWORK_EXPORT) +public: + enum class WellKnownHeader { + // IANA Permanent status: + AIM, + Accept, + AcceptAdditions, + AcceptCH, + AcceptDatetime, + AcceptEncoding, + AcceptFeatures, + AcceptLanguage, + AcceptPatch, + AcceptPost, + AcceptRanges, + AcceptSignature, + AccessControlAllowCredentials, + AccessControlAllowHeaders, + AccessControlAllowMethods, + AccessControlAllowOrigin, + AccessControlExposeHeaders, + AccessControlMaxAge, + AccessControlRequestHeaders, + AccessControlRequestMethod, + Age, + Allow, + ALPN, + AltSvc, + AltUsed, + Alternates, + ApplyToRedirectRef, + AuthenticationControl, + AuthenticationInfo, + Authorization, + CacheControl, + CacheStatus, + CalManagedID, + CalDAVTimezones, + CapsuleProtocol, + CDNCacheControl, + CDNLoop, + CertNotAfter, + CertNotBefore, + ClearSiteData, + ClientCert, + ClientCertChain, + Close, + Connection, + ContentDigest, + ContentDisposition, + ContentEncoding, + ContentID, + ContentLanguage, + ContentLength, + ContentLocation, + ContentRange, + ContentSecurityPolicy, + ContentSecurityPolicyReportOnly, + ContentType, + Cookie, + CrossOriginEmbedderPolicy, + CrossOriginEmbedderPolicyReportOnly, + CrossOriginOpenerPolicy, + CrossOriginOpenerPolicyReportOnly, + CrossOriginResourcePolicy, + DASL, + Date, + DAV, + DeltaBase, + Depth, + Destination, + DifferentialID, + DPoP, + DPoPNonce, + EarlyData, + ETag, + Expect, + ExpectCT, + Expires, + Forwarded, + From, + Hobareg, + Host, + If, + IfMatch, + IfModifiedSince, + IfNoneMatch, + IfRange, + IfScheduleTagMatch, + IfUnmodifiedSince, + IM, + IncludeReferredTokenBindingID, + KeepAlive, + Label, + LastEventID, + LastModified, + Link, + Location, + LockToken, + MaxForwards, + MementoDatetime, + Meter, + MIMEVersion, + Negotiate, + NEL, + ODataEntityId, + ODataIsolation, + ODataMaxVersion, + ODataVersion, + OptionalWWWAuthenticate, + OrderingType, + Origin, + OriginAgentCluster, + OSCORE, + OSLCCoreVersion, + Overwrite, + PingFrom, + PingTo, + Position, + Prefer, + PreferenceApplied, + Priority, + ProxyAuthenticate, + ProxyAuthenticationInfo, + ProxyAuthorization, + ProxyStatus, + PublicKeyPins, + PublicKeyPinsReportOnly, + Range, + RedirectRef, + Referer, + Refresh, + ReplayNonce, + ReprDigest, + RetryAfter, + ScheduleReply, + ScheduleTag, + SecPurpose, + SecTokenBinding, + SecWebSocketAccept, + SecWebSocketExtensions, + SecWebSocketKey, + SecWebSocketProtocol, + SecWebSocketVersion, + Server, + ServerTiming, + SetCookie, + Signature, + SignatureInput, + SLUG, + SoapAction, + StatusURI, + StrictTransportSecurity, + Sunset, + SurrogateCapability, + SurrogateControl, + TCN, + TE, + Timeout, + Topic, + Traceparent, + Tracestate, + Trailer, + TransferEncoding, + TTL, + Upgrade, + Urgency, + UserAgent, + VariantVary, + Vary, + Via, + WantContentDigest, + WantReprDigest, + WWWAuthenticate, + XContentTypeOptions, + XFrameOptions, + // IANA Deprecated status: + AcceptCharset, + CPEPInfo, + Pragma, + ProtocolInfo, + ProtocolQuery, + }; + Q_ENUM(WellKnownHeader) + + enum class CompareOption { + OrderInsensitive = 0x01, + OrderSensitive = 0x02, + }; + Q_DECLARE_FLAGS(CompareOptions, CompareOption) + + Q_NETWORK_EXPORT QHttpHeaders(); + Q_NETWORK_EXPORT ~QHttpHeaders(); + + Q_NETWORK_EXPORT QHttpHeaders(const QHttpHeaders &other); + QHttpHeaders(QHttpHeaders &&other) noexcept = default; + Q_NETWORK_EXPORT QHttpHeaders &operator=(const QHttpHeaders &other); + QT_MOVE_ASSIGNMENT_OPERATOR_IMPL_VIA_PURE_SWAP(QHttpHeaders) + void swap(QHttpHeaders &other) noexcept { d.swap(other.d); } + + Q_NETWORK_EXPORT bool append(QAnyStringView name, QAnyStringView value); + Q_NETWORK_EXPORT bool append(WellKnownHeader name, QAnyStringView value); + + Q_NETWORK_EXPORT bool insert(qsizetype i, QAnyStringView name, QAnyStringView value); + Q_NETWORK_EXPORT bool insert(qsizetype i, WellKnownHeader name, QAnyStringView value); + + Q_NETWORK_EXPORT bool replace(qsizetype i, QAnyStringView name, QAnyStringView value); + Q_NETWORK_EXPORT bool replace(qsizetype i, WellKnownHeader name, QAnyStringView value); + + Q_NETWORK_EXPORT bool has(QAnyStringView name) const; + Q_NETWORK_EXPORT bool has(WellKnownHeader name) const; + + Q_NETWORK_EXPORT QList names() const; + + Q_NETWORK_EXPORT void clear(); + Q_NETWORK_EXPORT void removeAll(QAnyStringView name); + Q_NETWORK_EXPORT void removeAll(WellKnownHeader name); + Q_NETWORK_EXPORT void removeAt(qsizetype i); + + Q_NETWORK_EXPORT QList values(QAnyStringView name) const; + Q_NETWORK_EXPORT QList values(WellKnownHeader name) const; + + Q_NETWORK_EXPORT QByteArray combinedValue(QAnyStringView name) const; + Q_NETWORK_EXPORT QByteArray combinedValue(WellKnownHeader name) const; + + Q_NETWORK_EXPORT qsizetype size() const noexcept; + bool isEmpty() const noexcept { return size() == 0; } + + Q_NETWORK_EXPORT bool equals(const QHttpHeaders &other, + CompareOptions options = CompareOption::OrderInsensitive) const noexcept; + + Q_NETWORK_EXPORT static QHttpHeaders + fromListOfPairs(const QList> &headers); + Q_NETWORK_EXPORT static QHttpHeaders + fromMultiMap(const QMultiMap &headers); + Q_NETWORK_EXPORT static QHttpHeaders + fromMultiHash(const QMultiHash &headers); + + Q_NETWORK_EXPORT QList> toListOfPairs() const; + Q_NETWORK_EXPORT QMultiMap toMultiMap() const; + Q_NETWORK_EXPORT QMultiHash toMultiHash() const; + +private: +#ifndef QT_NO_DEBUG_STREAM + friend Q_NETWORK_EXPORT QDebug operator<<(QDebug debug, const QHttpHeaders &headers); +#endif + QExplicitlySharedDataPointer d; +}; + +Q_DECLARE_OPERATORS_FOR_FLAGS(QHttpHeaders::CompareOptions) + +Q_DECLARE_SHARED(QHttpHeaders) + +QT_END_NAMESPACE + +#endif // QHTTPHEADERS_H diff --git a/tests/auto/network/access/CMakeLists.txt b/tests/auto/network/access/CMakeLists.txt index 5f2af4ba12..a3b56646bb 100644 --- a/tests/auto/network/access/CMakeLists.txt +++ b/tests/auto/network/access/CMakeLists.txt @@ -1,6 +1,7 @@ # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: BSD-3-Clause +add_subdirectory(qhttpheaders) add_subdirectory(qnetworkdiskcache) add_subdirectory(qnetworkcookiejar) add_subdirectory(qnetworkaccessmanager) diff --git a/tests/auto/network/access/qhttpheaders/CMakeLists.txt b/tests/auto/network/access/qhttpheaders/CMakeLists.txt new file mode 100644 index 0000000000..fb88c97f44 --- /dev/null +++ b/tests/auto/network/access/qhttpheaders/CMakeLists.txt @@ -0,0 +1,10 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +qt_internal_add_test(tst_qhttpheaders + SOURCES + tst_qhttpheaders.cpp + LIBRARIES + Qt::Core + Qt::Network +) diff --git a/tests/auto/network/access/qhttpheaders/tst_qhttpheaders.cpp b/tests/auto/network/access/qhttpheaders/tst_qhttpheaders.cpp new file mode 100644 index 0000000000..02fb8cac0a --- /dev/null +++ b/tests/auto/network/access/qhttpheaders/tst_qhttpheaders.cpp @@ -0,0 +1,517 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 + +#include + +#include + +#include + +using namespace Qt::StringLiterals; + +class tst_QHttpHeaders : public QObject +{ + Q_OBJECT + +private slots: + void comparison(); + void constructors(); + void accessors(); + void headerNameField(); + void headerValueField(); + void valueEncoding(); + +private: + static constexpr QAnyStringView n1{"name1"}; + static constexpr QAnyStringView n2{"name2"}; + static constexpr QAnyStringView n3{"name3"}; + static constexpr QAnyStringView v1{"value1"}; + static constexpr QAnyStringView v2{"value2"}; + static constexpr QAnyStringView v3{"value3"}; + static constexpr QAnyStringView N1{"NAME1"}; + static constexpr QAnyStringView N2{"NAME2"}; + static constexpr QAnyStringView N3{"NAME3"}; + static constexpr QAnyStringView V1{"VALUE1"}; + static constexpr QAnyStringView V2{"VALUE2"}; + static constexpr QAnyStringView V3{"VALUE3"}; +}; + +void tst_QHttpHeaders::comparison() +{ + // Basic comparisons + QHttpHeaders h1; + QHttpHeaders h2; + QVERIFY(h1.equals(h2)); // empties + h1.append(n1, v1); + QVERIFY(h1.equals(h1)); // self + h2.append(n1, v1); + QVERIFY(h1.equals(h2)); + h1.append(n2, v2); + QVERIFY(!h1.equals(h2)); + h1.removeAll(n2); + QVERIFY(h1.equals(h2)); + + // 'name' case-insensitivity and 'value' case-sensitivity + h1.removeAll(n1); + QVERIFY(h1.isEmpty()); + h1.append(N1, v1); + QVERIFY(h1.equals(h2)); + h1.removeAll(n1); + QVERIFY(h1.isEmpty()); + h1.append(n1, V1); + QVERIFY(!h1.equals(h2)); + + // Order-insensitivity + h1.clear(); + h2.clear(); + QVERIFY(h1.isEmpty() && h2.isEmpty()); + // Same headers but in different order + h1.append(n1, v1); + h1.append(n2, v2); + h2.append(n2, v2); + h2.append(n1, v1); + QVERIFY(h1.equals(h2)); + // Add header with different name casing + h1.insert(0, n1, v1); + h2.append(N1, v1); + QVERIFY(h1.equals(h2)); + // Add header with different value casing + h1.insert(0, n1, v1); + h2.append(n1, V1); + QVERIFY(!h1.equals(h2)); + + // Order-sensitivity + h1.clear(); + h2.clear(); + QVERIFY(h1.equals(h2, QHttpHeaders::CompareOption::OrderSensitive)); + // Same headers but in different order + h1.append(n1, v1); + h1.append(n2, v2); + h2.append(n2, v2); + h2.append(n1, v1); + QVERIFY(!h1.equals(h2, QHttpHeaders::CompareOption::OrderSensitive)); + + // Different number of headers + h1.clear(); + h2.clear(); + h1.append(n1, v1); + h2.append(n1, v1); + h2.append(n2, v2); + QVERIFY(!h1.equals(h2)); + QVERIFY(!h1.equals(h2, QHttpHeaders::CompareOption::OrderSensitive)); +} + +void tst_QHttpHeaders::constructors() +{ + // Default ctor + QHttpHeaders h1; + QVERIFY(h1.isEmpty()); + + // Copy ctor + QHttpHeaders h2(h1); + QVERIFY(h2.equals(h1)); + + // Copy assignment + QHttpHeaders h3; + h3 = h1; + QVERIFY(h3.equals(h1)); + + // Move assignment + QHttpHeaders h4; + h4 = std::move(h2); + QVERIFY(h4.equals(h1)); + + // Move ctor + QHttpHeaders h5(std::move(h4)); + QVERIFY(h5.equals(h1)); + + // Constructors that are counterparts to 'toXXX()' conversion getters + const QByteArray nb1{"name1"}; + const QByteArray nb2{"name2"}; + const QByteArray nv1{"value1"}; + const QByteArray nv2{"value2"}; + // Initialize three QHttpHeaders with same content and verify they match + QList> list{{nb1, nv1}, {nb2, nv2}, {nb2, nv2}}; + QMultiMap map{{nb1, nv1}, {nb2, nv2}, {nb2, nv2}}; + QMultiHash hash{{nb1, nv1}, {nb2, nv2}, {nb2, nv2}}; + QHttpHeaders hlist = QHttpHeaders::fromListOfPairs(list); + QHttpHeaders hmap = QHttpHeaders::fromMultiMap(map); + QHttpHeaders hhash = QHttpHeaders::fromMultiHash(hash); + QVERIFY(hlist.has(nb1) && hmap.has(nb1) && hhash.has(nb1)); + QVERIFY(!hlist.has(n3) && !hmap.has(n3) && !hhash.has(n3)); + QVERIFY(hlist.equals(hmap)); + QVERIFY(hmap.equals(hhash)); +} + +void tst_QHttpHeaders::accessors() +{ + QHttpHeaders h1; + + // isEmpty(), clear(), size() + h1.append(n1,v1); + QVERIFY(!h1.isEmpty()); + QCOMPARE(h1.size(), 1); + QVERIFY(h1.append(n1, v1)); + QCOMPARE(h1.size(), 2); + h1.insert(0, n1, v1); + QCOMPARE(h1.size(), 3); + h1.clear(); + QVERIFY(h1.isEmpty()); + + // has() + h1.append(n1, v1); + QVERIFY(h1.has(n1)); + QVERIFY(h1.has(N1)); + QVERIFY(!h1.has(n2)); + QVERIFY(!h1.has(QHttpHeaders::WellKnownHeader::Allow)); + h1.append(QHttpHeaders::WellKnownHeader::Accept, "nothing"); + QVERIFY(h1.has(QHttpHeaders::WellKnownHeader::Accept)); + QVERIFY(h1.has("accept")); + + // values() + QCOMPARE(h1.values(n1).at(0), v1); + QCOMPARE(h1.values(N1).at(0), v1); + QCOMPARE(h1.values(QHttpHeaders::WellKnownHeader::Accept).at(0), "nothing"); + QCOMPARE(h1.values("Accept").at(0), "nothing"); + QVERIFY(h1.values(N2).isEmpty()); + QVERIFY(h1.values(QHttpHeaders::WellKnownHeader::Allow).isEmpty()); + h1.clear(); + QVERIFY(h1.values(n1).isEmpty()); + h1.append(n1, v1); + h1.append(n1, v2); + h1.append(n1, v3); + h1.append(n2, v2); + h1.append(n3, ""); // empty value + QCOMPARE(h1.values(n1).size(), 3); + QCOMPARE(h1.values(n1).at(0), v1); + QCOMPARE(h1.values(n1).at(1), v2); + QCOMPARE(h1.values(n1).at(2), v3); + QCOMPARE(h1.values(n1), h1.values(N1)); + QVERIFY(!h1.values(n3).isEmpty()); + QCOMPARE(h1.values(n3).at(0), ""); + QVERIFY(!h1.combinedValue(n1).isNull()); + QCOMPARE(h1.combinedValue(n1), "value1,value2,value3"_ba); + h1.append(QHttpHeaders::WellKnownHeader::Accept, "nothing"); + h1.append(QHttpHeaders::WellKnownHeader::Accept, "ever"); + QVERIFY(!h1.combinedValue(QHttpHeaders::WellKnownHeader::Accept).isNull()); + QCOMPARE(h1.combinedValue(QHttpHeaders::WellKnownHeader::Accept), "nothing,ever"); + QVERIFY(h1.combinedValue("nonexistent").isNull()); + + // names() + h1.clear(); + QVERIFY(h1.names().isEmpty()); + h1.append(n1, v1); + QCOMPARE(h1.names().size(), 1); + QCOMPARE(h1.size(), 1); + QVERIFY(h1.names().contains(n1.toString().toLatin1())); + h1.append(n2, v2); + QCOMPARE(h1.names().size(), 2); + QCOMPARE(h1.size(), 2); + QVERIFY(h1.names().contains(n1.toString().toLatin1())); + QVERIFY(h1.names().contains(n2.toString().toLatin1())); + h1.append(n1, v1); + h1.append(n1, v1); + QCOMPARE(h1.size(), 4); + QCOMPARE(h1.names().size(), 2); + h1.append(N1, v1); // uppercase of n1 + QCOMPARE(h1.size(), 5); + QCOMPARE(h1.names().size(), 2); + + // removeAll() + h1.clear(); + QVERIFY(h1.append(n1, v1)); + QVERIFY(h1.append(QHttpHeaders::WellKnownHeader::Accept, "nothing")); + QVERIFY(h1.append(n1, v1)); + QCOMPARE(h1.size(), 3); + h1.removeAll(n1); + QVERIFY(!h1.has(n1)); + QCOMPARE(h1.size(), 1); + QVERIFY(h1.has("accept")); + h1.removeAll(QHttpHeaders::WellKnownHeader::Accept); + QVERIFY(!h1.has(QHttpHeaders::WellKnownHeader::Accept)); + + // removeAt() + h1.clear(); + h1.append(n1, v1); + h1.append(n2, v2); + h1.append(n3, v3); + + // Valid removals + QVERIFY(h1.has(n3)); + h1.removeAt(2); + QVERIFY(!h1.has(n3)); + QVERIFY(h1.has(n1)); + h1.removeAt(0); + QVERIFY(!h1.has(n1)); + QVERIFY(h1.has(n2)); + h1.removeAt(0); + QVERIFY(!h1.has(n2)); + QVERIFY(h1.isEmpty()); + + // toListOfPairs() + h1.clear(); + h1.append(n1, v1); + h1.append(n2, v2); + h1.append(N3, V3); // uppercase of n3 + auto list = h1.toListOfPairs(); + QCOMPARE(list.size(), h1.size()); + QCOMPARE(list.at(0).first, n1); + QCOMPARE(list.at(0).second, v1); + QCOMPARE(list.at(1).first, n2); + QCOMPARE(list.at(1).second, v2); + QCOMPARE(list.at(2).first, n3); // N3 has been lower-cased + QCOMPARE(list.at(2).second, V3); + + // toMultiMap() + auto map = h1.toMultiMap(); + QCOMPARE(map.size(), h1.size()); + QCOMPARE(map.value(n1.toString().toLatin1()), v1); + QCOMPARE(map.value(n2.toString().toLatin1()), v2); + QCOMPARE(map.value(n3.toString().toLatin1()), V3); + + // toMultiHash() + auto hash = h1.toMultiHash(); + QCOMPARE(hash.size(), h1.size()); + QCOMPARE(hash.value(n1.toString().toLatin1()), v1); + QCOMPARE(hash.value(n2.toString().toLatin1()), v2); + QCOMPARE(hash.value(n3.toString().toLatin1()), V3); + + // insert() + h1.clear(); + h1.append(n3, v3); + QVERIFY(h1.insert(0, n1, v1)); + list = h1.toListOfPairs(); + QCOMPARE(list.size(), 2); + QCOMPARE(list.at(0).first, n1); + QCOMPARE(list.at(0).second, v1); + QCOMPARE(list.at(1).first, n3); + QCOMPARE(list.at(1).second, v3); + QVERIFY(h1.insert(1, n2, v2)); + list = h1.toListOfPairs(); + QCOMPARE(list.size(), 3); + QCOMPARE(list.at(0).first, n1); + QCOMPARE(list.at(0).second, v1); + QCOMPARE(list.at(1).first, n2); + QCOMPARE(list.at(1).second, v2); + QCOMPARE(list.at(2).first, n3); + QCOMPARE(list.at(2).second, v3); + QVERIFY(h1.insert(1, QHttpHeaders::WellKnownHeader::Accept, "nothing")); + QCOMPARE(h1.size(), 4); + list = h1.toListOfPairs(); + QCOMPARE(list.at(1).first, "accept"); + QCOMPARE(list.at(1).second, "nothing"); + QVERIFY(h1.insert(list.size(), "LastName", "lastValue")); + QCOMPARE(h1.size(), 5); + list = h1.toListOfPairs(); + QCOMPARE(list.last().first, "lastname"); + QCOMPARE(list.last().second, "lastValue"); + // Failed insert + QRegularExpression re("HTTP header name contained*"); + QTest::ignoreMessage(QtMsgType::QtWarningMsg, re); + QVERIFY(!h1.insert(0, "a€", "b")); + + // replace + h1.clear(); + h1.append(n1, v1); + h1.append(n2, v2); + QCOMPARE(h1.size(), 2); + QVERIFY(h1.replace(0, n3, v3)); + QVERIFY(h1.replace(1, QHttpHeaders::WellKnownHeader::Accept, "nothing")); + QCOMPARE(h1.size(), 2); + list = h1.toListOfPairs(); + QCOMPARE(list.at(0).first, n3); + QCOMPARE(list.at(0).second, v3); + QCOMPARE(list.at(1).first, "accept"); + QCOMPARE(list.at(1).second, "nothing"); + QVERIFY(h1.replace(1, "ACCEPT", "NOTHING")); + QCOMPARE(h1.size(), 2); + list = h1.toListOfPairs(); + QCOMPARE(list.at(0).first, n3); + QCOMPARE(list.at(0).second, v3); + QCOMPARE(list.at(1).first, "accept"); + QCOMPARE(list.at(1).second, "NOTHING"); + // Failed replace + QTest::ignoreMessage(QtMsgType::QtWarningMsg, re); + QVERIFY(!h1.replace(0, "a€", "b")); + +} + +#define TEST_ILLEGAL_HEADER_NAME_CHARACTER(NAME) \ + QTest::ignoreMessage(QtMsgType::QtWarningMsg, re); \ + QVERIFY(!h1.append(NAME, v1)); \ + QVERIFY(h1.isEmpty()); \ + +void tst_QHttpHeaders::headerNameField() +{ + QHttpHeaders h1; + + // All allowed characters in different encodings and types + // const char[] + h1.append("abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'*+-.^_`|~", v1); + QCOMPARE(h1.size(), 1); + // UTF-8 + h1.append(u8"abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'*+-.^_`|~", + v1); + QCOMPARE(h1.size(), 2); + // UTF-16 + h1.append(u"abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'*+-.^_`|~", v1); + QCOMPARE(h1.size(), 3); + // QString (UTF-16) + h1.append(u"abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'*+-.^_`|~"_s, + v1); + QCOMPARE(h1.size(), 4); + QCOMPARE(h1.names().size(), 1); + h1.clear(); + + // Error cases + // Header name must contain at least 1 character + QTest::ignoreMessage(QtMsgType::QtWarningMsg, "HTTP header name cannot be empty"); + h1.append("", v1); + QVERIFY(h1.isEmpty()); + // Disallowed ASCII/extended ASCII characters (not exhaustive list) + QRegularExpression re("HTTP header name contained illegal character*"); + TEST_ILLEGAL_HEADER_NAME_CHARACTER("foo\x08" "bar"); // BS + TEST_ILLEGAL_HEADER_NAME_CHARACTER("foo\x7F" "bar"); // DEL + TEST_ILLEGAL_HEADER_NAME_CHARACTER("foo()" "bar"); // parantheses + TEST_ILLEGAL_HEADER_NAME_CHARACTER("foobar" "¿"); // extended ASCII + TEST_ILLEGAL_HEADER_NAME_CHARACTER("©" "foobar"); // extended ASCII + TEST_ILLEGAL_HEADER_NAME_CHARACTER("foo,bar"); // comma + // Disallowed UTF-8 characters + TEST_ILLEGAL_HEADER_NAME_CHARACTER(u8"€"); + TEST_ILLEGAL_HEADER_NAME_CHARACTER(u8"𝒜𝒴𝟘𝟡𐎀𐎜𐒀𐒐𝓐𝓩𝔸𝔹𝕀𝕁𝕌𝕍𓂀𓂁𓃀𓃁𓇋𓇌𓉐𓉑𓋴𓋵𓎡𓎢𓎣𓏏"); + // Disallowed UTF-16 characters + TEST_ILLEGAL_HEADER_NAME_CHARACTER(u"€"); + TEST_ILLEGAL_HEADER_NAME_CHARACTER(u"𝒜𝒴𝟘𝟡𐎀𐎜𐒀𐒐𝓐𝓩𝔸𝔹𝕀𝕁𝕌𝕍𓂀𓂁𓃀𓃁𓇋𓇌𓉐𓉑𓋴𓋵𓎡𓎢𓎣𓏏"); + + // Non-null-terminated name. The 'x' below is to make sure the strings don't + // null-terminate by happenstance + h1.clear(); + constexpr char L1Array[] = {'a','b','c','x'}; + const QLatin1StringView nonNullLatin1{L1Array, sizeof(L1Array) - 1}; // abc + + constexpr char UTF8Array[] = {0x64, 0x65, 0x66, 0x78}; + const QUtf8StringView nonNullUTF8(UTF8Array, sizeof(UTF8Array) - 1); // def + + constexpr QChar UTF16Array[] = {'g', 'h', 'i', 'x'}; + QStringView nonNullUTF16(UTF16Array, sizeof(UTF16Array) / sizeof(QChar) - 1); // ghi + + h1.append(nonNullLatin1, v1); + QCOMPARE(h1.size(), 1); + QVERIFY(h1.has(nonNullLatin1)); + QCOMPARE(h1.combinedValue(nonNullLatin1), v1); + + h1.append(nonNullUTF8, v2); + QCOMPARE(h1.size(), 2); + QVERIFY(h1.has(nonNullUTF8)); + QCOMPARE(h1.combinedValue(nonNullUTF8), v2); + + h1.append(nonNullUTF16, v3); + QCOMPARE(h1.size(), 3); + QVERIFY(h1.has(nonNullUTF16)); + QCOMPARE(h1.combinedValue(nonNullUTF16), v3); +} + +#define TEST_ILLEGAL_HEADER_VALUE_CHARACTER(VALUE) \ +QTest::ignoreMessage(QtMsgType::QtWarningMsg, re); \ + QVERIFY(!h1.append(n1, VALUE)); \ + QVERIFY(h1.isEmpty()); \ + +void tst_QHttpHeaders::headerValueField() +{ + QHttpHeaders h1; + + // Visible ASCII characters and space and horizontal tab + // const char[] + h1.append(n1, "!\"#$%&'()*+,-./0123456789:; \t<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" + "`abcdefghijklmnopqrstuvwxyz{|}~"); + QCOMPARE(h1.size(), 1); + // UTF-8 + h1.append(n1, u8"!\"#$%&'()*+,-./0123456789:; \t<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" + "`abcdefghijklmnopqrstuvwxyz{|}~"); + QCOMPARE(h1.size(), 2); + // UTF-16 + h1.append(n1, u"!\"#$%&'()*+,-./0123456789:; \t<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" + "`abcdefghijklmnopqrstuvwxyz{|}~"); + QCOMPARE(h1.size(), 3); + // QString / UTF-16 + h1.append(n1, u"!\"#$%&'()*+,-./0123456789:; \t<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" + "`abcdefghijklmnopqrstuvwxyz{|}~"_s); + QCOMPARE(h1.size(), 4); + const auto values = h1.values(n1); + QVERIFY(!values.isEmpty() && values.size() == 4); + QVERIFY(values[0] == values[1] + && values[1] == values[2] + && values[2] == values[3]); + // Extended ASCII (explicit on Latin-1 to avoid UTF-8 interpretation) + h1.append(n1, "\x80\x09\xB2\xFF"_L1); + QCOMPARE(h1.size(), 5); + // Empty value + h1.append(n1, ""); + QCOMPARE(h1.size(), 6); + // Leading and trailing space + h1.clear(); + h1.append(n1, " foo "); + QCOMPARE(h1.combinedValue(n1), "foo"); + h1.append(n1, "\tbar\t"); + QCOMPARE(h1.combinedValue(n1), "foo,bar"); + QCOMPARE(h1.size(), 2); + + h1.clear(); + QRegularExpression re("HTTP header value contained illegal character*"); + TEST_ILLEGAL_HEADER_VALUE_CHARACTER("foo\x08" "bar"); // BS + TEST_ILLEGAL_HEADER_VALUE_CHARACTER("foo\x1B" "bar"); // ESC + // Disallowed UTF-8 characters + TEST_ILLEGAL_HEADER_VALUE_CHARACTER(u8"€"); + TEST_ILLEGAL_HEADER_VALUE_CHARACTER(u8"𝒜𝒴𝟘𝟡𐎀𐎜𐒀𐒐𝓐𝓩𝔸𝔹𝕀𝕁𝕌𝕍𓂀𓂁𓃀𓃁𓇋𓇌𓉐𓉑𓋴𓋵𓎡𓎢𓎣𓏏"); + // Disallowed UTF-16 characters + TEST_ILLEGAL_HEADER_VALUE_CHARACTER(u"€"); + TEST_ILLEGAL_HEADER_VALUE_CHARACTER(u"𝒜𝒴𝟘𝟡𐎀𐎜𐒀𐒐𝓐𝓩𝔸𝔹𝕀𝕁𝕌𝕍𓂀𓂁𓃀𓃁𓇋𓇌𓉐𓉑𓋴𓋵𓎡𓎢𓎣𓏏"); + + // Non-null-terminated value. The 'x' below is to make sure the strings don't + // null-terminate by happenstance + h1.clear(); + constexpr char L1Array[] = {'a','b','c','x'}; + const QLatin1StringView nonNullLatin1{L1Array, sizeof(L1Array) - 1}; // abc + + constexpr char UTF8Array[] = {0x64, 0x65, 0x66, 0x78}; + const QUtf8StringView nonNullUTF8(UTF8Array, sizeof(UTF8Array) - 1); // def + + constexpr QChar UTF16Array[] = {'g', 'h', 'i', 'x'}; + QStringView nonNullUTF16(UTF16Array, sizeof(UTF16Array) / sizeof(QChar) - 1); // ghi + + h1.append(n1, nonNullLatin1); + QCOMPARE(h1.size(), 1); + QVERIFY(h1.has(n1)); + QCOMPARE(h1.combinedValue(n1), "abc"); + + h1.append(n2, nonNullUTF8); + QCOMPARE(h1.size(), 2); + QVERIFY(h1.has(n2)); + QCOMPARE(h1.combinedValue(n2), "def"); + + h1.append(n3, nonNullUTF16); + QCOMPARE(h1.size(), 3); + QVERIFY(h1.has(n3)); + QCOMPARE(h1.combinedValue(n3), "ghi"); +} + +void tst_QHttpHeaders::valueEncoding() +{ + // Test that common encodings are possible to set and not blocked by + // header value character filter (ie. don't contain disallowed characters as per RFC 9110) + QHttpHeaders h1; + // Within visible ASCII range + QVERIFY(h1.append(n1, "foo"_ba.toBase64())); + QCOMPARE(h1.values(n1).at(0), "Zm9v"); + h1.replace(0, n1, "foo"_ba.toPercentEncoding()); + QCOMPARE(h1.values(n1).at(0), "foo"); + + // Outside of ASCII/Latin-1 range (€) + h1.replace(0, n1, "foo€"_ba.toBase64()); + QCOMPARE(h1.values(n1).at(0), "Zm9v4oKs"); + h1.replace(0, n1, "foo€"_ba.toPercentEncoding()); + QCOMPARE(h1.values(n1).at(0), "foo%E2%82%AC"); +} + +QTEST_MAIN(tst_QHttpHeaders) +#include "tst_qhttpheaders.moc"