ClickHouse-Encoder
view release on metacpan or search on metacpan
t/hardening.t view on Meta::CPAN
my $enc = ClickHouse::Encoder->new(columns => [['d', 'Dynamic']]);
my $bytes = $enc->encode([[1], ['x'], [[1, 2]], [undef], [1.5]]);
my $blk = ClickHouse::Encoder->decode_block($bytes);
is($blk->{nrows}, 5, 'multi-kind Dynamic column still round-trips');
is($blk->{columns}[0]{values}[1], 'x', 'Dynamic string value survives');
}
# ---- 2. Type nesting depth --------------------------------------------
{
my $deep = sub { my $d = shift; ('Array(' x $d) . 'Int32' . (')' x $d) };
ok(eval { ClickHouse::Encoder->new(columns => [['c', $deep->(50)]]); 1 },
'nesting well inside the limit is accepted');
eval { ClickHouse::Encoder->new(columns => [['c', $deep->(200)]]) };
like($@, qr/Type nesting too deep/, 'over-deep type from new() croaks');
# The one that mattered: the type string comes off the wire.
my $blk = varint(1) . varint(0) . lenpfx('c') . lenpfx($deep->(20_000));
eval { ClickHouse::Encoder->decode_block($blk) };
like($@, qr/Type nesting too deep/,
'over-deep type from a Native block croaks instead of segfaulting');
}
# ---- 3. No leak on the decode error path ------------------------------
SKIP: {
skip 'RSS check needs Linux /proc', 1 unless -r '/proc/self/status';
# Under ASAN this measures the quarantine (freed blocks held back
# rather than reused), which climbs to its cap and plateaus.
# LeakSanitizer is the right tool there.
skip 'RSS growth measures the ASAN quarantine, not a leak', 1
if ($ENV{LD_PRELOAD} // '') =~ /libasan|libclang_rt\.asan/
|| ($ENV{ASAN_OPTIONS} // '') ne '';
my $rss = sub {
open my $fh, '<', '/proc/self/status' or return 0;
while (<$fh>) { return $1 if /^VmRSS:\s+(\d+)/ }
return 0;
};
# 199 well-formed String values then the buffer ends, so decode_column
# holds the column AV, 199 SVs and a TypeInfo when it croaks.
my $good = join '', map { lenpfx("value-$_-padding-padding") } 1 .. 199;
my $blk = varint(1) . varint(200) . lenpfx('s') . lenpfx('String') . $good;
eval { ClickHouse::Encoder->decode_block($blk) };
like($@, qr/buffer truncated/, 'truncated block croaks as expected');
eval { ClickHouse::Encoder->decode_block($blk) } for 1 .. 5_000; # warm up
my $before = $rss->();
eval { ClickHouse::Encoder->decode_block($blk) } for 1 .. 20_000;
my $growth = $rss->() - $before;
# Allow a few MB of arena noise; a per-iteration leak blows past it.
cmp_ok($growth, '<', 8 * 1024,
"no leak over 20k caught decode failures (RSS grew ${growth} kB)")
or diag("leaked roughly " . int($growth * 1024 / 20_000) . " bytes per failure");
}
# ---- 4. Out-of-range values croak instead of wrapping -----------------
{
my $enc = ClickHouse::Encoder->new(columns => [['d', 'Date']]);
# Boundaries stay valid.
ok(eval { $enc->encode([['1970-01-01'], ['2149-06-06']]); 1 },
'Date range endpoints still encode');
for my $bad ('1969-12-31', '2200-01-01', 70_000, -1) {
eval { $enc->encode([[$bad]]) };
like($@, qr/Date out of range/, "Date '$bad' croaks (was silently wrapping)");
}
my $d32 = ClickHouse::Encoder->new(columns => [['d', 'Date32']]);
ok(eval { $d32->encode([['1969-12-31'], ['2200-01-01']]); 1 },
'Date32 still accepts dates outside the Date range');
eval { $d32->encode([[2**40]]) };
like($@, qr/Date32 out of range/, 'Date32 beyond Int32 croaks');
}
{
my $enc = ClickHouse::Encoder->new(columns => [['t', 'DateTime64(9)']]);
ok(eval { $enc->encode([['2262-04-11 23:47:16']]); 1 },
'DateTime64(9) accepts the last representable second');
for my $bad ('2263-01-01 00:00:00', '9999-12-31 23:59:59') {
eval { $enc->encode([[$bad]]) };
like($@, qr/DateTime64\(9\) out of range/,
"DateTime64(9) '$bad' croaks (was wrapping negative)");
}
# Lower precision still reaches far-future dates.
my $p3 = ClickHouse::Encoder->new(columns => [['t', 'DateTime64(3)']]);
ok(eval { $p3->encode([['9999-12-31 23:59:59']]); 1 },
'DateTime64(3) still handles year 9999');
}
{
eval { ClickHouse::Encoder->new(
columns => [['e', "Enum8('a' = 18446744073709551617)"]]) };
like($@, qr/out of range for Enum8/,
'Enum value that overflowed a C long is rejected');
ok(eval { ClickHouse::Encoder->new(
columns => [['e', "Enum8('a' = 1, 'b' = -128, 'c' = 127)"]]); 1 },
'Enum8 boundary values still accepted');
eval { ClickHouse::Encoder->new(columns => [['f', 'FixedString(99999999999)']]) };
like($@, qr/FixedString/, 'FixedString length that overflowed atoi is rejected');
}
# ---- 4b. JSON object nesting depth ------------------------------------
# flatten_json_hash recurses once per level. Wire data reaches it too: a
# path name with N dotted segments decodes to an N-deep hash, so
# re-encoding a hostile block recursed just as far and blew the C stack.
{
my $enc = ClickHouse::Encoder->new(columns => [['j', 'JSON']]);
my $deep = sub { my $h = 'leaf'; $h = { n => $h } for 1 .. $_[0]; $h };
ok(eval { $enc->encode([[ $deep->(400) ]]); 1 },
'JSON nesting inside the limit encodes');
eval { $enc->encode([[ $deep->(100_000) ]]) };
like($@, qr/nesting deeper than/,
'over-deep JSON object croaks instead of segfaulting');
# The wire route: one path of N dotted segments -> N-deep hash.
my $path = join '.', ('a') x 100_000;
my $col = u64(0) . varint(0) . varint(1) . lenpfx($path)
. u64(1) . varint(0) . varint(1) . lenpfx('Int64') . u64(0)
. chr(0) . u64(7) . u64(0);
my $blk = varint(1) . varint(1) . lenpfx('j') . lenpfx('JSON') . $col;
my $decoded = eval { ClickHouse::Encoder->decode_block($blk) };
ok($decoded, 'deeply dotted JSON path decodes');
eval { $enc->encode([[ $decoded->{columns}[0]{values}[0] ]]) };
like($@, qr/nesting deeper than/,
're-encoding a hostile block croaks instead of segfaulting');
}
# ---- 5. Corrupt block header ------------------------------------------
{
my $blk = varint(0) . varint(1_000_000);
eval { ClickHouse::Encoder->decode_block($blk) };
like($@, qr/exceeds remaining buffer/,
'a row count with no bytes behind it is rejected');
# A zero-column schema is degenerate but legal, and do_encode writes
# its row count independently - the decoder must still read back what
# this encoder emits rather than rejecting ncols==0 outright.
my $empty = ClickHouse::Encoder->new(columns => [])->encode([]);
my $back = eval { ClickHouse::Encoder->decode_block($empty) };
is($@, '', 'zero-column block from our own encoder still decodes')
or diag($@);
is($back->{ncols}, 0, 'zero-column block reports ncols=0');
}
# ---- 6. Endpoint host validation --------------------------------------
{
for my $bad ('evil.com@internal.db', 'host:8123', 'a/b', "x\ny", '') {
eval { ClickHouse::Encoder::_check_endpoint({ host => $bad }) };
like($@, qr/host must not contain/, "host '$bad' rejected");
}
for my $good ('localhost', '10.0.0.1', 'db.example.com', '[::1]',
'[2001:db8::1]') {
ok(eval { ClickHouse::Encoder::_check_endpoint({ host => $good }); 1 },
"host '$good' accepted");
}
my ($url) = ClickHouse::Encoder::_http_url_headers('select 1', host => '[::1]');
like($url, qr{\Qhttp://[::1]:8123/\E}, 'IPv6 literal keeps its brackets in the URL');
}
# ---- 7. decompress_native_block size cap ------------------------------
{
my $payload = 'x' x 4;
# tag 0x02 (stored), compressed_size = 9 + 4, uncompressed_size = 4 GiB - 1
my $hdr = pack('C V V', 0x02, 9 + length($payload), 0xFFFFFFFF);
my $framed = ("\0" x 16) . $hdr . $payload;
eval { ClickHouse::Encoder->decompress_native_block(
$framed, hasher => undef) };
like($@, qr/exceeds max_size/,
'block claiming a 4 GiB uncompressed size is rejected');
# Round-trip through the real framing still works.
my $enc = ClickHouse::Encoder->new(columns => [['i', 'Int32']]);
my $bytes = $enc->encode([[1], [2]]);
my $comp = ClickHouse::Encoder->compress_native_block($bytes, mode => 'none');
is(ClickHouse::Encoder->decompress_native_block($comp), $bytes,
'normal compressed block still round-trips');
}
done_testing();
( run in 3.275 seconds using v1.01-cache-2.11-cpan-a49fcb8fa48 )