//! Differential and property tests for the LZ4 block codec. //! //! Every generated input must compress into a block which: //! //! 1. fits within `compressBound`, //! 2. is structurally valid LZ4 with the stricter guarantees our //! compressor documents (final five bytes are literals, matches start //! at least twelve bytes before the end), verified by an independent //! walker which shares no code with the codec, //! 3. decompresses to exactly the original bytes, and //! 4. is rejected when decompressed into a buffer of the wrong size. //! //! Valid blocks are additionally mutated (bit flips, splices, truncations) //! and fed to the decompressor, which must fail cleanly or succeed, but //! never read or write out of bounds. The unit-test build enables runtime //! safety, so any out-of-bounds slice access fails the test. //! //! The light suite below runs as a normal unit test and finishes quickly. //! The exhaustive suite multiplies the same properties across far more //! sizes, periods, seeds, and mutations; it is slow and therefore skipped //! unless the environment variable `GHOSTTY_LZ4_SLOW` is set: //! //! GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" const std = @import("std"); const testing = std.testing; const Allocator = std.mem.Allocator; const lz4 = @import("lz4.zig"); /// Number of trailing block bytes which must be literals, mirroring the /// documented guarantee of the compressor. Kept as an independent constant /// so a codec regression cannot silently weaken the check. const last_literals = 5; /// A match may not begin in the final twelve bytes. See `last_literals`. const match_find_limit = 12; /// Sizes around every encoding boundary: token nibble limits (14/15), /// minimum match and find limits (4/12), length-extension steps (15 + 255k), /// and power-of-two neighborhoods. const boundary_sizes = [_]usize{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 31, 32, 33, 63, 64, 65, 254, 255, 256, 269, 270, 271, 272, 1023, 1024, 4095, 4096, 4097, }; /// Input generators exercising distinct codec behaviors. Every generator is /// deterministic for a given random state. const Generator = enum { /// Uniform random bytes; largely incompressible. random_bytes, /// Runs of one repeated byte with random lengths; period-one matches. runs, /// One repeating pattern; exercises a fixed match period end to end. periodic, /// Eight-byte records with random small payloads and zero padding, with /// some records repeated; resembles terminal cell memory. cells, /// Dictionary words with separators; text-like literal/match mix. words, /// Mostly zeros with scattered random bytes; long matches with isolated /// literals. sparse, /// Random segments of all other generators; exercises transitions. mixed, fn fill(gen: Generator, random: std.Random, buf: []u8) void { switch (gen) { .random_bytes => random.bytes(buf), .runs => { var i: usize = 0; while (i < buf.len) { const run = @min( random.intRangeAtMost(usize, 1, 300), buf.len - i, ); @memset(buf[i..][0..run], random.int(u8)); i += run; } }, .periodic => fillPeriodic( random, buf, random.intRangeAtMost(usize, 1, 40), ), .cells => { var i: usize = 0; while (i + 8 <= buf.len) : (i += 8) { const cell = buf[i..][0..8]; if (i >= 8 and random.boolean()) { // Repeat one of the last 256 records. const back = 8 * random.intRangeAtMost( usize, 1, @min(i / 8, 256), ); cell.* = buf[i - back ..][0..8].*; } else { @memset(cell, 0); cell[0] = ' ' + random.uintLessThan(u8, 95); cell[1] = random.uintLessThan(u8, 4); } } random.bytes(buf[i..]); }, .words => { const words = [_][]const u8{ "the", "terminal", "page", "compress", "row", "cell", "style", "zig", "lz4", "block", "offset", "match", "a", "of", "and", "literal", "0x00", "0xFF", " ", "\r\n", "-----", "=", "pub fn", "const", }; var i: usize = 0; while (i < buf.len) { const word = words[random.uintLessThan(usize, words.len)]; const n = @min(word.len, buf.len - i); @memcpy(buf[i..][0..n], word[0..n]); i += n; if (i < buf.len) { buf[i] = if (random.boolean()) ' ' else '\n'; i += 1; } } }, .sparse => { @memset(buf, 0); if (buf.len == 0) return; for (0..buf.len / 32 + 1) |_| { const at = random.uintLessThan(usize, buf.len); buf[at] = random.int(u8); } }, .mixed => { var i: usize = 0; while (i < buf.len) { const segment = @min( random.intRangeAtMost(usize, 1, 2048), buf.len - i, ); const sub = random.enumValue(Generator); if (sub != .mixed) sub.fill(random, buf[i..][0..segment]); i += segment; } }, } } }; /// Fill `buf` with one repeating pattern of the given period. fn fillPeriodic(random: std.Random, buf: []u8, period: usize) void { if (buf.len == 0) return; const head = @min(period, buf.len); random.bytes(buf[0..head]); for (head..buf.len) |i| buf[i] = buf[i - period]; } /// Reusable buffers sized for the largest input a suite generates. const Workspace = struct { input: []u8, encoded: []u8, decoded: []u8, table: *lz4.HashTable, fn init(alloc: Allocator, max_input: usize) !Workspace { const input = try alloc.alloc(u8, max_input); errdefer alloc.free(input); const encoded = try alloc.alloc(u8, try lz4.compressBound(max_input)); errdefer alloc.free(encoded); // One extra byte so wrong-size decompression can be tested above // the exact length as well as below it. const decoded = try alloc.alloc(u8, max_input + 1); errdefer alloc.free(decoded); const table = try alloc.create(lz4.HashTable); return .{ .input = input, .encoded = encoded, .decoded = decoded, .table = table, }; } fn deinit(ws: *Workspace, alloc: Allocator) void { alloc.free(ws.input); alloc.free(ws.encoded); alloc.free(ws.decoded); alloc.destroy(ws.table); ws.* = undefined; } }; /// Compress one input and verify every property promised by the codec. /// Returns the encoded length so callers can reuse the encoded block. fn expectCodecProperties(ws: *Workspace, input: []const u8) !usize { const encoded_len = try lz4.compress(input, ws.encoded, ws.table); try testing.expect(encoded_len <= try lz4.compressBound(input.len)); const encoded = ws.encoded[0..encoded_len]; try expectValidBlock(encoded, input.len); // Exact-size decompression must reproduce the input bit for bit. The // output is poisoned first so unwritten bytes cannot pass as correct. const output = ws.decoded[0..input.len]; @memset(output, 0xAA); try testing.expectEqual(input.len, try lz4.decompress(encoded, output)); try testing.expectEqualSlices(u8, input, output); // The exact-size contract must reject both smaller and larger buffers. if (input.len > 0) { try testing.expectError( error.OutputTooSmall, lz4.decompress(encoded, ws.decoded[0 .. input.len - 1]), ); } try testing.expectError( error.OutputSizeMismatch, lz4.decompress(encoded, ws.decoded[0 .. input.len + 1]), ); return encoded_len; } /// Structurally validate one encoded block against the LZ4 block format and /// the stricter guarantees documented by our compressor. This deliberately /// reimplements the format rather than reusing codec internals. fn expectValidBlock(encoded: []const u8, raw_len: usize) !void { var ip: usize = 0; var op: usize = 0; var last_match_end: usize = 0; while (true) { // Every sequence, including the final one, starts with a token. try testing.expect(ip < encoded.len); const token = encoded[ip]; ip += 1; var literal_len: usize = token >> 4; if (literal_len == 15) literal_len += try readExtension(encoded, &ip); try testing.expect(encoded.len - ip >= literal_len); ip += literal_len; op += literal_len; // The final sequence contains only literals and ends the block. if (ip == encoded.len) { try testing.expectEqual(raw_len, op); if (last_match_end > 0) try testing.expect(raw_len - last_match_end >= last_literals); return; } try testing.expect(encoded.len - ip >= 2); const offset = std.mem.readInt(u16, encoded[ip..][0..2], .little); ip += 2; try testing.expect(offset >= 1); try testing.expect(offset <= op); // Our compressor starts matches at least `match_find_limit` bytes // before the end and never lets one run into the final literals. try testing.expect(op + match_find_limit <= raw_len); var match_len: usize = (token & 0x0F) + 4; if (token & 0x0F == 15) match_len += try readExtension(encoded, &ip); op += match_len; try testing.expect(op + last_literals <= raw_len); last_match_end = op; } } /// Read one length extension: bytes of 255 accumulate until a terminator /// below 255, which is included in the sum. fn readExtension(encoded: []const u8, ip: *usize) !usize { var total: usize = 0; while (true) { try testing.expect(ip.* < encoded.len); const value = encoded[ip.*]; ip.* += 1; total += value; if (value != 255) return total; } } /// Decode deterministic corruptions of a valid block. Any result is /// acceptable except memory unsafety, which the safety-checked test build /// turns into a failure. Mutations may still form a valid block, so output /// contents are intentionally not asserted. fn expectMutationSafety( ws: *Workspace, random: std.Random, encoded_len: usize, raw_len: usize, mutations: usize, ) !void { const original = try testing.allocator.dupe(u8, ws.encoded[0..encoded_len]); defer testing.allocator.free(original); for (0..mutations) |_| { const block = ws.encoded[0..encoded_len]; @memcpy(block, original); switch (random.uintLessThan(u8, 4)) { // Flip up to eight random bits. 0 => for (0..random.intRangeAtMost(usize, 1, 8)) |_| { const at = random.uintLessThan(usize, block.len); block[at] ^= @as(u8, 1) << random.int(u3); }, // Overwrite one random byte. Token and length bytes are the // most interesting targets and small blocks are mostly tokens. 1 => block[random.uintLessThan(usize, block.len)] = random.int(u8), // Splice random garbage over a random span. 2 => { const at = random.uintLessThan(usize, block.len); const span = @min( random.intRangeAtMost(usize, 1, 16), block.len - at, ); random.bytes(block[at..][0..span]); }, // Decode a random prefix of the intact block. else => {}, } const len = if (random.boolean()) random.uintAtMost(usize, block.len) else block.len; _ = lz4.decompress(block[0..len], ws.decoded[0..raw_len]) catch {}; } } /// Workload knobs shared by the light and exhaustive suites. const Budget = struct { /// Upper bound and step of the contiguous small-size sweep applied to /// every generator. sweep_max: usize, sweep_step: usize, /// Number and maximum size of random-parameter inputs. random_inputs: usize, random_max: usize, /// Explicit match periods checked with `fillPeriodic`. periods: []const usize, period_len: usize, /// Number of corrupted decode attempts per mutation base block. mutations: usize, fn maxInput(budget: Budget) usize { return @max( budget.random_max, @max(budget.period_len, boundary_sizes[boundary_sizes.len - 1]), ); } }; const light_budget: Budget = .{ .sweep_max = 96, .sweep_step = 1, .random_inputs = 24, .random_max = 32 * 1024, // One period per copy strategy: byte propagation (3), pattern words // (1/2/4/8), word strides (9), wide strides (17), plus the 64 KiB // window edge cases. .periods = &.{ 1, 2, 3, 4, 8, 9, 17, 65534, 65535, 65536, 65537 }, .period_len = 160 * 1024, .mutations = 64, }; const exhaustive_budget: Budget = .{ .sweep_max = 2048, .sweep_step = 1, .random_inputs = 512, .random_max = 512 * 1024, .periods = &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 23, 24, 31, 32, 33, 48, 63, 64, 65, 127, 128, 255, 256, 257, 4095, 4096, 32768, 65533, 65534, 65535, 65536, 65537, 65538, }, .period_len = 320 * 1024, .mutations = 4096, }; fn runSuite(budget: Budget, seed: u64) !void { var prng = std.Random.DefaultPrng.init(seed); const random = prng.random(); var ws: Workspace = try .init(testing.allocator, budget.maxInput()); defer ws.deinit(testing.allocator); // Every generator across every boundary size and the small-size sweep. // Small inputs hit the literal-only format edges: below the minimum // match sizes, around nibble limits, and around extension steps. inline for (@typeInfo(Generator).@"enum".fields) |field| { const gen: Generator = @enumFromInt(field.value); for (boundary_sizes) |size| { const input = ws.input[0..size]; gen.fill(random, input); _ = try expectCodecProperties(&ws, input); } var size: usize = 0; while (size <= budget.sweep_max) : (size += budget.sweep_step) { const input = ws.input[0..size]; gen.fill(random, input); _ = try expectCodecProperties(&ws, input); } } // Random generator and size pairs, biased toward interesting sizes by // squaring so both tiny and large inputs appear. for (0..budget.random_inputs) |_| { const gen = random.enumValue(Generator); const scale = random.float(f64); const size: usize = @intFromFloat( scale * scale * @as(f64, @floatFromInt(budget.random_max)), ); const input = ws.input[0..size]; gen.fill(random, input); const encoded_len = try expectCodecProperties(&ws, input); // Reuse a handful of these encodings as corruption bases. try expectMutationSafety( &ws, random, encoded_len, input.len, budget.mutations / budget.random_inputs + 1, ); } // Directed match periods, including the 64 KiB window edge where the // compressor's 16-bit position arithmetic wraps. for (budget.periods) |period| { const input = ws.input[0..budget.period_len]; fillPeriodic(random, input, period); _ = try expectCodecProperties(&ws, input); } // Dedicated corruption run over a text-like block, plus an exhaustive // truncation sweep: every prefix of a valid block must decode cleanly // or fail cleanly. { const input = ws.input[0..@min(16 * 1024, budget.random_max)]; Generator.words.fill(random, input); const encoded_len = try expectCodecProperties(&ws, input); try expectMutationSafety( &ws, random, encoded_len, input.len, budget.mutations, ); for (0..encoded_len) |prefix| { _ = lz4.decompress( ws.encoded[0..prefix], ws.decoded[0..input.len], ) catch {}; } } } test "lz4 differential light" { try runSuite(light_budget, 0x4C5A_3403); } test "lz4 differential exhaustive" { // Slow. Enable explicitly, ideally together with a test filter: // GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" if (!std.testing.environ.containsConstant("GHOSTTY_LZ4_SLOW")) return error.SkipZigTest; // Several independent seeds; the suite is deterministic per seed. for (0..4) |seed| try runSuite(exhaustive_budget, 0x4C5A_4000 + seed); }