From 70d91c42fa318175c315ac7e73331b9961e49a63 Mon Sep 17 00:00:00 2001 From: Jonathan Brady Date: Mon, 21 Jul 2025 11:11:40 +0100 Subject: [PATCH] Add support for serving Qt resources in their internal compressed form, if the Accept-Encoding headers allows. --- REUSE.toml | 2 +- src/KDSoapServer/ByteArrayViewSplitter.h | 177 +++++++++++++++++ .../KDSoapServerObjectInterface.cpp | 165 ++++++++++++++++ .../KDSoapServerObjectInterface.h | 31 +++ src/KDSoapServer/KDSoapServerSocket.cpp | 33 ++-- src/KDSoapServer/KDSoapServerSocket_p.h | 17 +- unittests/CMakeLists.txt | 1 + unittests/accept_encoding/CMakeLists.txt | 33 ++++ unittests/accept_encoding/file_deflate.txt | 50 +++++ .../accept_encoding/file_no_compression.txt | 1 + unittests/accept_encoding/file_zstd.txt | 50 +++++ unittests/accept_encoding/resources.qrc | 7 + .../accept_encoding/test_accept_encoding.cpp | 179 ++++++++++++++++++ unittests/accept_encoding/zstd_resources.qrc | 5 + 14 files changed, 737 insertions(+), 14 deletions(-) create mode 100644 src/KDSoapServer/ByteArrayViewSplitter.h create mode 100644 unittests/accept_encoding/CMakeLists.txt create mode 100644 unittests/accept_encoding/file_deflate.txt create mode 100644 unittests/accept_encoding/file_no_compression.txt create mode 100644 unittests/accept_encoding/file_zstd.txt create mode 100644 unittests/accept_encoding/resources.qrc create mode 100644 unittests/accept_encoding/test_accept_encoding.cpp create mode 100644 unittests/accept_encoding/zstd_resources.qrc diff --git a/REUSE.toml b/REUSE.toml index 4524a211..f7012fc4 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -5,7 +5,7 @@ SPDX-PackageDownloadLocation = "https://www.github.com/KDAB/KDSoap" #misc source code [[annotations]] -path = ["unittests/**/**.xsd", "unittests/**/**.wsdl", "unittests/**/**.XSD", "unittests/**/**.xml", "testtools/**.qrc", "examples/**/**.qrc", "examples/**/**.wsdl", "**.html", "**.css", "docs/manual/**.pdf", "**.bat", "**.pem"] +path = ["unittests/**/**.xsd", "unittests/**/**.wsdl", "unittests/**/**.XSD", "unittests/**/**.xml", "unittests/**/**.qrc", "testtools/**.qrc", "examples/**/**.qrc", "examples/**/**.wsdl", "**.html", "**.css", "docs/manual/**.pdf", "**.bat", "**.pem"] precedence = "aggregate" SPDX-FileCopyrightText = "Klarälvdalens Datakonsult AB, a KDAB Group company " SPDX-License-Identifier = "MIT" diff --git a/src/KDSoapServer/ByteArrayViewSplitter.h b/src/KDSoapServer/ByteArrayViewSplitter.h new file mode 100644 index 00000000..de6385e5 --- /dev/null +++ b/src/KDSoapServer/ByteArrayViewSplitter.h @@ -0,0 +1,177 @@ +/**************************************************************************** +** +** This file is part of the KD Soap project. +** +** SPDX-FileCopyrightText: 2025 Jonathan Brady +** +** SPDX-License-Identifier: MIT +** +****************************************************************************/ + +#ifndef BYTEARRAYVIEWSPLITTER_H +#define BYTEARRAYVIEWSPLITTER_H + +#include +#include +#include +#include +#if defined(__cpp_lib_ranges) && __cpp_lib_ranges >= 201911L +#include +#endif + +// A zero-copy byte-splitting view over a QByteArrayView. +// Splits by a character or QByteArrayView delimiter, with optional skipping of empty parts. +template +class ByteArrayViewSplitter +#if defined(__cpp_lib_ranges) && __cpp_lib_ranges >= 201911L + : public std::ranges::view_interface> +#endif +{ + using DelimType = std::decay_t; + + // Enforce supported delimiter types: byte values or QByteArrayView + static_assert( + std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v || std::is_same_v, + "Delimiter must be a valid QByteArrayView byte type or QByteArrayView itself"); + +public: + ByteArrayViewSplitter(QByteArrayView origin, Delim delim, Qt::SplitBehavior skipEmpty = Qt::SplitBehaviorFlags::KeepEmptyParts) + : m_origin(origin) + , m_delim(delim) + , m_skipEmpty(skipEmpty == Qt::SplitBehaviorFlags::SkipEmptyParts) + { + } + + class iterator + { + // Returns 1 for char-like delimiters, or size() for QByteArrayView delimiters + static constexpr qsizetype computeDelimSize(const DelimType &delim) + { + if constexpr (std::is_same_v) + return delim.size(); + else + return 1; + } + + public: + using value_type = QByteArrayView; + using difference_type = std::ptrdiff_t; + using iterator_category = std::forward_iterator_tag; + + iterator() = default; + + iterator(QByteArrayView origin, Delim delim, qsizetype offset, bool skipEmpty) + : m_origin(origin) + , m_delim(delim) + , m_delimSize(computeDelimSize(delim)) + , m_offset(offset) + , m_skipEmpty(skipEmpty) + { + if (m_offset < 0) { + // Marks end iterator + m_offset = m_nextOffset = -1; + } else { + // Start at offset, advance once to initialize m_nextOffset + m_nextOffset = m_offset; + do { + advance(); + } while (m_skipEmpty && m_offset >= 0 && m_offset + m_delimSize == m_nextOffset); // Skip empty parts + } + } + + // Returns the current part + QByteArrayView operator*() const + { + if (m_offset < 0) + return {}; + + const char *start = m_origin.data() + m_offset; + const char *end = m_origin.data() + m_nextOffset - m_delimSize; + + return QByteArrayView(start, end - start); + } + + // Advance to the next part + iterator &operator++() + { + do { + advance(); + } while (m_skipEmpty && m_offset >= 0 && m_offset + m_delimSize == m_nextOffset); // Skip empty parts + + return *this; + } + +#if defined(__cpp_lib_ranges) && __cpp_lib_ranges >= 201911L + // C++20 sentinel-based end comparison + bool operator==(std::default_sentinel_t) const + { + return m_offset < 0; + } +#endif + + // Equality comparison for pre-C++20 use + bool operator==(const iterator &other) const + { + return m_offset == other.m_offset && m_origin.data() == other.m_origin.data() && m_origin.size() == other.m_origin.size(); + } + + bool operator!=(const iterator &other) const + { + return !(*this == other); + } + + private: + // Advances m_offset and m_nextOffset to the next segment + void advance() + { + m_offset = m_nextOffset; + + if (m_offset > m_origin.size()) { + m_offset = -1; // Marks end + return; + } + + // Find next delimiter + const qsizetype delimPos = m_origin.indexOf(m_delim, m_offset); + if (delimPos != -1) { + m_nextOffset = delimPos + m_delimSize; + } else { + // No more delimiters: consume rest of input + m_nextOffset = m_origin.size() + m_delimSize; + } + } + + const QByteArrayView m_origin; + const DelimType m_delim; + const qsizetype m_delimSize; + qsizetype m_offset = 0; // Start of current part + qsizetype m_nextOffset = 0; // End of current part (includes delimiter) + const bool m_skipEmpty = false; + }; + + iterator begin() const + { + return iterator {m_origin, m_delim, qsizetype(0), m_skipEmpty}; + } + +#if defined(__cpp_lib_ranges) && __cpp_lib_ranges >= 201911L + // C++20 sentinel-based end + std::default_sentinel_t end() const noexcept + { + return {}; + } +#else + // Classic iterator-based end + iterator end() const noexcept + { + return iterator {m_origin, m_delim, qsizetype(-1), m_skipEmpty}; + } +#endif + +private: + const QByteArrayView m_origin; + const DelimType m_delim; + const bool m_skipEmpty; +}; + +#endif diff --git a/src/KDSoapServer/KDSoapServerObjectInterface.cpp b/src/KDSoapServer/KDSoapServerObjectInterface.cpp index a6dd4c4a..af3de85f 100644 --- a/src/KDSoapServer/KDSoapServerObjectInterface.cpp +++ b/src/KDSoapServer/KDSoapServerObjectInterface.cpp @@ -10,8 +10,15 @@ #include "KDSoapServerObjectInterface.h" #include "KDSoapClient/KDSoapValue.h" #include "KDSoapServerSocket_p.h" +#include #include +#include +#include #include +#include +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#include "ByteArrayViewSplitter.h" +#endif class KDSoapServerObjectInterface::Private { @@ -33,6 +40,7 @@ class KDSoapServerObjectInterface::Private KDSoap::SoapVersion m_requestVersion = KDSoap::SoapVersion::SOAP1_1; // QPointer in case the client disconnects during a delayed response QPointer m_serverSocket; + QMap m_httpHeaders; }; KDSoapServerObjectInterface::HttpResponseHeaderItem::HttpResponseHeaderItem(const QByteArray &name, const QByteArray &value) @@ -183,6 +191,7 @@ KDSoapDelayedResponseHandle KDSoapServerObjectInterface::prepareDelayedResponse( void KDSoapServerObjectInterface::setServerSocket(KDSoapServerSocket *serverSocket) { d->m_serverSocket = serverSocket; + d->m_httpHeaders = d->m_serverSocket->m_httpHeaders; } void KDSoapServerObjectInterface::sendDelayedResponse(const KDSoapDelayedResponseHandle &responseHandle, const KDSoapMessage &response) @@ -193,6 +202,161 @@ void KDSoapServerObjectInterface::sendDelayedResponse(const KDSoapDelayedRespons } } +namespace { +// Parses an Accept-Encoding header into a bitmask of EncodingFormat values. +// +// See RFC 9110 §12.5.3: Accept-Encoding +// Recognizes q-values (q=0 means "not acceptable") and wildcard "*" entries. +// +// Accept-Encoding: gzip, deflate, br, identity;q=0 +// Accept-Encoding: *, identity;q=0 → means "anything except identity" +// Accept-Encoding: *;q=0, gzip → means "anything except gzip" +KDSoapServerSocket::EncodingFormats parseAcceptEncoding(const QByteArray &headerValue) +{ + KDSoapServerSocket::EncodingFormats formats; // Accepted formats + KDSoapServerSocket::EncodingFormats rejectedFormats; // Explicitly rejected formats (q=0) + + const QMetaEnum metaEnum = QMetaEnum::fromType(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + using PartType = QByteArrayView; + const auto parts = ByteArrayViewSplitter(headerValue, ',', Qt::SkipEmptyParts); +#else + using PartType = QByteArray; + const auto parts = headerValue.split(','); +#endif + + for (const auto &rawPart : parts) { + const auto part = rawPart.trimmed(); + if (part.isEmpty()) + continue; + + // Per RFC 9110 §12.5.3, we parse each `token[;q=value]` + const int semiPos = part.indexOf(';'); + const auto encoding = semiPos >= 0 ? part.left(semiPos).trimmed() : part; + const auto params = semiPos >= 0 ? part.mid(semiPos + 1).trimmed() : PartType(); + + // Parse q= parameter (if present) looking for q=0 (rejection) + bool rejected = false; + if (params.startsWith("q=")) { + bool ok = false; + const float qVal = params.mid(2).trimmed().toFloat(&ok); + if (ok && qVal == 0.0f) { + rejected = true; + } + } + + // Handle wildcard "*" — add all known formats not explicitly rejected + if (encoding == "*") { + if (!rejected) { + // Accept all known formats (per our enum) except already rejected ones + for (int i = 0; i < metaEnum.keyCount(); ++i) { + const auto val = static_cast(metaEnum.value(i)); + if (!rejectedFormats.testFlag(val)) + formats |= val; + } + } else if (!formats.testFlag(KDSoapServerSocket::EncodingFormat::identity)) { + // Reject all formats not specifically mentioned, including identity + rejectedFormats |= KDSoapServerSocket::EncodingFormat::identity; + qInfo() << __LINE__ << formats << rejectedFormats; + } + continue; + } + + // Normalize encoding to lowercase and look up enum value +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const int value = metaEnum.keyToValue(QByteArray(encoding.constData(), encoding.size()).toLower().constData()); +#else + const int value = metaEnum.keyToValue(encoding.toLower().constData()); +#endif + if (value >= 0) { + const auto format = static_cast(value); + + if (rejected) { + // Remove from accepted in case it was added earlier (e.g. by *) and add to rejected + formats &= ~format; + rejectedFormats |= format; + } else { + // Accept format and remove from rejected + formats |= format; + rejectedFormats &= ~format; + } + } + // Unknown encodings are ignored per RFC 9110 §12.5.3 + } + + // Ensure identity is accepted unless explicitly rejected + if (!rejectedFormats.testFlag(KDSoapServerSocket::EncodingFormat::identity)) { + formats |= KDSoapServerSocket::EncodingFormat::identity; + } + + return formats; +} + +// Convert QResource::Compression enum to our HTTP encoding enum +KDSoapServerSocket::EncodingFormat compressionToEncodingFormat(QResource::Compression c) +{ + switch (c) { + case QResource::NoCompression: + return KDSoapServerSocket::EncodingFormat::identity; + case QResource::ZlibCompression: + // Qt uses zlib internally, HTTP calls this "deflate" (RFC 7231 §4.2.2) + return KDSoapServerSocket::EncodingFormat::deflate; + case QResource::ZstdCompression: + // zstd + return KDSoapServerSocket::EncodingFormat::zstd; + } + return KDSoapServerSocket::EncodingFormat::identity; +} +} + +std::pair KDSoapServerObjectInterface::fileForEncoding(const QString &logicalPath) +{ + QFileInfo fileInfo(logicalPath); + if (!fileInfo.exists()) { + qWarning() << "File does not exist:" << logicalPath; + return {nullptr, QByteArrayLiteral("HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n")}; + } + + const QString resolvedPath = fileInfo.absoluteFilePath(); + const auto acceptedEncodings = parseAcceptEncoding(d->m_httpHeaders.value("accept-encoding")); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 13, 0) + // Attempt to interpret as a Qt resource + if (QResource res(resolvedPath); res.isValid()) { + KDSoapServerSocket::EncodingFormat resourceCompression = compressionToEncodingFormat(res.compressionAlgorithm()); + + // If resource is compressed and the format is accepted + if (resourceCompression != KDSoapServerSocket::EncodingFormat::identity && acceptedEncodings.testFlag(resourceCompression)) { + QBuffer *buffer = new QBuffer; + // Per https://doc.qt.io/archives/qt-5.13/qresource.html and + // https://doc.qt.io/archives/qt-5.13/qbytearray.html#qUncompress + // the resource has a 4 byte header which contains the big endian uncompressed size. + buffer->setData(reinterpret_cast(res.data() + 4), res.size() - 4); + + // Return compressed content with Content-Encoding + const auto metaEnum = QMetaEnum::fromType(); + return {buffer, QByteArray::fromRawData(metaEnum.valueToKey(resourceCompression), ::qstrlen(metaEnum.valueToKey(resourceCompression)))}; + } + } +#endif + + // At this point: + // - Either it's not a Qt resource, or + // - It's a resource but compression was not accepted + + // RFC 9110 §8.4.4: "If the identity encoding is not acceptable, the origin server SHOULD send a 406 (Not Acceptable) response." + if (!acceptedEncodings.testFlag(KDSoapServerSocket::EncodingFormat::identity)) { + qWarning() << "Uncompressed file rejected by Accept-Encoding:" << resolvedPath; + return {nullptr, QByteArrayLiteral("HTTP/1.1 406 Not Acceptable\r\nContent-Length: 0\r\n\r\n")}; + } + + // Fall back to serving uncompressed version via QFile + QFile *file = new QFile(resolvedPath); + + return {file, QByteArray()}; +} + void KDSoapServerObjectInterface::writeHTTP(const QByteArray &httpReply) { const qint64 written = d->m_serverSocket->write(httpReply); @@ -210,6 +374,7 @@ void KDSoapServerObjectInterface::copyFrom(KDSoapServerObjectInterface *other) d->m_requestHeaders = other->d->m_requestHeaders; d->m_soapAction = other->d->m_soapAction; d->m_serverSocket = other->d->m_serverSocket; + d->m_httpHeaders = other->d->m_httpHeaders; d->m_requestVersion = other->d->m_requestVersion; } diff --git a/src/KDSoapServer/KDSoapServerObjectInterface.h b/src/KDSoapServer/KDSoapServerObjectInterface.h index 065eaeb1..5c8dccd3 100644 --- a/src/KDSoapServer/KDSoapServerObjectInterface.h +++ b/src/KDSoapServer/KDSoapServerObjectInterface.h @@ -235,6 +235,37 @@ class KDSOAPSERVER_EXPORT KDSoapServerObjectInterface */ void sendDelayedResponse(const KDSoapDelayedResponseHandle &responseHandle, const KDSoapMessage &response); + /** + * Resolves a logical file path to a QIODevice suitable for HTTP serving. + * This function uses QDir search paths to locate the file, and transparently handles + * both resource files (qrc:/) and on-disk files. If the resource is stored in a compressed + * format (e.g., zstd or deflate), and the client's Accept-Encoding header allows it, + * the compressed content will be served directly using a QBuffer. + * \param logicalPath the logical file path (can be relative; QDir search paths are used) + * \return A pair consisting of: + * - a QIODevice pointer containing the file contents (or nullptr on error), + * - the encoding used (for the Content-Encoding header), or an error message + * if the device is null. + * If the encoding is "identity" (i.e., uncompressed), an empty QByteArray is returned + * for the second element. + * If no acceptable encoding is available (e.g., the client sent "identity;q=0" and + * the resource is only available uncompressed), the function returns nullptr and + * a ready-to-send HTTP 406 response. + * This function follows the semantics of RFC 9110 §12.5.3 (formerly RFC 7231 §5.3.4), + * which defines how to interpret the Accept-Encoding header. In particular: + * - The absence of Accept-Encoding implies "identity" is acceptable. + * - "identity;q=0" explicitly excludes uncompressed content. + * - "*" can exclude all unspecified encodings unless overridden. + * + * \note This function expands the input to an absolute path using QDir search paths, + * but it does not enforce security checks. Callers are responsible for verifying + * that the resolved file is safe to serve (e.g., preventing directory traversal, + * validating MIME type or allowed locations). + * + * \since 2.3 + */ + std::pair fileForEncoding(const QString &logicalPath); + /** * Low-level method, not needed for normal operations. * Call this method to write an HTTP reply back, e.g. in case of an error. diff --git a/src/KDSoapServer/KDSoapServerSocket.cpp b/src/KDSoapServer/KDSoapServerSocket.cpp index 50df033b..38c3aa4e 100644 --- a/src/KDSoapServer/KDSoapServerSocket.cpp +++ b/src/KDSoapServer/KDSoapServerSocket.cpp @@ -129,7 +129,7 @@ static QByteArray stripQuotes(const QByteArray &bar) return bar; } -static QByteArray httpResponseHeaders(bool fault, const QByteArray &contentType, int responseDataSize, QObject *serverObject) +static QByteArray httpResponseHeaders(bool fault, const QByteArray &contentType, int responseDataSize, const QByteArray &contentEncoding, QObject *serverObject) { QByteArray httpResponse; httpResponse.reserve(50); @@ -146,6 +146,10 @@ static QByteArray httpResponseHeaders(bool fault, const QByteArray &contentType, httpResponse += contentType; httpResponse += "\r\nContent-Length: "; httpResponse += QByteArray::number(responseDataSize); + if (!contentEncoding.isEmpty()) { + httpResponse += "Content-Encoding: "; + httpResponse += contentEncoding; + } httpResponse += "\r\n"; KDSoapServerObjectInterface *serverObjectInterface = qobject_cast(serverObject); @@ -352,7 +356,7 @@ void KDSoapServerSocket::handleRequest(const QMap &httpH } if (requestType == "GET") { - if (pathAndQuery == server->wsdlPathInUrl() && handleWsdlDownload()) { + if (pathAndQuery == server->wsdlPathInUrl() && handleWsdlDownload(serverObjectInterface)) { return; } else if (handleFileDownload(serverObjectInterface, pathAndQuery)) { return; @@ -414,18 +418,21 @@ void KDSoapServerSocket::handleRequest(const QMap &httpH } } -bool KDSoapServerSocket::handleWsdlDownload() +bool KDSoapServerSocket::handleWsdlDownload(KDSoapServerObjectInterface *serverObjectInterface) { KDSoapServer *server = m_owner->server(); - const QString wsdlFile = server->wsdlFile(); - QFile wf(wsdlFile); - if (wf.open(QIODevice::ReadOnly)) { + const auto [wsdlFile, encoding] = serverObjectInterface->fileForEncoding(server->wsdlFile()); + if (wsdlFile && wsdlFile->open(QIODevice::ReadOnly)) { + // TODO: Add Content-Encoding header // qDebug() << "Returning wsdl file contents"; - const QByteArray responseText = wf.readAll(); - const QByteArray response = httpResponseHeaders(false, "application/xml", responseText.size(), m_serverObject); + const QByteArray responseText = wsdlFile->readAll(); + const QByteArray response = httpResponseHeaders(false, "application/xml", responseText.size(), encoding, m_serverObject); write(response); write(responseText); return true; + } else if (!wsdlFile && !encoding.isEmpty()) { + write(encoding); // Report the error from fileForEncoding + return true; } return false; } @@ -435,8 +442,10 @@ bool KDSoapServerSocket::handleFileDownload(KDSoapServerObjectInterface *serverO QByteArray contentType; QIODevice *device = serverObjectInterface->processFileRequest(path, contentType); if (!device) { - const QByteArray notFound = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; - write(notFound); + if (!serverObjectInterface->hasFault()) { + const QByteArray notFound = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + write(notFound); + } return true; } if (!device->open(QIODevice::ReadOnly)) { @@ -444,7 +453,7 @@ bool KDSoapServerSocket::handleFileDownload(KDSoapServerObjectInterface *serverO delete device; return true; // handled! } - const QByteArray response = httpResponseHeaders(false, contentType, device->size(), m_serverObject); + const QByteArray response = httpResponseHeaders(false, contentType, device->size(), QByteArray(), m_serverObject); if (m_doDebug) { qDebug() << "KDSoapServerSocket: file download response" << response; } @@ -478,7 +487,7 @@ bool KDSoapServerSocket::handleFileDownload(KDSoapServerObjectInterface *serverO void KDSoapServerSocket::writeXML(const QByteArray &xmlResponse, bool isFault, KDSoap::SoapVersion soapVersion) { const QByteArray httpHeaders = httpResponseHeaders(isFault, soapVersion == KDSoap::SoapVersion::SOAP1_1 ? "text/xml" : "application/soap+xml;charset=utf-8", - xmlResponse.size(), m_serverObject); + xmlResponse.size(), QByteArray(), m_serverObject); if (m_doDebug) { qDebug() << "KDSoapServerSocket: writing" << httpHeaders << xmlResponse; } diff --git a/src/KDSoapServer/KDSoapServerSocket_p.h b/src/KDSoapServer/KDSoapServerSocket_p.h index 292badac..da0e3e4d 100644 --- a/src/KDSoapServer/KDSoapServerSocket_p.h +++ b/src/KDSoapServer/KDSoapServerSocket_p.h @@ -39,6 +39,19 @@ class KDSoapServerSocket KDSoapServerSocket(KDSoapSocketList *owner, QObject *serverObject); ~KDSoapServerSocket(); + // EncodingFormat represents recognized HTTP Content-Encoding values. + // See RFC 9110 §8.4.1: https://datatracker.ietf.org/doc/html/rfc9110#section-8.4.1 + enum EncodingFormat + { // Note: deliberately lowercase for matching purposes + identity = 0x1, // No compression + gzip = 0x2, // Gzip - RFC 9110 §8.4.2.1 (uses RFC 1952 format), NOT supported by rcc, supported by QNetworkAccessManager + deflate = 0x4, // Zlib - RFC 9110 §8.4.2.2 (uses RFC 1950 format), supported by rcc, supported by QNetworkAccessManager + br = 0x8, // Brotli - RFC 7932, NOT supported by rcc, supported by QNetworkAccessManager if built with Brotli (Qt >= 5.10) + zstd = 0x10 // Zstd - RFC 8878, supported by rcc, NOT supported by QNetworkAccessManager + }; + Q_DECLARE_FLAGS(EncodingFormats, EncodingFormat) + Q_FLAG(EncodingFormats) + void setResponseDelayed(); void sendDelayedReply(KDSoapServerObjectInterface *serverObjectInterface, const KDSoapMessage &replyMsg); void sendReply(KDSoapServerObjectInterface *serverObjectInterface, const KDSoapMessage &replyMsg); @@ -50,7 +63,7 @@ private Q_SLOTS: private: void handleRequest(const QMap &headers, const QByteArray &receivedData); - bool handleWsdlDownload(); + bool handleWsdlDownload(KDSoapServerObjectInterface *serverObjectInterface); bool handleFileDownload(KDSoapServerObjectInterface *serverObjectInterface, const QString &path); void makeCall(KDSoapServerObjectInterface *serverObjectInterface, const KDSoapMessage &requestMsg, KDSoapMessage &replyMsg, const KDSoapHeaders &requestHeaders, const QByteArray &soapAction, const QString &path, KDSoap::SoapVersion soapVersion); @@ -79,4 +92,6 @@ private Q_SLOTS: QString m_method; }; +Q_DECLARE_OPERATORS_FOR_FLAGS(KDSoapServerSocket::EncodingFormats) + #endif // KDSOAPSERVERSOCKET_P_H diff --git a/unittests/CMakeLists.txt b/unittests/CMakeLists.txt index 7c8eacb5..bf2cc196 100644 --- a/unittests/CMakeLists.txt +++ b/unittests/CMakeLists.txt @@ -98,6 +98,7 @@ add_subdirectory(empty_element_wsdl) add_subdirectory(ws_discovery_wsdl) add_subdirectory(soap_over_udp) add_subdirectory(kdwsdl2cpp_jobs) +add_subdirectory(accept_encoding) # These need internet access add_subdirectory(webcalls) diff --git a/unittests/accept_encoding/CMakeLists.txt b/unittests/accept_encoding/CMakeLists.txt new file mode 100644 index 00000000..4a302bf4 --- /dev/null +++ b/unittests/accept_encoding/CMakeLists.txt @@ -0,0 +1,33 @@ +# This file is part of the KD Soap project. +# +# SPDX-FileCopyrightText: 2025 Jonathan Brady +# +# SPDX-License-Identifier: MIT +# + +project(accept_encoding) + +set(accept_encoding_SRCS test_accept_encoding.cpp resources.qrc) +set(EXTRA_LIBS kdsoap-server) +#add_unittest(${accept_encoding_SRCS}) + +# Resources don't seem to work with add_unittest so set up target manually. +set(_name test_accept_encoding) +# What I'd like to do is: +#qt_add_resources(test_accept_encoding resources.qrc) +#if(QT_FEATURE_zstd) +# qt_add_resources(test_accept_encoding zstd_resources.qrc) +#endif() +# But I can't get that to work. +add_executable( + ${_name} + ${accept_encoding_SRCS} +) +add_test(NAME kdsoap-${_name} COMMAND ${_name}) +target_link_libraries( + ${_name} + ${QT_LIBRARIES} + kdsoap + testtools + ${EXTRA_LIBS} +) diff --git a/unittests/accept_encoding/file_deflate.txt b/unittests/accept_encoding/file_deflate.txt new file mode 100644 index 00000000..2ef47133 --- /dev/null +++ b/unittests/accept_encoding/file_deflate.txt @@ -0,0 +1,50 @@ +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. +File with deflate compression. +This file must be sufficiently long enough to get past the compression threshold. diff --git a/unittests/accept_encoding/file_no_compression.txt b/unittests/accept_encoding/file_no_compression.txt new file mode 100644 index 00000000..293aeb88 --- /dev/null +++ b/unittests/accept_encoding/file_no_compression.txt @@ -0,0 +1 @@ +File with no compression. diff --git a/unittests/accept_encoding/file_zstd.txt b/unittests/accept_encoding/file_zstd.txt new file mode 100644 index 00000000..69d75e02 --- /dev/null +++ b/unittests/accept_encoding/file_zstd.txt @@ -0,0 +1,50 @@ +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. +File with zstd compression. +This file must be sufficiently long enough to get past the compression threshold. diff --git a/unittests/accept_encoding/resources.qrc b/unittests/accept_encoding/resources.qrc new file mode 100644 index 00000000..ba5cc7b3 --- /dev/null +++ b/unittests/accept_encoding/resources.qrc @@ -0,0 +1,7 @@ + + + file_no_compression.txt + file_deflate.txt + + + diff --git a/unittests/accept_encoding/test_accept_encoding.cpp b/unittests/accept_encoding/test_accept_encoding.cpp new file mode 100644 index 00000000..ff21d44f --- /dev/null +++ b/unittests/accept_encoding/test_accept_encoding.cpp @@ -0,0 +1,179 @@ +/**************************************************************************** +** +** This file is part of the KD Soap project. +** +** SPDX-FileCopyrightText: 2025 Jonathan Brady +** +** SPDX-License-Identifier: MIT +** +****************************************************************************/ + +#include "KDSoapServer.h" +#include "KDSoapServerObjectInterface.h" +#include +#include +#include +#include +#include + +class FileServerObject final : public QObject, public KDSoapServerObjectInterface +{ + Q_OBJECT + Q_INTERFACES(KDSoapServerObjectInterface) +public: + FileServerObject() = default; + ~FileServerObject() = default; + + HttpResponseHeaderItems additionalHttpResponseHeaderItems() const override + { + if (!m_contentEncoding.isEmpty()) + return {{"Content-Encoding", m_contentEncoding}}; + return {}; + } + + QIODevice *processFileRequest(const QString &path, QByteArray &contentType) override + { + const auto [device, encodingOrError] = fileForEncoding("searchpath:" + path); + + if (!device) { + if (!encodingOrError.isEmpty()) { + writeHTTP(encodingOrError); + // We set a fault so KDSoapServerSocket doesn't add a not found error + // The first parameters are ignored but if the first one is null setFault + // will assert. + setFault(QStringLiteral("HTTP"), QString()); + } + return nullptr; + } + + m_contentEncoding = encodingOrError; + contentType = "text/plain"; + return device; + } + +private: + QByteArray m_contentEncoding; +}; + +class FileServer final : public KDSoapServer +{ + Q_OBJECT +public: + FileServer() = default; + ~FileServer() = default; + + QObject *createServerObject() override + { + return new FileServerObject(); + } +}; + +class TestAcceptEncoding : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + void cleanupTestCase(); + void encodingScenarios_data(); + void encodingScenarios(); + +private: + FileServer *server = nullptr; + QNetworkAccessManager *manager = nullptr; +}; + +void TestAcceptEncoding::initTestCase() +{ + QDir::setSearchPaths("searchpath", {":/"}); + server = new FileServer(); + QVERIFY(server->listen(QHostAddress::LocalHost)); + + manager = new QNetworkAccessManager(this); +} + +void TestAcceptEncoding::cleanupTestCase() +{ + delete server; + delete manager; +} + +void TestAcceptEncoding::encodingScenarios_data() +{ + QTest::addColumn("file"); + QTest::addColumn("acceptEncoding"); + QTest::addColumn("expectedStatus"); + QTest::addColumn("expectedEncoding"); + /* + // File from disk (uncompressed) + QTest::newRow("disk_file_no_header") << "file_no_compression.txt" << QByteArray() << 200 << QByteArray(); + QTest::newRow("disk_file_identity") << "file_no_compression.txt" << QByteArrayLiteral("identity") << 200 << QByteArray(); + QTest::newRow("disk_file_identity_disabled") << "file_no_compression.txt" << QByteArrayLiteral("identity;q=0") << 406 << QByteArray(); + QTest::newRow("disk_file_deflate_only") << "file_no_compression.txt" << QByteArrayLiteral("deflate") << 200 << QByteArray(); + QTest::newRow("disk_file_wildcard") << "file_no_compression.txt" << QByteArrayLiteral("*") << 200 << QByteArray(); + QTest::newRow("disk_file_deflate_identity_disabled") << "file_no_compression.txt" << QByteArrayLiteral("deflate, identity;q=0") << 406 << QByteArray(); + */ + // Deflate resource + QTest::newRow("deflate_resource_no_header") << "file_deflate.txt" << QByteArray() << 200 << QByteArrayLiteral("deflate"); + QTest::newRow("deflate_resource_deflate") << "file_deflate.txt" << QByteArrayLiteral("deflate") << 200 << QByteArrayLiteral("deflate"); + QTest::newRow("deflate_resource_identity") << "file_deflate.txt" << QByteArrayLiteral("identity") << 200 << QByteArray(); + QTest::newRow("deflate_resource_zstd") << "file_deflate.txt" << QByteArrayLiteral("zstd") << 200 << QByteArray(); + QTest::newRow("deflate_resource_identity_disabled") << "file_deflate.txt" << QByteArrayLiteral("identity;q=0") << 406 << QByteArray(); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + // Zstd resource + QTest::newRow("zstd_resource_no_header") << "file_zstd.txt" << QByteArray() << 200 << QByteArray(); + QTest::newRow("zstd_resource_zstd") << "file_zstd.txt" << QByteArrayLiteral("zstd") << 200 << QByteArrayLiteral("zstd"); + QTest::newRow("zstd_resource_identity") << "file_zstd.txt" << QByteArrayLiteral("identity") << 200 << QByteArray(); + QTest::newRow("zstd_resource_deflate_only") << "file_zstd.txt" << QByteArrayLiteral("deflate") << 200 << QByteArray(); + QTest::newRow("zstd_resource_identity_disabled") << "file_zstd.txt" << QByteArrayLiteral("identity;q=0") << 200 << QByteArray(); +#endif + + // 404 test + QTest::newRow("not_found") << "nonexistent.txt" << QByteArray() << 404 << QByteArray(); +} + +void TestAcceptEncoding::encodingScenarios() +{ + QFETCH(QString, file); + QFETCH(QByteArray, acceptEncoding); + QFETCH(int, expectedStatus); + QFETCH(QByteArray, expectedEncoding); + + QUrl url(QString("http://localhost:%1/%2").arg(server->serverPort()).arg(file)); + QNetworkRequest request(url); + if (!acceptEncoding.isEmpty()) + request.setRawHeader("Accept-Encoding", acceptEncoding); + + QNetworkReply *reply = manager->get(request); + QSignalSpy spy(reply, &QNetworkReply::finished); + QVERIFY(spy.wait(3000)); + + const int actualStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) && !QT_CONFIG(zstd) + QEXPECT_FAIL("zstd_resource_no_header", "Qt compiled without zstd support", Abort); + QEXPECT_FAIL("zstd_resource_zstd", "Qt compiled without zstd support", Abort); + QEXPECT_FAIL("zstd_resource_identity", "Qt compiled without zstd support", Abort); + QEXPECT_FAIL("zstd_resource_deflate_only", "Qt compiled without zstd support", Abort); + QEXPECT_FAIL("zstd_resource_identity_disabled", "Qt compiled without zstd support", Abort); +#endif + QCOMPARE(actualStatus, expectedStatus); + + const QByteArray actualEncoding = reply->rawHeader("Content-Encoding"); + QCOMPARE(actualEncoding, expectedEncoding); + + // Unfortunately QtNetworkAccessManager turns off decompression for us if we ask for a specific encoding. + // We can therefore only check if the data was received correctly if we use the default headers + // or we implement our own decompression. + if (expectedStatus == 200 && acceptEncoding.isEmpty()) { + QFile f("searchpath:" + file); + QVERIFY2(f.open(QIODevice::ReadOnly), qPrintable(QString("Could not open %1").arg(file))); + const QByteArray expectedContent = f.readAll(); + QCOMPARE(reply->readAll(), expectedContent); + } + + reply->deleteLater(); +} + +QTEST_MAIN(TestAcceptEncoding) +#include "test_accept_encoding.moc" diff --git a/unittests/accept_encoding/zstd_resources.qrc b/unittests/accept_encoding/zstd_resources.qrc new file mode 100644 index 00000000..feb76404 --- /dev/null +++ b/unittests/accept_encoding/zstd_resources.qrc @@ -0,0 +1,5 @@ + + + file_zstd.txt + +