From: Vsevolod Stakhov Date: Sun, 8 Feb 2026 14:21:09 +0000 (+0000) Subject: [Test] Add v3 compression and proxy forwarding tests X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=af488e4480dedcc5ece3c26924730b528671fede;p=thirdparty%2Frspamd.git [Test] Add v3 compression and proxy forwarding tests Add C++ unit tests for zstd per-part compression round-trip (serialize and iov paths), mixed compressed/uncompressed parts, and body_iov segment writability for in-place encryption. Add Robot functional tests for /checkv3 through the proxy, both direct multipart and rspamc with zstd compression. --- diff --git a/test/functional/cases/140_proxy.robot b/test/functional/cases/140_proxy.robot index a2b872f51e..79b6b1fedc 100644 --- a/test/functional/cases/140_proxy.robot +++ b/test/functional/cases/140_proxy.robot @@ -25,6 +25,18 @@ RSPAMC Legacy Protocol ${result} = Rspamc ${RSPAMD_LOCAL_ADDR} ${RSPAMD_PORT_PROXY} ${MESSAGE} Should Contain ${result} RSPAMD/1.3 0 EX_OK +CHECKV3 VIA PROXY + [Documentation] Send /checkv3 multipart request through proxy, verify result + Set Test Variable ${RSPAMD_PORT_NORMAL} ${RSPAMD_PORT_PROXY} + Scan File V3 ${MESSAGE} + Expect Symbol SIMPLE_TEST + +CHECKV3 VIA PROXY WITH COMPRESSION + [Documentation] Send /checkv3 via rspamc through proxy with zstd compression + ${result} = Run Rspamc -p -h ${RSPAMD_LOCAL_ADDR}:${RSPAMD_PORT_PROXY} --protocol-v3 + ... ${MESSAGE} + Check Rspamc ${result} SIMPLE_TEST + *** Keywords *** Proxy Setup # Run slave & copy variables diff --git a/test/rspamd_cxx_unit_multipart.hxx b/test/rspamd_cxx_unit_multipart.hxx index f13089f494..8455a78600 100644 --- a/test/rspamd_cxx_unit_multipart.hxx +++ b/test/rspamd_cxx_unit_multipart.hxx @@ -26,6 +26,12 @@ #include #include +#ifdef SYS_ZSTD +#include "zstd.h" +#else +#include "contrib/zstd/zstd.h" +#endif + TEST_SUITE("multipart_form") { TEST_CASE("basic two-part form") @@ -585,4 +591,190 @@ TEST_SUITE("multipart_roundtrip") } } +TEST_SUITE("multipart_zstd") +{ + TEST_CASE("serialize with compression produces Content-Encoding: zstd") + { + rspamd::http::multipart_response resp; + std::string data = "{\"action\":\"reject\",\"score\":15.0}"; + resp.add_part("result", "application/json", data, true /* compress */); + + ZSTD_CStream *cstream = ZSTD_createCStream(); + ZSTD_initCStream(cstream, 1); + + auto serialized = resp.serialize(cstream); + ZSTD_freeCStream(cstream); + + /* Parse the multipart output */ + auto boundary = std::string(resp.get_boundary()); + auto parsed = rspamd::http::parse_multipart_form(serialized, boundary); + REQUIRE(parsed.has_value()); + CHECK(parsed->parts.size() == 1); + CHECK(parsed->parts[0].content_encoding == "zstd"); + + /* The data should be compressed (not matching original) */ + CHECK(parsed->parts[0].data != data); + + /* Decompress and verify */ + ZSTD_DStream *dstream = ZSTD_createDStream(); + ZSTD_initDStream(dstream); + auto &compressed = parsed->parts[0].data; + ZSTD_inBuffer zin = {compressed.data(), compressed.size(), 0}; + std::string decompressed(data.size() * 2, '\0'); + ZSTD_outBuffer zout = {decompressed.data(), decompressed.size(), 0}; + + while (zin.pos < zin.size) { + size_t r = ZSTD_decompressStream(dstream, &zout, &zin); + REQUIRE(!ZSTD_isError(r)); + } + ZSTD_freeDStream(dstream); + decompressed.resize(zout.pos); + + CHECK(decompressed == data); + } + + TEST_CASE("prepare_iov with compression round-trip") + { + rspamd::http::multipart_response resp; + std::string result = "{\"score\":42}"; + std::string body = "Hello world body data for compression test"; + resp.add_part("result", "application/json", result, true); + resp.add_part("body", "application/octet-stream", body, true); + + ZSTD_CStream *cs = ZSTD_createCStream(); + ZSTD_initCStream(cs, 1); + resp.prepare_iov(cs); + ZSTD_freeCStream(cs); + + /* Reassemble iov */ + std::string reassembled; + for (gsize i = 0; i < resp.body_iov_count(); i++) { + const auto *iov = &resp.body_iov()[i]; + reassembled.append(static_cast(iov->iov_base), iov->iov_len); + } + CHECK(reassembled.size() == resp.body_total_len()); + + /* Parse and verify both parts have zstd encoding */ + auto boundary = std::string(resp.get_boundary()); + auto parsed = rspamd::http::parse_multipart_form(reassembled, boundary); + REQUIRE(parsed.has_value()); + CHECK(parsed->parts.size() == 2); + CHECK(parsed->parts[0].content_encoding == "zstd"); + CHECK(parsed->parts[1].content_encoding == "zstd"); + + /* Decompress result part */ + { + ZSTD_DStream *ds = ZSTD_createDStream(); + ZSTD_initDStream(ds); + auto &comp = parsed->parts[0].data; + ZSTD_inBuffer zin = {comp.data(), comp.size(), 0}; + std::string dec(result.size() * 4, '\0'); + ZSTD_outBuffer zout = {dec.data(), dec.size(), 0}; + while (zin.pos < zin.size) { + size_t r = ZSTD_decompressStream(ds, &zout, &zin); + REQUIRE(!ZSTD_isError(r)); + } + ZSTD_freeDStream(ds); + dec.resize(zout.pos); + CHECK(dec == result); + } + + /* Decompress body part */ + { + ZSTD_DStream *ds = ZSTD_createDStream(); + ZSTD_initDStream(ds); + auto &comp = parsed->parts[1].data; + ZSTD_inBuffer zin = {comp.data(), comp.size(), 0}; + std::string dec(body.size() * 4, '\0'); + ZSTD_outBuffer zout = {dec.data(), dec.size(), 0}; + while (zin.pos < zin.size) { + size_t r = ZSTD_decompressStream(ds, &zout, &zin); + REQUIRE(!ZSTD_isError(r)); + } + ZSTD_freeDStream(ds); + dec.resize(zout.pos); + CHECK(dec == body); + } + } + + TEST_CASE("mixed compressed and uncompressed parts") + { + rspamd::http::multipart_response resp; + std::string result = "{\"action\":\"no action\"}"; + std::string body = "Plain uncompressed body"; + resp.add_part("result", "application/json", result, true); /* compressed */ + resp.add_part("body", "application/octet-stream", body, false); /* uncompressed */ + + ZSTD_CStream *cs = ZSTD_createCStream(); + ZSTD_initCStream(cs, 1); + resp.prepare_iov(cs); + ZSTD_freeCStream(cs); + + std::string reassembled; + for (gsize i = 0; i < resp.body_iov_count(); i++) { + const auto *iov = &resp.body_iov()[i]; + reassembled.append(static_cast(iov->iov_base), iov->iov_len); + } + + auto boundary = std::string(resp.get_boundary()); + auto parsed = rspamd::http::parse_multipart_form(reassembled, boundary); + REQUIRE(parsed.has_value()); + CHECK(parsed->parts.size() == 2); + + /* Result part: compressed */ + CHECK(parsed->parts[0].content_encoding == "zstd"); + + /* Body part: uncompressed — data should match directly */ + CHECK(parsed->parts[1].content_encoding.empty()); + CHECK(parsed->parts[1].data == body); + } + + TEST_CASE("body_iov segments are writable for in-place encryption") + { + /* The encryption path (rspamd_cryptobox_encryptv_nm_inplace) writes + * to body_iov segments in-place. Verify all segments are writable. */ + rspamd::http::multipart_response resp; + std::string result = "{\"action\":\"reject\"}"; + std::string body = "Message body content here"; + resp.add_part("result", "application/json", result, true); + resp.add_part("body", "application/octet-stream", body, false); + + ZSTD_CStream *cs = ZSTD_createCStream(); + ZSTD_initCStream(cs, 1); + resp.prepare_iov(cs); + ZSTD_freeCStream(cs); + + /* Verify we can write to every byte of every iov segment + * (simulates what encryptv_nm_inplace does) */ + for (gsize i = 0; i < resp.body_iov_count(); i++) { + auto *iov = &resp.body_iov()[i]; + auto *p = static_cast(iov->iov_base); + for (gsize j = 0; j < iov->iov_len; j++) { + p[j] ^= 0xFF; /* XOR (simulate encryption) */ + } + } + + /* XOR back to restore */ + for (gsize i = 0; i < resp.body_iov_count(); i++) { + auto *iov = &resp.body_iov()[i]; + auto *p = static_cast(iov->iov_base); + for (gsize j = 0; j < iov->iov_len; j++) { + p[j] ^= 0xFF; + } + } + + /* After restoring, reassemble and verify it parses correctly */ + std::string reassembled; + for (gsize i = 0; i < resp.body_iov_count(); i++) { + const auto *iov = &resp.body_iov()[i]; + reassembled.append(static_cast(iov->iov_base), iov->iov_len); + } + + auto boundary = std::string(resp.get_boundary()); + auto parsed = rspamd::http::parse_multipart_form(reassembled, boundary); + REQUIRE(parsed.has_value()); + CHECK(parsed->parts.size() == 2); + } +} + #endif// RSPAMD_CXX_UNIT_MULTIPART_HXX