2026-01-14 15:37:19 -05:00
|
|
|
#include <gtest/gtest.h>
|
|
|
|
|
#include "core/gzip_handler.h"
|
|
|
|
|
|
|
|
|
|
using namespace lgx;
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Compression/Decompression Roundtrip Tests
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Roundtrip_SimpleData) {
|
|
|
|
|
std::vector<uint8_t> original = {'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd'};
|
|
|
|
|
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
ASSERT_FALSE(compressed.empty());
|
|
|
|
|
EXPECT_TRUE(GzipHandler::isGzipData(compressed));
|
|
|
|
|
|
|
|
|
|
auto decompressed = GzipHandler::decompress(compressed);
|
|
|
|
|
EXPECT_EQ(decompressed, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Roundtrip_LargeData) {
|
|
|
|
|
// Create 1MB of data
|
|
|
|
|
std::vector<uint8_t> original(1024 * 1024);
|
|
|
|
|
for (size_t i = 0; i < original.size(); ++i) {
|
|
|
|
|
original[i] = static_cast<uint8_t>(i % 256);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
ASSERT_FALSE(compressed.empty());
|
|
|
|
|
|
|
|
|
|
// Compression should reduce size for patterned data
|
|
|
|
|
EXPECT_LT(compressed.size(), original.size());
|
|
|
|
|
|
|
|
|
|
auto decompressed = GzipHandler::decompress(compressed);
|
|
|
|
|
EXPECT_EQ(decompressed, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Roundtrip_BinaryData) {
|
|
|
|
|
// Binary data with null bytes
|
|
|
|
|
std::vector<uint8_t> original = {0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x00, 0xAB};
|
|
|
|
|
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
ASSERT_FALSE(compressed.empty());
|
|
|
|
|
|
|
|
|
|
auto decompressed = GzipHandler::decompress(compressed);
|
|
|
|
|
EXPECT_EQ(decompressed, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Empty Data Handling
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, EmptyData_Compress) {
|
|
|
|
|
std::vector<uint8_t> empty;
|
|
|
|
|
|
|
|
|
|
auto compressed = GzipHandler::compress(empty);
|
|
|
|
|
// Should produce valid gzip (with empty content)
|
|
|
|
|
EXPECT_TRUE(GzipHandler::isGzipData(compressed));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, EmptyData_Roundtrip) {
|
|
|
|
|
std::vector<uint8_t> empty;
|
|
|
|
|
|
|
|
|
|
auto compressed = GzipHandler::compress(empty);
|
|
|
|
|
auto decompressed = GzipHandler::decompress(compressed);
|
|
|
|
|
|
|
|
|
|
EXPECT_TRUE(decompressed.empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Determinism Tests
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Determinism_SameInput) {
|
|
|
|
|
std::vector<uint8_t> data = {'T', 'e', 's', 't', ' ', 'd', 'a', 't', 'a'};
|
|
|
|
|
|
|
|
|
|
auto compressed1 = GzipHandler::compress(data);
|
|
|
|
|
auto compressed2 = GzipHandler::compress(data);
|
|
|
|
|
|
|
|
|
|
// Same input MUST produce identical output
|
|
|
|
|
EXPECT_EQ(compressed1, compressed2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Determinism_MultipleRuns) {
|
|
|
|
|
// More complex data
|
|
|
|
|
std::vector<uint8_t> data(10000);
|
|
|
|
|
for (size_t i = 0; i < data.size(); ++i) {
|
|
|
|
|
data[i] = static_cast<uint8_t>((i * 7 + 13) % 256);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto compressed1 = GzipHandler::compress(data);
|
|
|
|
|
auto compressed2 = GzipHandler::compress(data);
|
|
|
|
|
auto compressed3 = GzipHandler::compress(data);
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(compressed1, compressed2);
|
|
|
|
|
EXPECT_EQ(compressed2, compressed3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Determinism_HeaderFormat) {
|
|
|
|
|
std::vector<uint8_t> data = {'X'};
|
|
|
|
|
auto compressed = GzipHandler::compress(data);
|
|
|
|
|
|
|
|
|
|
// Check gzip header format
|
|
|
|
|
ASSERT_GE(compressed.size(), 10);
|
|
|
|
|
|
|
|
|
|
// Magic bytes
|
|
|
|
|
EXPECT_EQ(compressed[0], 0x1f);
|
|
|
|
|
EXPECT_EQ(compressed[1], 0x8b);
|
|
|
|
|
|
|
|
|
|
// Compression method (deflate)
|
|
|
|
|
EXPECT_EQ(compressed[2], 8);
|
|
|
|
|
|
|
|
|
|
// Flags (should be 0 - no extra fields)
|
|
|
|
|
EXPECT_EQ(compressed[3], 0);
|
|
|
|
|
|
|
|
|
|
// Mtime should be 0 (bytes 4-7)
|
|
|
|
|
EXPECT_EQ(compressed[4], 0);
|
|
|
|
|
EXPECT_EQ(compressed[5], 0);
|
|
|
|
|
EXPECT_EQ(compressed[6], 0);
|
|
|
|
|
EXPECT_EQ(compressed[7], 0);
|
|
|
|
|
|
|
|
|
|
// OS byte (we use 0xFF for unknown)
|
|
|
|
|
EXPECT_EQ(compressed[9], 0xFF);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Invalid Data Handling
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, IsGzipData_ValidMagic) {
|
|
|
|
|
std::vector<uint8_t> valid = {0x1f, 0x8b, 0x08, 0x00};
|
|
|
|
|
EXPECT_TRUE(GzipHandler::isGzipData(valid));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, IsGzipData_InvalidMagic) {
|
|
|
|
|
std::vector<uint8_t> invalid = {0x50, 0x4b, 0x03, 0x04}; // ZIP magic
|
|
|
|
|
EXPECT_FALSE(GzipHandler::isGzipData(invalid));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, IsGzipData_TooShort) {
|
|
|
|
|
std::vector<uint8_t> tooShort = {0x1f};
|
|
|
|
|
EXPECT_FALSE(GzipHandler::isGzipData(tooShort));
|
|
|
|
|
|
|
|
|
|
std::vector<uint8_t> empty;
|
|
|
|
|
EXPECT_FALSE(GzipHandler::isGzipData(empty));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Decompress_InvalidData) {
|
|
|
|
|
std::vector<uint8_t> garbage = {'n', 'o', 't', ' ', 'g', 'z', 'i', 'p'};
|
|
|
|
|
|
|
|
|
|
auto result = GzipHandler::decompress(garbage);
|
|
|
|
|
EXPECT_TRUE(result.empty());
|
|
|
|
|
EXPECT_FALSE(GzipHandler::getLastError().empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Decompress_TruncatedData) {
|
|
|
|
|
std::vector<uint8_t> data = {'T', 'e', 's', 't'};
|
|
|
|
|
auto compressed = GzipHandler::compress(data);
|
|
|
|
|
|
|
|
|
|
// Truncate the compressed data
|
|
|
|
|
compressed.resize(compressed.size() / 2);
|
|
|
|
|
|
|
|
|
|
auto result = GzipHandler::decompress(compressed);
|
|
|
|
|
// Should fail gracefully
|
|
|
|
|
EXPECT_TRUE(result.empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Stream Decompression
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, DecompressStream) {
|
|
|
|
|
std::vector<uint8_t> original = {'S', 't', 'r', 'e', 'a', 'm', ' ', 't', 'e', 's', 't'};
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
2026-06-10 12:47:14 -04:00
|
|
|
|
2026-01-14 15:37:19 -05:00
|
|
|
std::vector<uint8_t> result;
|
2026-06-10 12:47:14 -04:00
|
|
|
bool success = GzipHandler::decompressStream(compressed,
|
2026-01-14 15:37:19 -05:00
|
|
|
[&result](const uint8_t* buffer, size_t size) {
|
|
|
|
|
result.insert(result.end(), buffer, buffer + size);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
2026-06-10 12:47:14 -04:00
|
|
|
|
2026-01-14 15:37:19 -05:00
|
|
|
EXPECT_TRUE(success);
|
|
|
|
|
EXPECT_EQ(result, original);
|
|
|
|
|
}
|
2026-06-10 12:47:14 -04:00
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Decompression Bomb Protection (F-007)
|
|
|
|
|
//
|
|
|
|
|
// A small, highly-compressible gzip stream can inflate to gigabytes. The
|
|
|
|
|
// decompress paths must enforce a hard cap on total output and reject the
|
|
|
|
|
// stream once it is exceeded, instead of growing an unbounded in-memory buffer
|
|
|
|
|
// and OOM-killing the host process.
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
// Build a "bomb": a small gzip stream that decompresses to `decompressedSize`
|
|
|
|
|
// bytes of zeros (DEFLATE achieves ~1000:1 on a run of identical bytes).
|
|
|
|
|
static std::vector<uint8_t> makeZeroBomb(size_t decompressedSize) {
|
|
|
|
|
std::vector<uint8_t> zeros(decompressedSize, 0);
|
|
|
|
|
return GzipHandler::compress(zeros);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Decompress_RejectsBombExceedingCap) {
|
|
|
|
|
// 64 MiB of zeros compresses to a handful of KiB on disk...
|
|
|
|
|
const size_t bombSize = 64 * 1024 * 1024;
|
|
|
|
|
auto bomb = makeZeroBomb(bombSize);
|
|
|
|
|
ASSERT_FALSE(bomb.empty());
|
|
|
|
|
// ...the on-disk archive is tiny relative to its decompressed size.
|
|
|
|
|
EXPECT_LT(bomb.size(), bombSize / 100);
|
|
|
|
|
|
|
|
|
|
// Decompress with a 1 MiB cap: the loop must bail out long before
|
|
|
|
|
// materializing the full 64 MiB, and must not return the payload.
|
|
|
|
|
const size_t cap = 1 * 1024 * 1024;
|
|
|
|
|
auto result = GzipHandler::decompress(bomb, cap);
|
|
|
|
|
|
|
|
|
|
EXPECT_TRUE(result.empty());
|
|
|
|
|
EXPECT_LE(result.size(), cap);
|
|
|
|
|
EXPECT_FALSE(GzipHandler::getLastError().empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Decompress_AllowsOutputUpToCap) {
|
|
|
|
|
// Data that decompresses to just under the cap must still succeed.
|
|
|
|
|
std::vector<uint8_t> original(64 * 1024);
|
|
|
|
|
for (size_t i = 0; i < original.size(); ++i) {
|
|
|
|
|
original[i] = static_cast<uint8_t>((i * 31 + 7) % 256);
|
|
|
|
|
}
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
|
|
|
|
|
auto result = GzipHandler::decompress(compressed, 1 * 1024 * 1024);
|
|
|
|
|
EXPECT_EQ(result, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, Decompress_DefaultCapAllowsNormalData) {
|
|
|
|
|
// The default cap must not interfere with ordinary, legitimately-sized data.
|
|
|
|
|
std::vector<uint8_t> original(2 * 1024 * 1024);
|
|
|
|
|
for (size_t i = 0; i < original.size(); ++i) {
|
|
|
|
|
original[i] = static_cast<uint8_t>(i % 256);
|
|
|
|
|
}
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
|
|
|
|
|
auto result = GzipHandler::decompress(compressed); // default cap
|
|
|
|
|
EXPECT_EQ(result, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, DecompressStream_RejectsBombExceedingCap) {
|
|
|
|
|
const size_t bombSize = 64 * 1024 * 1024;
|
|
|
|
|
auto bomb = makeZeroBomb(bombSize);
|
|
|
|
|
ASSERT_FALSE(bomb.empty());
|
|
|
|
|
|
|
|
|
|
const size_t cap = 1 * 1024 * 1024;
|
|
|
|
|
size_t written = 0;
|
|
|
|
|
bool success = GzipHandler::decompressStream(bomb,
|
|
|
|
|
[&written](const uint8_t*, size_t size) {
|
|
|
|
|
written += size;
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
cap);
|
|
|
|
|
|
|
|
|
|
EXPECT_FALSE(success);
|
|
|
|
|
// The cap bounds how much is ever handed to the write callback.
|
|
|
|
|
EXPECT_LE(written, cap);
|
|
|
|
|
EXPECT_FALSE(GzipHandler::getLastError().empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST(GzipHandlerTest, DecompressStream_AllowsOutputUpToCap) {
|
|
|
|
|
std::vector<uint8_t> original(64 * 1024);
|
|
|
|
|
for (size_t i = 0; i < original.size(); ++i) {
|
|
|
|
|
original[i] = static_cast<uint8_t>((i * 17 + 3) % 256);
|
|
|
|
|
}
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
|
|
|
|
|
std::vector<uint8_t> result;
|
|
|
|
|
bool success = GzipHandler::decompressStream(compressed,
|
|
|
|
|
[&result](const uint8_t* buffer, size_t size) {
|
|
|
|
|
result.insert(result.end(), buffer, buffer + size);
|
|
|
|
|
return true;
|
|
|
|
|
},
|
|
|
|
|
1 * 1024 * 1024);
|
|
|
|
|
|
|
|
|
|
EXPECT_TRUE(success);
|
|
|
|
|
EXPECT_EQ(result, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =============================================================================
|
|
|
|
|
// Configurable Library-Wide Default Cap
|
|
|
|
|
//
|
|
|
|
|
// The decompression limit is also configurable for the whole library via
|
|
|
|
|
// GzipHandler::setDefaultMaxDecompressedSize(). Calls that pass no explicit
|
|
|
|
|
// maxOutputSize (including the .lgx load path) must honor the configured value.
|
|
|
|
|
//
|
|
|
|
|
// These tests mutate process-global state, so the fixture restores the factory
|
|
|
|
|
// default after each one to avoid leaking the setting into other tests.
|
|
|
|
|
// =============================================================================
|
|
|
|
|
|
|
|
|
|
class GzipDefaultCapTest : public ::testing::Test {
|
|
|
|
|
protected:
|
|
|
|
|
void TearDown() override {
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(
|
|
|
|
|
GzipHandler::DEFAULT_MAX_DECOMPRESSED_SIZE);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
TEST_F(GzipDefaultCapTest, FactoryDefaultIsOneGiB) {
|
|
|
|
|
EXPECT_EQ(GzipHandler::getDefaultMaxDecompressedSize(),
|
|
|
|
|
GzipHandler::DEFAULT_MAX_DECOMPRESSED_SIZE);
|
|
|
|
|
EXPECT_EQ(GzipHandler::DEFAULT_MAX_DECOMPRESSED_SIZE,
|
|
|
|
|
1024ull * 1024 * 1024);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(GzipDefaultCapTest, SetAndGetRoundtrip) {
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(4 * 1024 * 1024);
|
|
|
|
|
EXPECT_EQ(GzipHandler::getDefaultMaxDecompressedSize(), 4u * 1024 * 1024);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(GzipDefaultCapTest, ConfiguredDefaultIsEnforced) {
|
|
|
|
|
// Lower the library-wide default below the bomb's decompressed size...
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(1 * 1024 * 1024);
|
|
|
|
|
|
|
|
|
|
auto bomb = makeZeroBomb(64 * 1024 * 1024);
|
|
|
|
|
ASSERT_FALSE(bomb.empty());
|
|
|
|
|
|
|
|
|
|
// ...and decompress WITHOUT an explicit cap: the configured default applies.
|
|
|
|
|
auto result = GzipHandler::decompress(bomb);
|
|
|
|
|
|
|
|
|
|
EXPECT_TRUE(result.empty());
|
|
|
|
|
EXPECT_FALSE(GzipHandler::getLastError().empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(GzipDefaultCapTest, RaisedDefaultAllowsLargerData) {
|
|
|
|
|
// Data larger than a deliberately-low base, allowed once the default is raised.
|
|
|
|
|
std::vector<uint8_t> original(4 * 1024 * 1024);
|
|
|
|
|
for (size_t i = 0; i < original.size(); ++i) {
|
|
|
|
|
original[i] = static_cast<uint8_t>((i * 13 + 5) % 256);
|
|
|
|
|
}
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
|
|
|
|
|
// Default too low: rejected.
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(1 * 1024 * 1024);
|
|
|
|
|
EXPECT_TRUE(GzipHandler::decompress(compressed).empty());
|
|
|
|
|
|
|
|
|
|
// Raise the default above the payload: now accepted.
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(16 * 1024 * 1024);
|
|
|
|
|
EXPECT_EQ(GzipHandler::decompress(compressed), original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(GzipDefaultCapTest, ExplicitArgOverridesConfiguredDefault) {
|
|
|
|
|
// A low library default must not override a deliberately-higher per-call cap.
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(1024); // 1 KiB
|
|
|
|
|
|
|
|
|
|
std::vector<uint8_t> original(256 * 1024);
|
|
|
|
|
for (size_t i = 0; i < original.size(); ++i) {
|
|
|
|
|
original[i] = static_cast<uint8_t>((i * 7 + 1) % 256);
|
|
|
|
|
}
|
|
|
|
|
auto compressed = GzipHandler::compress(original);
|
|
|
|
|
|
|
|
|
|
// Explicit cap is honored regardless of the (lower) configured default.
|
|
|
|
|
auto result = GzipHandler::decompress(compressed, 1 * 1024 * 1024);
|
|
|
|
|
EXPECT_EQ(result, original);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_F(GzipDefaultCapTest, ZeroIsRejectedAndLeavesProtectionIntact) {
|
|
|
|
|
// 0 is the USE_DEFAULT_MAX sentinel and must never become the stored cap,
|
|
|
|
|
// otherwise it would silently disable bomb protection.
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(2 * 1024 * 1024);
|
|
|
|
|
GzipHandler::setDefaultMaxDecompressedSize(0); // must be ignored
|
|
|
|
|
|
|
|
|
|
EXPECT_EQ(GzipHandler::getDefaultMaxDecompressedSize(), 2u * 1024 * 1024);
|
|
|
|
|
|
|
|
|
|
// And the bomb is still rejected under the still-effective limit.
|
|
|
|
|
auto bomb = makeZeroBomb(64 * 1024 * 1024);
|
|
|
|
|
ASSERT_FALSE(bomb.empty());
|
|
|
|
|
EXPECT_TRUE(GzipHandler::decompress(bomb).empty());
|
|
|
|
|
}
|