ClickHouse-Encoder
view release on metacpan or search on metacpan
xt/fuzz-decoder.t view on Meta::CPAN
#!/usr/bin/env perl
# Decoder fuzz: feed random bytes / structurally-corrupted Native
# buffers to decode_block and assert the process never segfaults.
# The only acceptable outcomes are: clean decode or a Perl-level
# croak. ASAN-clean too (pair with xt/asan.t for tighter coverage).
#
# Skipped unless RELEASE_TESTING=1. Tweak FUZZ_DECODER_ITERS to scale.
use strict;
use warnings;
use Test::More;
use lib 'blib/lib', 'blib/arch';
use ClickHouse::Encoder;
plan skip_all => 'set RELEASE_TESTING=1 to run decoder fuzz'
unless $ENV{RELEASE_TESTING};
# Bound address space so a pathological decode allocation gets killed
# rather than driving the host into swap. RLIMIT_AS counts ALL virtual
# memory including the perl interpreter, shared libraries, and Perl's
# own arena slabs - a freshly-loaded perl already uses 100-300 MiB of
# VA on Linux. Set a generous cap (2 GiB); any legitimate decoder
# allocation stays well under it. The cap is what catches a pathological
# `Newx(N, ...)` for a corrupted N before it drives the host into swap.
my $cap_ok = eval {
require BSD::Resource;
BSD::Resource::setrlimit(
BSD::Resource::RLIMIT_AS(),
2 * 1024 * 1024 * 1024,
2 * 1024 * 1024 * 1024);
1;
};
plan skip_all => 'BSD::Resource not available; skipping unbounded fuzz'
unless $cap_ok;
srand($ENV{FUZZ_DECODER_SEED} // 19937);
# 150 is the empirical safe ceiling under RLIMIT_AS=2 GiB with the
# default seed: enough iterations to exercise each seed shape several
# times without the cumulative Perl arena footprint plus a worst-case
# bounded allocation tripping the cap. Bump FUZZ_DECODER_ITERS in CI
# when adding more aggressive fuzz patterns.
my $iters = $ENV{FUZZ_DECODER_ITERS} // 150;
# Build a known-good seed block for each interesting type, then
# mutate. Both fully-random and surgically-perturbed inputs exercise
# different decoder paths.
my @seeds;
# Generate seed buffers we can corrupt later.
for my $type (
'Int32', 'String', 'Array(Int64)', 'Nullable(String)',
'Tuple(Int32, String)', 'Map(String, Int32)',
'LowCardinality(String)', 'Variant(Int32, String)',
'JSON', 'Dynamic',
) {
my $enc = eval { ClickHouse::Encoder->new(columns => [['c', $type]]) };
next unless $enc;
my @rows;
if ($type eq 'JSON') {
@rows = ([{a => 1, b => "x"}], [{tags => [1,2,3]}], [undef]);
} elsif ($type eq 'Dynamic') {
@rows = ([1], ["x"], [[1,2]], [undef]);
} elsif ($type =~ /^Variant/) {
@rows = ([[0, 42]], [[1, "hi"]], [undef]);
} elsif ($type =~ /Map/) {
@rows = ([{a=>1,b=>2}], [{}]);
} elsif ($type =~ /Tuple/) {
@rows = ([[1,"x"]], [[2,"y"]]);
} elsif ($type =~ /Array/) {
@rows = ([[1,2,3]], [[]]);
} elsif ($type eq 'Nullable(String)') {
@rows = (['x'], [undef], ['y']);
} else {
@rows = ([1], [2], [3]);
}
my $bytes = eval { $enc->encode(\@rows) };
push @seeds, $bytes if defined $bytes && length $bytes;
}
ok(@seeds, 'have ' . scalar(@seeds) . ' seed buffers');
my $crashed = 0;
my $survived = 0;
# Per-iteration size cap: skip fuzz buffers whose first varints would
# claim more memory than makes sense. Cheap pre-screen so we never
# even invoke decode_block on obvious OOM bait. Decoder hardening
# catches the rest; this is belt-and-suspenders.
sub _looks_safe {
my $b = shift;
return 0 if length($b) < 2;
my @bytes = unpack 'C*', substr($b, 0, 20);
my ($v, $shift) = (0, 0);
for my $byte (@bytes) {
$v |= ($byte & 0x7f) << $shift;
return $v < 1024 * 1024 if !($byte & 0x80);
$shift += 7;
return 0 if $shift >= 64;
}
return 0;
}
for my $i (1 .. $iters) {
my $bytes;
if ($i % 3 == 0) {
# Fully random bytes
my $n = int(rand 200) + 1;
$bytes = join '', map chr(int rand 256), 1..$n;
} else {
# Pick a seed, flip a few bytes / chop the end / inject zeros.
my $seed = $seeds[int rand @seeds];
$bytes = $seed;
my $mode = int rand 4;
if ($mode == 0) {
# Chop the buffer to a random prefix.
$bytes = substr($bytes, 0, int rand length($bytes));
}
elsif ($mode == 1) {
# Flip ~5% of bytes.
my $n = int(length($bytes) * 0.05) + 1;
for (1..$n) {
my $pos = int rand length($bytes);
substr($bytes, $pos, 1, chr(int rand 256));
}
}
elsif ($mode == 2) {
# Insert junk bytes mid-buffer.
my $pos = int rand length($bytes);
my $junk = join '', map chr(int rand 256), 1..int(rand 16)+1;
substr($bytes, $pos, 0, $junk);
}
else {
# Append trailing garbage.
$bytes .= join '', map chr(int rand 256), 1..int(rand 32);
}
}
next unless _looks_safe($bytes);
# Wrap in a fresh sub call so the decoder's return value and any
# mortal SVs created during decode are reclaimed before the next
# iteration. Without this, mortals can pile up across hundreds
# of iterations until the test process's heap is exhausted.
eval { _try_decode($bytes); 1 };
# "Out of memory" / "Killed" from RLIMIT_AS aborts the test process
# and never returns to this branch - but catching the symbolic text
# in $@ surfaces a real allocation gap if the eval somehow recovered
# without the process dying.
if ($@ && $@ =~ /Segmentation|stack overflow|Out of memory|Killed/i) {
$crashed++;
diag "CRASH at iter $i: $@";
} else {
$survived++;
}
}
sub _try_decode {
ClickHouse::Encoder->decode_block($_[0]);
return;
}
is($crashed, 0, "$iters fuzz iterations: 0 crashes ($survived survived)");
# ---- structured adversarial blocks ------------------------------------
# Byte-mutating a valid seed cannot reach these shapes: a mutated wire
# count only matters if the bytes after it still parse, so a bumped
# "ntypes" lands on a garbage type name and is rejected before the count
# is used, and no mutation of a shallow seed yields a deeply nested type
# expression. Both hid real bugs from this file's mutation loop, so
# construct them deliberately.
{
my $vi = sub { my $v = shift; my $o=''; while ($v >= 0x80) { $o .= chr(($v & 0x7f)|0x80); $v >>= 7 } $o . chr($v) };
my $ls = sub { $vi->(length $_[0]) . $_[0] };
my $u64 = sub { pack 'V2', $_[0] & 0xFFFFFFFF, ($_[0] >> 32) & 0xFFFFFFFF };
my @adversarial;
# Dynamic / JSON prefixes claiming far more variant types than there
# are distinct kinds (the wire count indexed a 9-entry stack array).
for my $n (2, 9, 10, 64, 300) {
for my $name (qw(Bool Int64 String Array(Int64))) {
push @adversarial, [
"Dynamic prefix, $n x $name",
$vi->(1) . $vi->(1) . $ls->('d') . $ls->('Dynamic')
. $u64->(1) . $vi->(0) . $vi->($n)
. ($ls->($name) x $n) . $u64->(0)
. chr(int rand 256) . ("\0" x 64) ];
}
push @adversarial, [
"JSON path prefix, $n x Bool",
$vi->(1) . $vi->(1) . $ls->('j') . $ls->('JSON')
. $u64->(0) . $vi->(0) . $vi->(1) . $ls->('a')
. $u64->(1) . $vi->(0) . $vi->($n)
. ($ls->('Bool') x $n) . $u64->(0)
. chr(int rand 256) . ("\0" x 64) ];
}
# Deeply nested type expressions read off the wire.
for my $depth (10, 100, 101, 5_000, 50_000) {
for my $shape (['Array(', ')', 'Int32'],
['Nullable(', ')', 'Int32'],
['Tuple(', ')', 'Int32'],
['Map(String, ', ')', 'Int32'],
['LowCardinality(', ')', 'String']) {
my ($open, $close, $leaf) = @$shape;
push @adversarial, [
"nested $open x $depth",
$vi->(1) . $vi->(0) . $ls->('c')
. $ls->(($open x $depth) . $leaf . ($close x $depth)) ];
}
}
# Degenerate / contradictory block headers.
push @adversarial,
['ncols=0 nrows=huge', $vi->(0) . $vi->(1_000_000)],
['ncols=huge nrows=0', $vi->(1_000_000) . $vi->(0)],
['ncols=1 nrows=huge', $vi->(1) . $vi->(2**40) . $ls->('c') . $ls->('Int32')],
['LowCardinality dict_n huge',
$vi->(1) . $vi->(1) . $ls->('c') . $ls->('LowCardinality(String)')
. $u64->(1) . $u64->(1 << 9) . $u64->(2**40)],
['Variant disc out of range',
$vi->(1) . $vi->(1) . $ls->('c') . $ls->('Variant(Int32, String)')
( run in 0.628 second using v1.01-cache-2.11-cpan-f03e8824b8d )