terminal: add standalone LZ4 block codec

Scrollback compression needs a codec that can be used from libghostty-vt
without pulling in libc, and we need to measure it before integrating it
with terminal page ownership.

This adds an allocation-free raw LZ4 block codec in scalar Zig.  Callers
provide the input, output, and fixed-size scratch table. The decoder
uses an exact-size output contract so page metadata mismatches fail
cleanly. Compatibility vectors, boundary cases, random round trips, and
fuzz coverage exercise the block format.

Also adds a page-compression benchmark that operates on reusable raw
page corpora. Compression and decompression have separate modes with
setup outside the timed region, plus a ratio report and no-op baseline.
Nothing uses compression in the terminal yet; this is the isolated codec
and measurement groundwork.
This commit is contained in:
Mitchell Hashimoto
2026-07-08 08:45:01 -07:00
parent ad692f1e85
commit c62c159841
5 changed files with 981 additions and 0 deletions

View File

@@ -0,0 +1,401 @@
//! Benchmarks raw LZ4 compression and decompression on page-sized byte
//! buffers.
//!
//! This benchmark is intentionally independent of terminal page ownership and
//! lifecycle. It treats its input as opaque bytes and calls only the standalone
//! LZ4 block codec. In particular, it does not compress pages owned by a live
//! terminal and is not evidence that compression is enabled in production.
//!
//! ## Input
//!
//! `--data` names a pre-generated raw byte corpus. The corpus is divided into
//! `--page-size` byte chunks, with a final short chunk retained when the file
//! size is not an exact multiple. The default page size is 400 KiB, matching a
//! standard terminal page in ReleaseFast builds on the current target.
//!
//! A raw dump of actual page backing memory is the most representative input:
//! it includes cells, rows, styles, graphemes, hyperlinks, allocator metadata,
//! and unused capacity exactly as the codec would see them. Keep such corpora
//! outside the repository and reuse the same file when comparing branches.
//! Arbitrary files are accepted too, but their compression ratios should not be
//! interpreted as terminal scrollback ratios.
//!
//! ## Modes
//!
//! * `noop` walks the input chunks without invoking the codec. This measures the
//! benchmark loop's minimum overhead.
//! * `compress` compresses every input chunk into a reusable output buffer.
//! * `decompress` prepares compressed blocks during setup, then decompresses
//! every block into a reusable output buffer.
//! * `report` compresses each chunk once and prints raw and encoded sizes. It is
//! for inspecting ratios, not timing comparisons.
//!
//! Dataset loading, output allocation, and preparation of blocks for
//! decompression happen in `setup` and are outside `Benchmark`'s timed region.
//! The `compress` and `decompress` steps perform no allocation.
//! `hyperfine` still measures full process lifetime, so use `--loops` to
//! amortize setup and teardown when comparing small corpora.
//!
//! ## Examples
//!
//! Build benchmarks in ReleaseFast mode:
//!
//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false
//!
//! Inspect the compression ratio of a page corpus:
//!
//! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw
//!
//! Compare compression and decompression with `hyperfine`:
//!
//! hyperfine --warmup 3 \
//! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \
//! 'ghostty-bench +page-compression --mode=decompress --loops=100 --data=/tmp/pages.raw'
const PageCompression = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const lz4 = @import("../terminal/compress/lz4.zig");
const log = std.log.scoped(.@"page-compression-bench");
/// Prevent a malformed or accidentally enormous corpus from consuming
/// unbounded memory during benchmark setup.
const max_data_size = 64 * 1024 * 1024;
alloc: Allocator,
opts: Options,
/// Complete contents of the input corpus. Individual pages are slices into
/// this allocation, so it remains alive until teardown.
data: []u8 = &.{},
/// Compressed blocks prepared during setup for `decompress` mode.
encoded: std.ArrayList(Encoded) = .empty,
/// Reused by compression and report modes. Its length is the compression
/// bound of the largest input chunk.
compression_output: []u8 = &.{},
/// Reused by decompression mode. Its length is at least one input chunk.
decompression_output: []u8 = &.{},
/// Fixed 16 KiB scratch table required by the compressor.
table: lz4.HashTable = undefined,
pub const Options = struct {
/// Set by the shared CLI parser for string option ownership.
_arena: ?std.heap.ArenaAllocator = null,
/// Select the operation performed inside the timed benchmark step.
mode: Mode = .compress,
/// Repeat the complete corpus this many times per benchmark step. Increase
/// this when the corpus is too small for stable `hyperfine` measurements.
loops: u32 = 1,
/// Number of bytes treated as one independent LZ4 block. Real page dumps
/// should use the exact backing-memory size of the pages being measured.
@"page-size": usize = 400 * 1024,
/// Pre-generated input corpus. `-` reads stdin, although a regular file is
/// recommended so identical bytes can be reused across benchmark runs.
/// When unset, all modes are no-ops.
data: ?[]const u8 = null,
pub fn deinit(self: *Options) void {
if (self._arena) |arena| arena.deinit();
self.* = undefined;
}
};
pub const Mode = enum {
/// Walk page boundaries and establish the benchmark loop overhead.
noop,
/// Compress each raw page into a reusable compression-bound buffer.
compress,
/// Decompress blocks prepared before the timed region.
decompress,
/// Print per-page and aggregate encoded sizes. Not a timing benchmark.
report,
};
const Encoded = struct {
/// Exact compressed block bytes.
bytes: []u8,
/// Exact output length expected by the raw block decoder.
raw_len: usize,
};
/// Allocate benchmark state. Input data is intentionally loaded later by
/// `setup` so construction is cheap and follows the other benchmarks.
pub fn create(
alloc: Allocator,
opts: Options,
) !*PageCompression {
const ptr = try alloc.create(PageCompression);
ptr.* = .{
.alloc = alloc,
.opts = opts,
};
return ptr;
}
/// Release allocations retained across benchmark steps.
pub fn destroy(self: *PageCompression, alloc: Allocator) void {
self.clearPreparedData();
alloc.destroy(self);
}
/// Select one operation for the benchmark harness to time.
pub fn benchmark(self: *PageCompression) Benchmark {
return .init(self, .{
.stepFn = switch (self.opts.mode) {
.noop => stepNoop,
.compress => stepCompress,
.decompress => stepDecompress,
.report => stepReport,
},
.setupFn = setup,
.teardownFn = teardown,
});
}
/// Load and partition the input corpus. For decompression mode this also
/// creates the encoded blocks, keeping compression outside the timed region.
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *PageCompression = @ptrCast(@alignCast(ptr));
assert(self.data.len == 0);
assert(self.encoded.items.len == 0);
self.setupData() catch |err| {
log.warn("failed to prepare page compression benchmark err={}", .{err});
return error.BenchmarkFailed;
};
}
fn setupData(self: *PageCompression) !void {
if (self.opts.loops == 0) return error.InvalidLoops;
if (self.opts.@"page-size" == 0) return error.InvalidPageSize;
const data_file = try options.dataFile(self.opts.data) orelse return;
defer data_file.close();
self.data = try data_file.readToEndAlloc(self.alloc, max_data_size);
errdefer {
self.alloc.free(self.data);
self.data = &.{};
}
if (self.data.len == 0) return;
if (self.opts.mode == .noop) return;
const largest_page = @min(self.opts.@"page-size", self.data.len);
self.compression_output = try self.alloc.alloc(
u8,
try lz4.compressBound(largest_page),
);
errdefer {
self.alloc.free(self.compression_output);
self.compression_output = &.{};
}
if (self.opts.mode == .decompress) try self.prepareEncoded();
}
/// Precompress every input page and verify one decode before benchmarking.
/// This catches corpus or codec problems before the timer starts.
fn prepareEncoded(self: *PageCompression) !void {
self.decompression_output = try self.alloc.alloc(
u8,
@min(self.opts.@"page-size", self.data.len),
);
errdefer {
self.alloc.free(self.decompression_output);
self.decompression_output = &.{};
}
var it = self.pages();
while (it.next()) |page| {
const encoded_len = try lz4.compress(
page,
self.compression_output,
&self.table,
);
const encoded = try self.alloc.dupe(
u8,
self.compression_output[0..encoded_len],
);
self.encoded.append(self.alloc, .{
.bytes = encoded,
.raw_len = page.len,
}) catch |err| {
self.alloc.free(encoded);
return err;
};
}
var page_it = self.pages();
for (self.encoded.items) |block| {
const page = page_it.next().?;
const output = self.decompression_output[0..block.raw_len];
_ = try lz4.decompress(block.bytes, output);
if (!std.mem.eql(u8, page, output)) return error.RoundTripMismatch;
}
}
/// Release everything created by setup. This is shared by teardown and
/// destroy so errors and direct unit-test use remain leak-free.
fn clearPreparedData(self: *PageCompression) void {
for (self.encoded.items) |block| self.alloc.free(block.bytes);
self.encoded.deinit(self.alloc);
self.encoded = .empty;
if (self.compression_output.len > 0)
self.alloc.free(self.compression_output);
self.compression_output = &.{};
if (self.decompression_output.len > 0)
self.alloc.free(self.decompression_output);
self.decompression_output = &.{};
if (self.data.len > 0) self.alloc.free(self.data);
self.data = &.{};
}
fn teardown(ptr: *anyopaque) void {
const self: *PageCompression = @ptrCast(@alignCast(ptr));
self.clearPreparedData();
}
/// Baseline mode: traverse exactly the same page boundaries as compression
/// without invoking the codec.
fn stepNoop(ptr: *anyopaque) Benchmark.Error!void {
const self: *PageCompression = @ptrCast(@alignCast(ptr));
for (0..self.opts.loops) |_| {
var it = self.pages();
while (it.next()) |page| std.mem.doNotOptimizeAway(page);
}
}
/// Compress all pages into one reusable output buffer. Only the returned
/// encoded length is consumed because retaining output pages would measure
/// allocation and ownership rather than codec throughput.
fn stepCompress(ptr: *anyopaque) Benchmark.Error!void {
const self: *PageCompression = @ptrCast(@alignCast(ptr));
for (0..self.opts.loops) |_| {
var it = self.pages();
while (it.next()) |page| {
const encoded_len = lz4.compress(
page,
self.compression_output,
&self.table,
) catch |err| {
log.warn("page compression failed err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(encoded_len);
}
}
}
/// Decompress blocks prepared by setup. The output allocation is reused so
/// this measures only decoding and the required memory writes.
fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void {
const self: *PageCompression = @ptrCast(@alignCast(ptr));
for (0..self.opts.loops) |_| {
for (self.encoded.items) |block| {
const output = self.decompression_output[0..block.raw_len];
_ = lz4.decompress(block.bytes, output) catch |err| {
log.warn("page decompression failed err={}", .{err});
return error.BenchmarkFailed;
};
std.mem.doNotOptimizeAway(output);
}
}
}
/// Print size information for evaluating compression ratio. This shares the
/// input and codec paths with compression mode but deliberately makes no
/// timing claims.
fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
const self: *PageCompression = @ptrCast(@alignCast(ptr));
if (self.data.len == 0) return;
var page_index: usize = 0;
var raw_total: usize = 0;
var encoded_total: usize = 0;
var it = self.pages();
while (it.next()) |page| : (page_index += 1) {
const encoded_len = lz4.compress(
page,
self.compression_output,
&self.table,
) catch |err| {
log.warn("page compression report failed err={}", .{err});
return error.BenchmarkFailed;
};
raw_total += page.len;
encoded_total += encoded_len;
std.debug.print(
"page-compression page={d} raw={d} encoded={d} ratio={d:.2}%\n",
.{ page_index, page.len, encoded_len, percentage(encoded_len, page.len) },
);
}
std.debug.print(
"page-compression total pages={d} raw={d} encoded={d} ratio={d:.2}% " ++
"workspace={d} output_bound={d}\n",
.{
page_index,
raw_total,
encoded_total,
percentage(encoded_total, raw_total),
@sizeOf(lz4.HashTable),
self.compression_output.len,
},
);
}
/// Iterate fixed-size page chunks without allocating an index table.
fn pages(self: *const PageCompression) PageIterator {
return .{
.data = self.data,
.page_size = self.opts.@"page-size",
};
}
const PageIterator = struct {
data: []const u8,
page_size: usize,
offset: usize = 0,
fn next(self: *PageIterator) ?[]const u8 {
if (self.offset >= self.data.len) return null;
const len = @min(self.page_size, self.data.len - self.offset);
const end = self.offset + len;
defer self.offset = end;
return self.data[self.offset..end];
}
};
fn percentage(part: usize, whole: usize) f64 {
if (whole == 0) return 0;
return @as(f64, @floatFromInt(part)) * 100 /
@as(f64, @floatFromInt(whole));
}
test PageCompression {
const testing = std.testing;
const impl: *PageCompression = try .create(testing.allocator, .{});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}

View File

@@ -8,6 +8,7 @@ const cli = @import("../cli.zig");
pub const Action = enum {
@"codepoint-width",
@"grapheme-break",
@"page-compression",
@"screen-clone",
@"terminal-parser",
@"terminal-stream",
@@ -25,6 +26,7 @@ pub const Action = enum {
pub fn Struct(comptime action: Action) type {
return switch (action) {
.@"screen-clone" => @import("ScreenClone.zig"),
.@"page-compression" => @import("PageCompression.zig"),
.@"terminal-stream" => @import("TerminalStream.zig"),
.@"codepoint-width" => @import("CodepointWidth.zig"),
.@"grapheme-break" => @import("GraphemeBreak.zig"),

View File

@@ -7,6 +7,7 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig");
pub const ScreenClone = @import("ScreenClone.zig");
pub const TerminalParser = @import("TerminalParser.zig");
pub const IsSymbol = @import("IsSymbol.zig");
pub const PageCompression = @import("PageCompression.zig");
test {
@import("std").testing.refAllDecls(@This());

View File

@@ -0,0 +1,576 @@
//! An allocation-free implementation of the raw LZ4 block format.
//!
//! LZ4 has two relevant layers: the block format describes the compressed
//! bytes, while the frame format adds headers, sizes, checksums, and support
//! for a stream of blocks. Terminal pages already have their own ownership
//! and metadata, so this implements only blocks. In particular, an encoded
//! block does not contain its decompressed size. The caller must store that
//! separately and provide an exactly sized buffer when decoding.
//!
//! A block is a series of sequences. Each non-final sequence has this shape:
//!
//! token | literal length extensions | literals | offset | match length extensions
//!
//! The token's high nibble contains the literal length and its low nibble
//! contains the match length minus four. A nibble value of 15 means that the
//! length continues in extension bytes at the corresponding point in the
//! sequence. Each extension byte adds to the length; a value of 255 means
//! another byte follows. The literal bytes are copied directly. The two-byte
//! little-endian offset then points backwards in the already decompressed
//! output to the match bytes.
//!
//! The last sequence is special: it contains literals only and ends directly
//! after them. The reference format also requires the last five input bytes to
//! be literals and the final match to begin at least twelve bytes before the
//! end of the input. The compressor observes these restrictions so its output
//! can be consumed by optimized LZ4 decoders which copy in larger units.
//!
//! Compression uses the standard fast LZ4 strategy: hash each four-byte input
//! sequence, remember only its most recent position, and test that one position
//! as a match candidate. This favors compression speed and a small, fixed
//! workspace over finding the best possible match. The implementation is
//! scalar Zig and allocates nothing; all input, output, and scratch memory is
//! supplied by the caller.
//!
//! Format reference:
//! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md
const std = @import("std");
/// Maximum input accepted by the reference LZ4 block API. Keeping the same
/// limit means `compressBound` fits in the integer sizes used by LZ4 callers
/// and gives us the same compatibility boundary as other implementations.
pub const max_input_size: usize = 0x7E000000;
/// Every LZ4 match represents at least four bytes. The token stores the number
/// of bytes beyond this minimum rather than the full match length.
const min_match = 4;
/// Number of bytes at the end of a conforming block which must remain literals.
const last_literals = 5;
/// A match may not begin in the final 12 bytes. This leaves enough room for the
/// minimum match and the required five trailing literals.
const match_find_limit = 12;
/// We retain one input position for each 12-bit hash. LZ4 refers to this as
/// memory usage 14 because the 4096 entries are four bytes each (16 KiB).
const hash_log = 12;
/// Multiplicative hash used by the reference LZ4 fast compressor. The high
/// `hash_log` bits provide the table index.
const hash_multiplier: u32 = 2_654_435_761;
/// Scratch memory used while compressing one block. Each entry stores an input
/// position plus one; zero therefore means that the hash has not been seen.
/// The table is reset by every call to `compress` and can be reused afterwards.
pub const HashTable = [1 << hash_log]u32;
/// Errors which can occur while encoding a block.
pub const CompressError = error{
/// The input exceeds the maximum size supported by the block compressor.
InputTooLarge,
/// The provided output buffer cannot hold the encoded block.
OutputTooSmall,
};
/// Errors which can occur while decoding a block.
pub const DecompressError = error{
/// The encoded block ended in the middle of a sequence.
TruncatedInput,
/// A match offset was zero or pointed before the produced output.
InvalidOffset,
/// A sequence would write beyond the provided output buffer.
OutputTooSmall,
/// The block ended before filling the exact-size output buffer.
OutputSizeMismatch,
};
/// Return the maximum number of bytes needed to encode `input_len` bytes.
///
/// Incompressible input is represented as one literal run. Every 255 literal
/// bytes can require one extension byte. The additional 16-byte margin covers
/// the token and the format's fixed overhead. Callers can allocate this amount
/// once and reuse it for any block no larger than `input_len`.
pub fn compressBound(input_len: usize) CompressError!usize {
if (input_len > max_input_size) return error.InputTooLarge;
return input_len + input_len / 255 + 16;
}
/// Compress `input` into a raw LZ4 block in `output`.
///
/// Returns the initialized length of `output`. The input and output buffers
/// must not overlap. `table` is scratch space and does not need to be
/// initialized by the caller; it is reset before use.
pub fn compress(
input: []const u8,
output: []u8,
table: *HashTable,
) CompressError!usize {
if (input.len > max_input_size) return error.InputTooLarge;
// Zero is reserved as "no previous position". Actual positions are stored
// plus one so that a match at input offset zero remains representable.
@memset(table, 0);
// `ip` is the current input position, `anchor` is the first literal not yet
// emitted, and `op` is the next output position. A successful match emits
// input[anchor..ip] as literals followed by the match, then moves both input
// positions to the end of that match.
var op: usize = 0;
var anchor: usize = 0;
var ip: usize = 0;
// LZ4's format leaves the final five input bytes as literals and starts
// the final match at least twelve bytes before the end. This is not
// required by our safe decoder, but makes blocks compatible with fast
// decoders that rely on the standard format restrictions.
const search_end = if (input.len >= match_find_limit)
input.len - match_find_limit
else
0;
while (input.len >= match_find_limit and ip <= search_end) {
// Hash the next four bytes and replace the table entry immediately.
// Hash collisions are expected, so equality is checked below before
// accepting the saved position as a match.
const sequence = readU32(input, ip);
const hash = hashSequence(sequence);
const previous_plus_one = table[hash];
table[hash] = @intCast(ip + 1);
if (previous_plus_one == 0) {
ip += 1;
continue;
}
var match_pos: usize = previous_plus_one - 1;
// Offsets are encoded as u16 and zero is invalid. Since match_pos is
// always earlier than ip here, checking the distance also rules out a
// value that cannot be represented in the block.
if (ip - match_pos > std.math.maxInt(u16) or
readU32(input, match_pos) != sequence)
{
ip += 1;
continue;
}
// Pull the match backwards into the current literal run. This is a
// cheap improvement that is particularly helpful around aligned cell
// records without requiring a hash chain.
while (ip > anchor and match_pos > 0 and
input[ip - 1] == input[match_pos - 1])
{
ip -= 1;
match_pos -= 1;
}
// We already compared the first four bytes. Continue byte-by-byte up
// to the point where the required last five literals begin.
var match_end = ip + min_match;
var candidate_end = match_pos + min_match;
const match_end_limit = input.len - last_literals;
while (match_end < match_end_limit and
input[match_end] == input[candidate_end])
{
match_end += 1;
candidate_end += 1;
}
try emitSequence(
output,
&op,
input[anchor..ip],
@intCast(ip - match_pos),
match_end - ip,
);
// The main loop jumps over the matched bytes rather than hashing every
// position within them. Seed one position near the end so an adjacent
// repeated record can still refer back into this match. The next loop
// iteration will then seed `match_end` normally.
if (match_end >= 2 and match_end - 2 + min_match <= input.len) {
const seed = match_end - 2;
table[hashSequence(readU32(input, seed))] = @intCast(seed + 1);
}
ip = match_end;
anchor = ip;
}
// Whatever remains after the last match is the terminal literal-only
// sequence. For short inputs this is also the only sequence in the block.
try emitLastLiterals(output, &op, input[anchor..]);
return op;
}
/// Decompress a raw LZ4 block into an exact-size output buffer.
///
/// Returns `output.len` on success. Both consuming all input and filling all
/// output are required. Raw LZ4 blocks do not carry their decoded size, so this
/// exact-size contract validates the size metadata maintained by the caller.
/// The input and output buffers must not overlap.
pub fn decompress(input: []const u8, output: []u8) DecompressError!usize {
// `ip` and `op` always identify the next unread input byte and the next
// unwritten output byte respectively.
var ip: usize = 0;
var op: usize = 0;
while (true) {
// A normal block ends after the literal bytes of its final sequence.
// This also accepts the empty block produced by our compressor, which
// consists of a zero token and no literals.
if (ip == input.len) {
if (op != output.len) return error.OutputSizeMismatch;
return op;
}
const token = input[ip];
ip += 1;
// The high nibble and any extension bytes describe the literal run.
// Bounds are checked before slicing so malformed blocks never cause a
// partial read or write.
const literal_len = try decodeLength(input, &ip, token >> 4);
if (literal_len > input.len - ip) return error.TruncatedInput;
if (literal_len > output.len - op) return error.OutputTooSmall;
@memcpy(output[op..][0..literal_len], input[ip..][0..literal_len]);
ip += literal_len;
op += literal_len;
// Ending immediately after the literals marks the final sequence. Any
// non-final sequence must continue with an offset and match length.
if (ip == input.len) {
if (op != output.len) return error.OutputSizeMismatch;
return op;
}
if (input.len - ip < 2) return error.TruncatedInput;
const offset = std.mem.readInt(u16, input[ip..][0..2], .little);
ip += 2;
if (offset == 0 or offset > op) return error.InvalidOffset;
// The token stores the match length minus the four-byte minimum. As
// with literals, a low nibble of 15 is extended by following bytes.
const encoded_match_len = try decodeLength(input, &ip, token & 0x0F);
const match_len = std.math.add(
usize,
encoded_match_len,
min_match,
) catch return error.OutputTooSmall;
if (match_len > output.len - op) return error.OutputTooSmall;
// Match copies are allowed to overlap. For an offset smaller than the
// match length, bytes written early in this loop become the source for
// later bytes. This is how a short pattern such as one space can expand
// into an arbitrarily long run.
const match_pos = op - offset;
for (0..match_len) |i| output[op + i] = output[match_pos + i];
op += match_len;
}
}
/// Emit one non-final sequence.
///
/// A sequence starts with a token, followed by optional literal length bytes,
/// the literals themselves, the two-byte offset, and optional match length
/// bytes. This function computes the complete size first so `OutputTooSmall`
/// is reported without partially writing a sequence.
fn emitSequence(
output: []u8,
op: *usize,
literals: []const u8,
offset: u16,
match_len: usize,
) CompressError!void {
std.debug.assert(match_len >= min_match);
std.debug.assert(offset > 0);
const encoded_match_len = match_len - min_match;
// One byte is always needed for the token and two for the offset. Each
// length may additionally need extension bytes after its token nibble.
const required = 1 +
encodedLengthBytes(literals.len) + literals.len +
2 + encodedLengthBytes(encoded_match_len);
if (required > output.len - op.*) return error.OutputTooSmall;
const token_pos = op.*;
op.* += 1;
// Lengths below 15 fit directly in their nibble. Larger values put 15 in
// the nibble and encode the remainder immediately after the token.
output[token_pos] = (@as(u8, @intCast(@min(literals.len, 15))) << 4) |
@as(u8, @intCast(@min(encoded_match_len, 15)));
// Literal length extensions precede the literals they describe.
if (literals.len >= 15) writeLength(output, op, literals.len - 15);
@memcpy(output[op.*..][0..literals.len], literals);
op.* += literals.len;
// Match length extensions follow the offset because this is where the
// decoder expects them in an LZ4 sequence.
std.mem.writeInt(u16, output[op.*..][0..2], offset, .little);
op.* += 2;
if (encoded_match_len >= 15)
writeLength(output, op, encoded_match_len - 15);
}
/// Emit the literal-only sequence which terminates every block.
///
/// There is no offset or match length after these bytes. As with
/// `emitSequence`, capacity is checked before modifying the output.
fn emitLastLiterals(
output: []u8,
op: *usize,
literals: []const u8,
) CompressError!void {
const required = 1 + encodedLengthBytes(literals.len) + literals.len;
if (required > output.len - op.*) return error.OutputTooSmall;
output[op.*] = @as(u8, @intCast(@min(literals.len, 15))) << 4;
op.* += 1;
if (literals.len >= 15) writeLength(output, op, literals.len - 15);
@memcpy(output[op.*..][0..literals.len], literals);
op.* += literals.len;
}
/// Return the number of extension bytes needed when a length is represented by
/// a token nibble plus zero or more bytes. An extended length always ends with
/// a byte below 255, so an exact multiple of 255 requires a final zero byte.
fn encodedLengthBytes(encoded_len: usize) usize {
if (encoded_len < 15) return 0;
return (encoded_len - 15) / 255 + 1;
}
/// Write the portion of a length which did not fit in the token nibble.
///
/// Each 255 byte means "add 255 and continue". The final byte is always less
/// than 255 and may be zero.
fn writeLength(output: []u8, op: *usize, length_: usize) void {
var length = length_;
while (length >= 255) {
output[op.*] = 255;
op.* += 1;
length -= 255;
}
output[op.*] = @intCast(length);
op.* += 1;
}
/// Decode a length from its token nibble and any following extension bytes.
/// `ip` is advanced past every consumed extension byte.
fn decodeLength(
input: []const u8,
ip: *usize,
nibble: u8,
) DecompressError!usize {
var length: usize = nibble;
if (nibble != 15) return length;
while (true) {
if (ip.* >= input.len) return error.TruncatedInput;
const value = input[ip.*];
ip.* += 1;
length = std.math.add(usize, length, value) catch
return error.TruncatedInput;
if (value != 255) return length;
}
}
/// Read the four-byte sequence used for match finding. Callers only use this
/// where at least four input bytes remain.
inline fn readU32(input: []const u8, pos: usize) u32 {
return std.mem.readInt(u32, input[pos..][0..4], .little);
}
/// Map a four-byte input sequence to its scratch-table slot.
inline fn hashSequence(sequence: u32) usize {
return @intCast((sequence *% hash_multiplier) >> (32 - hash_log));
}
/// Shared round-trip assertion used by the corpus-style tests below.
fn expectRoundTrip(input: []const u8) !void {
const testing = std.testing;
const bound = try compressBound(input.len);
const encoded = try testing.allocator.alloc(u8, bound);
defer testing.allocator.free(encoded);
const decoded = try testing.allocator.alloc(u8, input.len);
defer testing.allocator.free(decoded);
var table: HashTable = undefined;
const encoded_len = try compress(input, encoded, &table);
try testing.expectEqual(input.len, try decompress(
encoded[0..encoded_len],
decoded,
));
try testing.expectEqualSlices(u8, input, decoded);
}
test "compressBound" {
const testing = std.testing;
try testing.expectEqual(@as(usize, 16), try compressBound(0));
try testing.expectEqual(@as(usize, 272), try compressBound(255));
try testing.expectError(error.InputTooLarge, compressBound(max_input_size + 1));
}
test "literal-only compatibility vectors" {
const testing = std.testing;
var empty: [0]u8 = .{};
try testing.expectEqual(@as(usize, 0), try decompress(&.{0}, &empty));
var hello: [5]u8 = undefined;
try testing.expectEqual(@as(usize, 5), try decompress(
&.{ 0x50, 'h', 'e', 'l', 'l', 'o' },
&hello,
));
try testing.expectEqualStrings("hello", &hello);
var fifteen: [15]u8 = undefined;
var encoded: [17]u8 = undefined;
encoded[0] = 0xF0;
encoded[1] = 0;
@memset(encoded[2..], 'x');
_ = try decompress(&encoded, &fifteen);
try testing.expect(std.mem.allEqual(u8, &fifteen, 'x'));
}
test "overlapping match compatibility vector" {
const testing = std.testing;
// One literal 'a', followed by a four-byte match at distance one.
var output: [5]u8 = undefined;
try testing.expectEqual(@as(usize, 5), try decompress(
&.{ 0x10, 'a', 0x01, 0x00 },
&output,
));
try testing.expectEqualStrings("aaaaa", &output);
}
test "extended overlapping match compatibility vector" {
const testing = std.testing;
// One literal followed by a 274-byte match. The match extension is
// encoded as 255 + 0 after the low token nibble's initial 15 bytes.
var output: [275]u8 = undefined;
try testing.expectEqual(@as(usize, output.len), try decompress(
&.{ 0x1F, 'a', 0x01, 0x00, 0xFF, 0x00 },
&output,
));
try testing.expect(std.mem.allEqual(u8, &output, 'a'));
}
test "maximum match offset compatibility vector" {
const testing = std.testing;
const literal_len = std.math.maxInt(u16);
const extension_len = (literal_len - 15) / 255 + 1;
const encoded = try testing.allocator.alloc(
u8,
1 + extension_len + literal_len + 2,
);
defer testing.allocator.free(encoded);
const output = try testing.allocator.alloc(u8, literal_len + min_match);
defer testing.allocator.free(output);
var op: usize = 0;
encoded[op] = 0xF0;
op += 1;
writeLength(encoded, &op, literal_len - 15);
for (encoded[op..][0..literal_len], 0..) |*byte, i|
byte.* = @truncate(i);
op += literal_len;
std.mem.writeInt(u16, encoded[op..][0..2], std.math.maxInt(u16), .little);
op += 2;
try testing.expectEqual(encoded.len, op);
try testing.expectEqual(output.len, try decompress(encoded, output));
try testing.expectEqualSlices(u8, encoded[1 + extension_len ..][0..4], output[literal_len..]);
}
test "round trips boundary-sized inputs" {
const testing = std.testing;
const lengths = [_]usize{
0, 1, 3, 4, 5, 12, 15, 16, 19,
20, 254, 255, 256, 269, 270, 271, 510, 511,
512, 65_535, 65_536, 65_537,
};
for (lengths) |len| {
const buf = try testing.allocator.alloc(u8, len);
defer testing.allocator.free(buf);
for (buf, 0..) |*byte, i| byte.* = @truncate(i *% 31);
try expectRoundTrip(buf);
}
}
test "round trips compressible page-sized inputs" {
const testing = std.testing;
const page_len = 400 * 1024;
const zeros = try testing.allocator.alloc(u8, page_len);
defer testing.allocator.free(zeros);
@memset(zeros, 0);
try expectRoundTrip(zeros);
const structured = try testing.allocator.alloc(u8, page_len);
defer testing.allocator.free(structured);
@memset(structured, 0);
for (0..page_len / 8) |i| {
structured[i * 8] = @truncate(' ' + i % 95);
structured[i * 8 + 4] = @truncate((i / 80) % 16);
}
try expectRoundTrip(structured);
}
test "round trips deterministic random inputs" {
const testing = std.testing;
var prng = std.Random.DefaultPrng.init(0x4C5A_3401);
const random = prng.random();
for (0..256) |_| {
const len = random.uintLessThan(usize, 32 * 1024);
const input = try testing.allocator.alloc(u8, len);
defer testing.allocator.free(input);
random.bytes(input);
try expectRoundTrip(input);
}
}
test "compress reports short output" {
const testing = std.testing;
const input = "a terminal page needs enough output space";
var table: HashTable = undefined;
var output: [4]u8 = undefined;
try testing.expectError(
error.OutputTooSmall,
compress(input, &output, &table),
);
}
test "decompress rejects malformed blocks" {
const testing = std.testing;
var output: [32]u8 = undefined;
try testing.expectError(error.TruncatedInput, decompress(&.{0xF0}, &output));
try testing.expectError(error.TruncatedInput, decompress(&.{ 0x10, 'a', 1 }, output[0..5]));
try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 0, 0 }, output[0..5]));
try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 2, 0 }, output[0..5]));
try testing.expectError(error.OutputTooSmall, decompress(
&.{ 0x10, 'a', 1, 0 },
output[0..4],
));
try testing.expectError(error.OutputSizeMismatch, decompress(&.{0}, output[0..1]));
}
test "fuzz decompressor safety" {
return std.testing.fuzz({}, fuzzDecompress, .{});
}
fn fuzzDecompress(_: void, input: []const u8) !void {
var output: [4096]u8 = undefined;
_ = decompress(input, &output) catch {};
}

View File

@@ -83,6 +83,7 @@ test {
// Internals
_ = @import("bitmap_allocator.zig");
_ = @import("compress/lz4.zig");
_ = @import("hash_map.zig");
_ = @import("ref_counted_set.zig");
_ = @import("size.zig");