Implement zstd decompression

Given the size of Redfish schemas these days, it would be nice to be
able to store them on disk in a zstd format.  Unfortunately, not all
clients support zstd at this time.

This commit implements reading of zstd files from disk, as well as
decompressing zstd in the case where the client does not support zstd as
a return type.

Tested:
Implanted an artificial zstd file into the system, and observed correct
decompression both with an allow-encoding header of empty string and
zstd.

Change-Id: I8b631bb943de99002fdd6745340aec010ee591ff
Signed-off-by: Ed Tanous <etanous@nvidia.com>
diff --git a/README.md b/README.md
index d895538..ed4d106 100644
--- a/README.md
+++ b/README.md
@@ -76,6 +76,12 @@
 generate a self-signed a certificate before launching the server. Please see the
 bmcweb source code for details on the parameters this certificate is built with.
 
+## Compression
+
+bmcweb supports various forms of http compression, including zstd and gzip.
+Client headers are observed to determine whether compressed payloads are
+supported.
+
 ## Redfish Aggregation
 
 bmcweb is capable of aggregating resources from satellite BMCs. Refer to
diff --git a/config/meson.build b/config/meson.build
index c69b789..c363ad7 100644
--- a/config/meson.build
+++ b/config/meson.build
@@ -9,6 +9,7 @@
     'experimental-redfish-multi-computer-system',
     'google-api',
     'host-serial-socket',
+    'http-zstd',
     'http2',
     'hypervisor-computer-system',
     'ibm-management-console',
diff --git a/http/complete_response_fields.hpp b/http/complete_response_fields.hpp
index 486b699..41314b8 100644
--- a/http/complete_response_fields.hpp
+++ b/http/complete_response_fields.hpp
@@ -3,6 +3,7 @@
 #pragma once
 
 #include "boost_formatters.hpp"
+#include "http_body.hpp"
 #include "http_response.hpp"
 #include "http_utility.hpp"
 #include "json_html_serializer.hpp"
@@ -20,7 +21,48 @@
 namespace crow
 {
 
-inline void completeResponseFields(std::string_view accepts, Response& res)
+inline void handleEncoding(std::string_view acceptEncoding, Response& res)
+{
+    using bmcweb::CompressionType;
+    using enum bmcweb::CompressionType;
+    using http_helpers::Encoding;
+    using enum http_helpers::Encoding;
+    // If the payload is currently compressed, see if we can avoid
+    // decompressing it by sending it to the client directly
+    switch (res.response.body().compressionType)
+    {
+        case Zstd:
+        {
+            std::array<Encoding, 1> allowedEnc{ZSTD};
+            Encoding encoding =
+                http_helpers::getPreferredEncoding(acceptEncoding, allowedEnc);
+
+            if (encoding == ZSTD)
+            {
+                // If the client supports returning zstd directly, allow that.
+                res.response.body().clientCompressionType = Zstd;
+            }
+        }
+        break;
+        case Gzip:
+        {
+            std::array<Encoding, 1> allowedEnc{GZIP};
+            Encoding encoding =
+                http_helpers::getPreferredEncoding(acceptEncoding, allowedEnc);
+            if (encoding != GZIP)
+            {
+                BMCWEB_LOG_WARNING(
+                    "Unimplemented: Returning gzip payload to client that did not explicitly allow it.");
+            }
+        }
+        break;
+        default:
+            break;
+    }
+}
+
+inline void completeResponseFields(
+    std::string_view accepts, std::string_view acceptEncoding, Response& res)
 {
     BMCWEB_LOG_INFO("Response: {}", res.resultInt());
     addSecurityHeaders(res);
@@ -56,5 +98,7 @@
                 2, ' ', true, nlohmann::json::error_handler_t::replace));
         }
     }
+
+    handleEncoding(acceptEncoding, res);
 }
 } // namespace crow
diff --git a/http/http2_connection.hpp b/http/http2_connection.hpp
index 99604f5..e9e9cd8 100644
--- a/http/http2_connection.hpp
+++ b/http/http2_connection.hpp
@@ -52,6 +52,7 @@
     std::shared_ptr<Request> req = std::make_shared<Request>();
     std::optional<bmcweb::HttpBody::reader> reqReader;
     std::string accept;
+    std::string acceptEnc;
     Response res;
     std::optional<bmcweb::HttpBody::writer> writer;
 };
@@ -205,7 +206,7 @@
         Response& res = stream.res;
         res = std::move(completedRes);
 
-        completeResponseFields(stream.accept, res);
+        completeResponseFields(stream.accept, stream.acceptEnc, res);
         res.addHeader(boost::beast::http::field::date, getCachedDateStr());
         res.preparePayload();
 
@@ -277,7 +278,9 @@
             }
         }
         crow::Request& thisReq = *it->second.req;
-        it->second.accept = thisReq.getHeaderValue("Accept");
+        using boost::beast::http::field;
+        it->second.accept = thisReq.getHeaderValue(field::accept);
+        it->second.acceptEnc = thisReq.getHeaderValue(field::accept_encoding);
 
         BMCWEB_LOG_DEBUG("Handling {} \"{}\"", logPtr(&thisReq),
                          thisReq.url().encoded_path());
@@ -520,7 +523,7 @@
         {
             BMCWEB_LOG_DEBUG("create stream for id {}", frame.hd.stream_id);
 
-            streams.emplace(frame.hd.stream_id, Http2StreamData());
+            streams[frame.hd.stream_id];
         }
         return 0;
     }
diff --git a/http/http_body.hpp b/http/http_body.hpp
index d76ed49..288c1fe 100644
--- a/http/http_body.hpp
+++ b/http/http_body.hpp
@@ -5,6 +5,7 @@
 #include "duplicatable_file_handle.hpp"
 #include "logging.hpp"
 #include "utility.hpp"
+#include "zstd_decompressor.hpp"
 
 #include <fcntl.h>
 
@@ -49,6 +50,13 @@
     Base64,
 };
 
+enum class CompressionType
+{
+    Raw,
+    Gzip,
+    Zstd,
+};
+
 class HttpBody::value_type
 {
     DuplicatableFileHandle fileHandle;
@@ -60,6 +68,19 @@
     explicit value_type(std::string_view s) : strBody(s) {}
     explicit value_type(EncodingType e) : encodingType(e) {}
     EncodingType encodingType = EncodingType::Raw;
+    CompressionType compressionType = CompressionType::Raw;
+    CompressionType clientCompressionType = CompressionType::Raw;
+
+    ~value_type() = default;
+
+    explicit value_type(EncodingType enc, CompressionType comp) :
+        encodingType(enc), compressionType(comp)
+    {}
+
+    value_type(const value_type& other) noexcept = default;
+    value_type& operator=(const value_type& other) noexcept = default;
+    value_type(value_type&& other) noexcept = default;
+    value_type& operator=(value_type&& other) noexcept = default;
 
     const boost::beast::file_posix& file() const
     {
@@ -156,6 +177,8 @@
     std::string buf;
     crow::utility::Base64Encoder encoder;
 
+    std::optional<ZstdDecompressor> zstd;
+
     value_type& body;
     size_t sent = 0;
     // 64KB This number is arbitrary, and selected to try to optimize for larger
@@ -169,7 +192,13 @@
     template <bool IsRequest, class Fields>
     writer(boost::beast::http::header<IsRequest, Fields>& /*header*/,
            value_type& bodyIn) : body(bodyIn)
-    {}
+    {
+        if (body.compressionType == CompressionType::Zstd &&
+            body.clientCompressionType != CompressionType::Zstd)
+        {
+            zstd.emplace();
+        }
+    }
 
     static void init(boost::beast::error_code& ec)
     {
@@ -235,6 +264,18 @@
         {
             ret.first = const_buffers_type(chunkView.data(), chunkView.size());
         }
+
+        if (zstd)
+        {
+            std::optional<const_buffers_type> decompressed =
+                zstd->decompress(ret.first);
+            if (!decompressed)
+            {
+                return boost::none;
+            }
+            ret.first = *decompressed;
+        }
+
         return ret;
     }
 };
diff --git a/http/http_connection.hpp b/http/http_connection.hpp
index 807ca35..0c8ad0e 100644
--- a/http/http_connection.hpp
+++ b/http/http_connection.hpp
@@ -383,14 +383,16 @@
             return;
         }
         req->session = userSession;
-        accept = req->getHeaderValue("Accept");
+        using boost::beast::http::field;
+        accept = req->getHeaderValue(field::accept);
+        acceptEncoding = req->getHeaderValue(field::accept_encoding);
         // Fetch the client IP address
         req->ipAddress = ip;
 
         // Check for HTTP version 1.1.
         if (req->version() == 11)
         {
-            if (req->getHeaderValue(boost::beast::http::field::host).empty())
+            if (req->getHeaderValue(field::host).empty())
             {
                 res.result(boost::beast::http::status::bad_request);
                 completeRequest(res);
@@ -490,7 +492,7 @@
         res = std::move(thisRes);
         res.keepAlive(keepAlive);
 
-        completeResponseFields(accept, res);
+        completeResponseFields(accept, acceptEncoding, res);
         res.addHeader(boost::beast::http::field::date, getCachedDateStr());
 
         doWrite();
@@ -931,6 +933,8 @@
     std::shared_ptr<crow::Request> req;
     std::string accept;
     std::string http2settings;
+    std::string acceptEncoding;
+
     crow::Response res;
 
     std::shared_ptr<persistent_data::UserSession> userSession;
diff --git a/http/http_response.hpp b/http/http_response.hpp
index 4f2b0f0..46155a3 100644
--- a/http/http_response.hpp
+++ b/http/http_response.hpp
@@ -306,12 +306,15 @@
         expectedHash = hash;
     }
 
-    OpenCode openFile(const std::filesystem::path& path,
-                      bmcweb::EncodingType enc = bmcweb::EncodingType::Raw)
+    OpenCode openFile(
+        const std::filesystem::path& path,
+        bmcweb::EncodingType enc = bmcweb::EncodingType::Raw,
+        bmcweb::CompressionType comp = bmcweb::CompressionType::Raw)
     {
         boost::beast::error_code ec;
         response.body().open(path.c_str(), boost::beast::file_mode::read, ec);
         response.body().encodingType = enc;
+        response.body().compressionType = comp;
         if (ec)
         {
             BMCWEB_LOG_ERROR("Failed to open file {}, ec={}", path.c_str(),
diff --git a/http/zstd_decompressor.cpp b/http/zstd_decompressor.cpp
new file mode 100644
index 0000000..aa42392
--- /dev/null
+++ b/http/zstd_decompressor.cpp
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright OpenBMC Authors
+
+#include "zstd_decompressor.hpp"
+
+#include "logging.hpp"
+
+#ifdef HAVE_ZSTD
+#include <zstd.h>
+#endif
+#include <boost/asio/buffer.hpp>
+
+#include <cstddef>
+#include <optional>
+
+#ifdef HAVE_ZSTD
+ZstdDecompressor::ZstdDecompressor() : dctx(ZSTD_createDStream())
+{
+    ZSTD_initDStream(dctx);
+}
+#else
+ZstdDecompressor::ZstdDecompressor() {};
+#endif
+
+std::optional<boost::asio::const_buffer> ZstdDecompressor::decompress(
+    [[maybe_unused]] boost::asio::const_buffer buffIn)
+{
+#ifdef HAVE_ZSTD
+    compressionBuf.clear();
+    ZSTD_inBuffer input = {buffIn.data(), buffIn.size(), 0};
+
+    // Note, this loop is prone to compression bombs, decompressing chunks that
+    // appear very small, but decompress to be very large, given that they're
+    // highly decompressible. This algorithm assumes that at this time, the
+    // whole file will fit in ram.
+    while (input.pos != input.size)
+    {
+        constexpr size_t frameSize = 4096;
+        auto buffer = compressionBuf.prepare(frameSize);
+        ZSTD_outBuffer output = {buffer.data(), buffer.size(), 0};
+        const size_t ret = ZSTD_decompressStream(dctx, &output, &input);
+        if (ZSTD_isError(ret) != 0)
+        {
+            BMCWEB_LOG_ERROR("Decompression Failed with code {}:{}", ret,
+                             ZSTD_getErrorName(ret));
+            return std::nullopt;
+        }
+        compressionBuf.commit(output.pos);
+    }
+    return compressionBuf.cdata();
+#else
+    BMCWEB_LOG_CRITICAL("Attempt to decompress, but libzstd not enabled");
+
+    return std::nullopt;
+#endif
+}
+
+ZstdDecompressor::~ZstdDecompressor()
+{
+#ifdef HAVE_ZSTD
+    ZSTD_freeDStream(dctx);
+#endif
+}
diff --git a/http/zstd_decompressor.hpp b/http/zstd_decompressor.hpp
new file mode 100644
index 0000000..777a4b8
--- /dev/null
+++ b/http/zstd_decompressor.hpp
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright OpenBMC Authors
+
+#pragma once
+
+#ifdef HAVE_ZSTD
+#include <zstd.h>
+#endif
+
+#include <boost/asio/buffer.hpp>
+#include <boost/beast/core/flat_buffer.hpp>
+
+#include <optional>
+
+class ZstdDecompressor
+{
+    boost::beast::flat_buffer compressionBuf;
+
+#ifdef HAVE_ZSTD
+    ZSTD_DCtx* dctx;
+#endif
+
+  public:
+    ZstdDecompressor(const ZstdDecompressor&) = delete;
+    ZstdDecompressor(ZstdDecompressor&&) = delete;
+    ZstdDecompressor& operator=(const ZstdDecompressor&) = delete;
+    ZstdDecompressor& operator=(ZstdDecompressor&&) = delete;
+
+    ZstdDecompressor();
+    std::optional<boost::asio::const_buffer> decompress(
+        boost::asio::const_buffer buffIn);
+    ~ZstdDecompressor();
+};
diff --git a/include/webassets.hpp b/include/webassets.hpp
index 91239dc..aa57230 100644
--- a/include/webassets.hpp
+++ b/include/webassets.hpp
@@ -5,6 +5,7 @@
 #include "app.hpp"
 #include "async_resp.hpp"
 #include "forward_unauthorized.hpp"
+#include "http_body.hpp"
 #include "http_request.hpp"
 #include "http_response.hpp"
 #include "logging.hpp"
@@ -68,6 +69,7 @@
     std::string_view contentType;
     std::string_view contentEncoding;
     std::string etag;
+    bmcweb::CompressionType onDiskComp = bmcweb::CompressionType::Raw;
     bool renamed = false;
 };
 
@@ -109,7 +111,8 @@
         }
     }
 
-    if (asyncResp->res.openFile(file.absolutePath) != crow::OpenCode::Success)
+    if (asyncResp->res.openFile(file.absolutePath, bmcweb::EncodingType::Raw,
+                                file.onDiskComp) != crow::OpenCode::Success)
     {
         BMCWEB_LOG_DEBUG("failed to read file");
         asyncResp->res.result(
@@ -174,6 +177,7 @@
         // Use the non-gzip version for determining content type
         extension = webpath.extension().string();
         file.contentEncoding = "gzip";
+        file.onDiskComp = bmcweb::CompressionType::Gzip;
     }
     else if (extension == ".zstd")
     {
@@ -181,6 +185,7 @@
         // Use the non-zstd version for determining content type
         extension = webpath.extension().string();
         file.contentEncoding = "zstd";
+        file.onDiskComp = bmcweb::CompressionType::Zstd;
     }
 
     file.etag = getStaticEtag(webpath);
diff --git a/meson.build b/meson.build
index 23e3f28..c5b689f 100644
--- a/meson.build
+++ b/meson.build
@@ -230,6 +230,11 @@
 endif
 bmcweb_dependencies += [openssl]
 
+zstd = dependency('libzstd', required: get_option('http-zstd').allowed())
+if zstd.found()
+    add_project_arguments('-DHAVE_ZSTD', language: 'cpp')
+    bmcweb_dependencies += [zstd]
+endif
 nghttp2 = dependency('libnghttp2', version: '>=1.52.0', required: false)
 if not nghttp2.found()
     cmake = import('cmake')
@@ -383,6 +388,7 @@
     'http/mutual_tls.cpp',
     'http/routing/sserule.cpp',
     'http/routing/websocketrule.cpp',
+    'http/zstd_decompressor.cpp',
     'redfish-core/src/dbus_log_watcher.cpp',
     'redfish-core/src/error_message_utils.cpp',
     'redfish-core/src/error_messages.cpp',
@@ -451,6 +457,7 @@
     'test/http/server_sent_event_test.cpp',
     'test/http/utility_test.cpp',
     'test/http/verb_test.cpp',
+    'test/http/zstd_decompressor_test.cpp',
     'test/include/async_resolve_test.cpp',
     'test/include/credential_pipe_test.cpp',
     'test/include/dbus_utility_test.cpp',
diff --git a/meson.options b/meson.options
index 14cf611..86a3713 100644
--- a/meson.options
+++ b/meson.options
@@ -283,6 +283,14 @@
     description: 'Specifies the http request body length limit',
 )
 
+# BMCWEB_HTTP_ZSTD
+option(
+    'http-zstd',
+    type: 'feature',
+    value: 'enabled',
+    description: 'Allows compression/decompression using zstd',
+)
+
 # BMCWEB_REDFISH_NEW_POWERSUBSYSTEM_THERMALSUBSYSTEM
 option(
     'redfish-new-powersubsystem-thermalsubsystem',
diff --git a/subprojects/zstd.wrap b/subprojects/zstd.wrap
new file mode 100644
index 0000000..7327737
--- /dev/null
+++ b/subprojects/zstd.wrap
@@ -0,0 +1,14 @@
+[wrap-file]
+directory = zstd-1.5.5
+source_url = https://github.com/facebook/zstd/releases/download/v1.5.5/zstd-1.5.5.tar.gz
+source_filename = zstd-1.5.5.tar.gz
+source_hash = 9c4396cc829cfae319a6e2615202e82aad41372073482fce286fac78646d3ee4
+patch_filename = zstd_1.5.5-1_patch.zip
+patch_url = https://wrapdb.mesonbuild.com/v2/zstd_1.5.5-1/get_patch
+patch_hash = 0a076f6e60c4288193c2d9604670f2d8b5cb05511c2ac43eb521cb7363665278
+source_fallback_url = https://github.com/mesonbuild/wrapdb/releases/download/zstd_1.5.5-1/zstd-1.5.5.tar.gz
+wrapdb_version = 1.5.5-1
+
+[provide]
+libzstd = libzstd_dep
+
diff --git a/test/http/http_body_test.cpp b/test/http/http_body_test.cpp
index 75c81fa..889161c 100644
--- a/test/http/http_body_test.cpp
+++ b/test/http/http_body_test.cpp
@@ -62,13 +62,14 @@
     // Move constructor
     HttpBody::value_type value2 = value;
     EXPECT_EQ(value2.encodingType, EncodingType::Raw);
+    EXPECT_EQ(value2.compressionType, CompressionType::Raw);
     EXPECT_EQ(value2.str(), "teststring");
     EXPECT_EQ(value2.payloadSize(), 10);
 }
 
 TEST(HttpHttpBodyValueType, MoveFile)
 {
-    HttpBody::value_type value(EncodingType::Base64);
+    HttpBody::value_type value(EncodingType::Base64, CompressionType::Raw);
     TemporaryFileHandle temporaryFile("teststring");
     boost::system::error_code ec;
     value.open(temporaryFile.stringPath.c_str(), boost::beast::file_mode::read,
@@ -80,6 +81,7 @@
     size_t out = value2.file().read(buffer.data(), buffer.size(), ec);
     ASSERT_FALSE(ec);
     EXPECT_EQ(value2.encodingType, EncodingType::Base64);
+    EXPECT_EQ(value2.compressionType, CompressionType::Raw);
 
     EXPECT_THAT(std::span(buffer.data(), out),
                 ElementsAre('t', 'e', 's', 't', 's', 't', 'r', 'i', 'n', 'g'));
@@ -92,7 +94,7 @@
 
 TEST(HttpHttpBodyValueType, MoveOperatorFile)
 {
-    HttpBody::value_type value(EncodingType::Base64);
+    HttpBody::value_type value(EncodingType::Base64, CompressionType::Raw);
     TemporaryFileHandle temporaryFile("teststring");
     boost::system::error_code ec;
     value.open(temporaryFile.stringPath.c_str(), boost::beast::file_mode::read,
@@ -104,6 +106,7 @@
     size_t out = value2.file().read(buffer.data(), buffer.size(), ec);
     ASSERT_FALSE(ec);
     EXPECT_EQ(value2.encodingType, EncodingType::Base64);
+    EXPECT_EQ(value2.compressionType, CompressionType::Raw);
 
     EXPECT_THAT(std::span(buffer.data(), out),
                 ElementsAre('t', 'e', 's', 't', 's', 't', 'r', 'i', 'n', 'g'));
@@ -115,7 +118,7 @@
 
 TEST(HttpFileBodyValueType, SetFd)
 {
-    HttpBody::value_type value(EncodingType::Base64);
+    HttpBody::value_type value(EncodingType::Base64, CompressionType::Raw);
     TemporaryFileHandle temporaryFile("teststring");
     boost::system::error_code ec;
     FILE* r = fopen(temporaryFile.stringPath.c_str(), "r");
diff --git a/test/http/zstd_decompressor_test.cpp b/test/http/zstd_decompressor_test.cpp
new file mode 100644
index 0000000..e765bd9
--- /dev/null
+++ b/test/http/zstd_decompressor_test.cpp
@@ -0,0 +1,124 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Copyright OpenBMC Authors
+#ifdef HAVE_ZSTD
+#include "zstd_decompressor.hpp"
+
+#include <boost/asio/buffer.hpp>
+
+#include <algorithm>
+#include <array>
+#include <cstddef>
+#include <optional>
+#include <span>
+
+#include <gmock/gmock.h>
+#include <gtest/gtest.h>
+
+using ::testing::Each;
+using ::testing::Eq;
+
+namespace zstd
+{
+namespace
+{
+
+TEST(Zstd, EmptyFile)
+{
+    std::array<unsigned char, 13> empty{0x28, 0xb5, 0x2f, 0xfd, 0x24,
+                                        0x00, 0x01, 0x00, 0x00, 0x99,
+                                        0xe9, 0xd8, 0x51};
+
+    ZstdDecompressor comp;
+    std::optional<boost::asio::const_buffer> out =
+        comp.decompress(boost::asio::buffer(empty));
+    ASSERT_TRUE(out);
+    if (!out)
+    {
+        return;
+    }
+    EXPECT_TRUE(out->size() == 0);
+}
+
+TEST(Zstd, ZerosFile)
+{
+    // A 1MB file of all zeros created using
+    // dd if=/dev/zero of=zeros-file bs=1024 count=1024
+    // zstd -c zeros-file | xxd -i
+    std::array<unsigned char, 54> zeros = {
+        0x28, 0xb5, 0x2f, 0xfd, 0xa4, 0x00, 0x00, 0x10, 0x00, 0x54, 0x00,
+        0x00, 0x10, 0x00, 0x00, 0x01, 0x00, 0xfb, 0xff, 0x39, 0xc0, 0x02,
+        0x02, 0x00, 0x10, 0x00, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00, 0x10,
+        0x00, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00, 0x10, 0x00, 0x02, 0x00,
+        0x10, 0x00, 0x03, 0x00, 0x10, 0x00, 0xf1, 0x3e, 0x16, 0xe1};
+
+    for (size_t chunkSize :
+         std::to_array<size_t>({1U, 2U, 4U, 8U, 16U, zeros.size()}))
+    {
+        ZstdDecompressor comp;
+        std::span<unsigned char> data = std::span(zeros);
+        size_t read = 0;
+        while (!data.empty())
+        {
+            std::span<unsigned char> chunk =
+                data.subspan(0, std::min(chunkSize, data.size()));
+            std::optional<boost::asio::const_buffer> out = comp.decompress(
+                boost::asio::buffer(chunk.data(), chunk.size()));
+            ASSERT_TRUE(out);
+            if (out)
+            {
+                EXPECT_THAT(
+                    std::span(static_cast<const unsigned char*>(out->data()),
+                              out->size()),
+                    Each(Eq(0)));
+                read += out->size();
+            }
+            data = data.subspan(chunk.size());
+        }
+
+        EXPECT_EQ(read, 1024 * 1024);
+    }
+}
+
+TEST(Zstd, OnesFile)
+{
+    // A 1MB file of all ones created using
+    // dd if=/dev/zero bs=1024 count=1024 | tr "\000" "\377" > ones.txt
+    // zstd -c ones-file | xxd -i
+    std::array<unsigned char, 54> ones = {
+        0x28, 0xb5, 0x2f, 0xfd, 0xa4, 0x00, 0x00, 0x10, 0x00, 0x54, 0x00,
+        0x00, 0x10, 0xff, 0xff, 0x01, 0x00, 0xfb, 0xff, 0x39, 0xc0, 0x02,
+        0x02, 0x00, 0x10, 0xff, 0x02, 0x00, 0x10, 0xff, 0x02, 0x00, 0x10,
+        0xff, 0x02, 0x00, 0x10, 0xff, 0x02, 0x00, 0x10, 0xff, 0x02, 0x00,
+        0x10, 0xff, 0x03, 0x00, 0x10, 0xff, 0xb4, 0xc8, 0xba, 0x13};
+
+    for (size_t chunkSize :
+         std::to_array<size_t>({1U, 2U, 4U, 8U, 16U, ones.size()}))
+    {
+        ZstdDecompressor comp;
+        std::span<unsigned char> data = std::span(ones);
+        size_t read = 0;
+        while (!data.empty())
+        {
+            std::span<unsigned char> chunk =
+                data.subspan(0, std::min(chunkSize, data.size()));
+            std::optional<boost::asio::const_buffer> out = comp.decompress(
+                boost::asio::buffer(chunk.data(), chunk.size()));
+            ASSERT_TRUE(out);
+            if (out)
+            {
+                EXPECT_THAT(
+                    std::span(static_cast<const unsigned char*>(out->data()),
+                              out->size()),
+                    Each(Eq(0xFF)));
+                read += out->size();
+            }
+            data = data.subspan(chunk.size());
+        }
+
+        EXPECT_EQ(read, 1024 * 1024);
+    }
+}
+
+} // namespace
+} // namespace zstd
+#endif