From 81ea80e4cfe069b7d6af5b9764b3ca9b4c70cad3 Mon Sep 17 00:00:00 2001 From: ruslandoga Date: Mon, 3 Aug 2026 16:39:00 +0300 Subject: [PATCH] Harden RowBinary boundary validation --- lib/ch/row_binary.ex | 185 ++++++++++++++---- lib/ch/types.ex | 128 +++++++++--- test/ch/connection_test.exs | 32 +++ test/ch/row_binary_decimal_test.exs | 129 ++++++++++++ test/ch/row_binary_float_test.exs | 14 ++ test/ch/row_binary_network_test.exs | 47 +++++ test/ch/row_binary_temporal_boundary_test.exs | 58 ++++++ test/ch/types_property_test.exs | 21 ++ test/ch/types_test.exs | 73 +++++++ 9 files changed, 623 insertions(+), 64 deletions(-) create mode 100644 test/ch/row_binary_decimal_test.exs create mode 100644 test/ch/row_binary_network_test.exs create mode 100644 test/ch/row_binary_temporal_boundary_test.exs create mode 100644 test/ch/types_property_test.exs diff --git a/lib/ch/row_binary.ex b/lib/ch/row_binary.ex index 1ff0d7f2..1230fabb 100644 --- a/lib/ch/row_binary.ex +++ b/lib/ch/row_binary.ex @@ -141,13 +141,9 @@ defmodule Ch.RowBinary do defp encoding_type({:nullable = n, t}), do: {n, encoding_type(t)} defp encoding_type({:low_cardinality, t}), do: encoding_type(t) - defp encoding_type({:decimal, p, s}) do - case decimal_size(p) do - 32 -> {:decimal32, s} - 64 -> {:decimal64, s} - 128 -> {:decimal128, s} - 256 -> {:decimal256, s} - end + defp encoding_type({:decimal, precision, scale} = type) do + validate_decimal_type!(precision, scale) + type end defp encoding_type({d, _scale} = t) @@ -165,6 +161,7 @@ defmodule Ch.RowBinary do defp encoding_type({:time64 = t, p}), do: {t, time_unit(p)} defp encoding_type({e, mappings}) when e in [:enum8, :enum16] do + Ch.Types.encode({e, mappings}) {e, Map.new(mappings)} end @@ -259,47 +256,52 @@ defmodule Ch.RowBinary do end end - for size <- [32, 64] do + for {size, max} <- [{32, 3.4028234663852886e38}, {64, 1.7976931348623157e308}] do type = :"f#{size}" - def encode(unquote(type), f) when is_number(f) do + def encode(unquote(type), f) when is_number(f) and f >= -unquote(max) and f <= unquote(max) do <> end def encode(unquote(type), nil), do: <<0::unquote(size)>> + + def encode(unquote(type), term) do + raise ArgumentError, "invalid Float#{unquote(size)}: #{inspect(term)}" + end end - def encode({:decimal, precision, scale}, decimal) do - type = - case decimal_size(precision) do - 32 -> :decimal32 - 64 -> :decimal64 - 128 -> :decimal128 - 256 -> :decimal256 - end + def encode({:decimal, precision, scale}, %Decimal{} = decimal) do + validate_decimal_type!(precision, scale) + size = decimal_size(precision) + coefficient = decimal_coefficient!(decimal, scale, size) - encode({type, scale}, decimal) + if abs(coefficient) >= Integer.pow(10, precision) do + raise ArgumentError, + "Decimal value #{Decimal.to_string(decimal)} exceeds precision #{precision}" + end + + encode_decimal!(decimal, coefficient, size, scale) + end + + def encode({:decimal, precision, scale}, nil) do + validate_decimal_type!(precision, scale) + <<0::size(decimal_size(precision))>> end for size <- [32, 64, 128, 256] do type = :"decimal#{size}" + precision = %{32 => 9, 64 => 18, 128 => 38, 256 => 76}[size] - def encode({unquote(type), scale} = t, %Decimal{sign: sign, coef: coef, exp: exp} = d) do - cond do - scale == -exp -> - i = sign * coef - <> - - exp >= 0 -> - i = sign * coef * Integer.pow(10, exp + scale) - <> - - true -> - encode(t, Decimal.round(d, scale)) - end + def encode({unquote(type), scale}, %Decimal{} = decimal) do + validate_decimal_scale!(unquote(type), scale, unquote(precision)) + coefficient = decimal_coefficient!(decimal, scale, unquote(size)) + encode_decimal!(decimal, coefficient, unquote(size), scale) end - def encode({unquote(type), _scale}, nil), do: <<0::unquote(size)>> + def encode({unquote(type), scale}, nil) do + validate_decimal_scale!(unquote(type), scale, unquote(precision)) + <<0::unquote(size)>> + end end def encode(:boolean, true), do: 1 @@ -347,29 +349,36 @@ defmodule Ch.RowBinary do def encode(:datetime, %NaiveDateTime{} = datetime) do {seconds, _micros} = NaiveDateTime.to_gregorian_seconds(datetime) - <> + encode_fixed_integer!(seconds - @epoch_gregorian_seconds, 32, :unsigned, "DateTime") end def encode(:datetime, %DateTime{} = datetime) do - <> + datetime + |> DateTime.to_unix(:second) + |> encode_fixed_integer!(32, :unsigned, "DateTime") end def encode(:datetime, nil), do: <<0::32>> def encode({:datetime64, time_unit}, %NaiveDateTime{} = datetime) do {seconds, micros} = NaiveDateTime.to_gregorian_seconds(datetime) - - <<(seconds - @epoch_gregorian_seconds) * time_unit + div(micros * time_unit, 1_000_000)::64-little-signed>> + ticks = (seconds - @epoch_gregorian_seconds) * time_unit + div(micros * time_unit, 1_000_000) + encode_fixed_integer!(ticks, 64, :signed, "DateTime64") end def encode({:datetime64, time_unit}, %DateTime{} = datetime) do - <> + datetime + |> DateTime.to_unix(time_unit) + |> encode_fixed_integer!(64, :signed, "DateTime64") end def encode({:datetime64, _time_unit}, nil), do: <<0::64>> def encode(:date, %Date{} = date) do - <> + date + |> Date.to_gregorian_days() + |> Kernel.-(@epoch_gregorian_days) + |> encode_fixed_integer!(16, :unsigned, "Date") end def encode(:date, nil), do: <<0::16>> @@ -421,13 +430,29 @@ defmodule Ch.RowBinary do def encode(:uuid, nil), do: <<0::128>> - def encode(:ipv4, {a, b, c, d}), do: [d, c, b, a] + def encode(:ipv4, {a, b, c, d}) + when is_integer(a) and a in 0..255 and is_integer(b) and b in 0..255 and is_integer(c) and + c in 0..255 and is_integer(d) and d in 0..255, + do: [d, c, b, a] + + def encode(:ipv4, {_, _, _, _} = address) do + raise ArgumentError, "invalid IPv4 address: #{inspect(address)}" + end + def encode(:ipv4, nil), do: <<0::32>> - def encode(:ipv6, {b1, b2, b3, b4, b5, b6, b7, b8}) do + def encode(:ipv6, {b1, b2, b3, b4, b5, b6, b7, b8}) + when is_integer(b1) and b1 in 0..65_535 and is_integer(b2) and b2 in 0..65_535 and + is_integer(b3) and b3 in 0..65_535 and is_integer(b4) and b4 in 0..65_535 and + is_integer(b5) and b5 in 0..65_535 and is_integer(b6) and b6 in 0..65_535 and + is_integer(b7) and b7 in 0..65_535 and is_integer(b8) and b8 in 0..65_535 do <> end + def encode(:ipv6, {_, _, _, _, _, _, _, _} = address) do + raise ArgumentError, "invalid IPv6 address: #{inspect(address)}" + end + def encode(:ipv6, <<_::128>> = encoded), do: encoded def encode(:ipv6, nil), do: <<0::128>> @@ -1542,6 +1567,86 @@ defmodule Ch.RowBinary do {:lists.reverse(rows), bin, {:cont, types_rest, row}} end + defp decimal_coefficient!(decimal, scale, size) do + unless is_integer(decimal.coef) do + raise ArgumentError, "ClickHouse Decimal values must be finite" + end + + %Decimal{sign: sign, coef: coefficient, exp: exponent} = decimal + shift = exponent + scale + max_digits = size |> then(&((1 <<< (&1 - 1)) - 1)) |> Integer.digits() |> length() + + cond do + coefficient == 0 -> + 0 + + shift >= 0 and length(Integer.digits(coefficient)) + shift > max_digits -> + raise ArgumentError, + "Decimal#{size}(#{scale}) value #{Decimal.to_string(decimal)} is out of range" + + shift >= 0 -> + sign * coefficient * Integer.pow(10, shift) + + -shift > length(Integer.digits(coefficient)) -> + 0 + + true -> + divisor = Integer.pow(10, -shift) + quotient = div(coefficient, divisor) + remainder = rem(coefficient, divisor) + rounded = if remainder * 2 >= divisor, do: quotient + 1, else: quotient + sign * rounded + end + end + + defp encode_decimal!(decimal, coefficient, size, scale) do + encode_fixed_integer!( + coefficient, + size, + :signed, + "Decimal#{size}(#{scale}) value #{Decimal.to_string(decimal)}" + ) + end + + defp validate_decimal_type!(precision, scale) + when is_integer(precision) and precision in 1..76 and is_integer(scale) and scale >= 0 and + scale <= precision, + do: :ok + + defp validate_decimal_type!(precision, scale) do + raise ArgumentError, + "invalid Decimal precision and scale: precision=#{inspect(precision)}, scale=#{inspect(scale)}" + end + + defp validate_decimal_scale!(_type, scale, precision) + when is_integer(scale) and scale >= 0 and scale <= precision, + do: :ok + + defp validate_decimal_scale!(type, scale, precision) do + raise ArgumentError, + "invalid #{decimal_type_name(type)} scale #{inspect(scale)}; expected 0..#{precision}" + end + + defp decimal_type_name(type) do + type |> Atom.to_string() |> String.replace_prefix("decimal", "Decimal") + end + + defp encode_fixed_integer!(integer, size, :unsigned, _type) + when is_integer(integer) and integer >= 0 and integer < 1 <<< size do + <> + end + + defp encode_fixed_integer!(integer, size, :signed, _type) + when is_integer(integer) and integer >= -(1 <<< (size - 1)) and + integer < 1 <<< (size - 1) do + <> + end + + defp encode_fixed_integer!(integer, size, signedness, type) do + raise ArgumentError, + "#{type} is out of range for a #{size}-bit #{signedness} integer: #{inspect(integer)}" + end + @compile inline: [decimal_size: 1] # https://clickhouse.com/docs/en/sql-reference/data-types/decimal/ defp decimal_size(precision) when is_integer(precision) do diff --git a/lib/ch/types.ex b/lib/ch/types.ex index d0433d08..3c03894b 100644 --- a/lib/ch/types.ex +++ b/lib/ch/types.ex @@ -419,7 +419,7 @@ defmodule Ch.Types do end defp decode([:string | stack], <>, acc) do - decode_string(rest, 0, rest, stack, acc) + decode_string(rest, 0, rest, [], stack, acc) end defp decode([:int | stack], <>, acc) do @@ -482,20 +482,28 @@ defmodule Ch.Types do defp build_type(:map = m, [v, k]), do: {m, k, v} defp build_type(:nullable = n, [t]), do: {n, t} defp build_type(:low_cardinality = l, [t]), do: {l, t} - defp build_type(:enum8 = e, mapping), do: {e, build_enum_mapping(mapping)} - defp build_type(:enum16 = e, mapping), do: {e, build_enum_mapping(mapping)} + defp build_type(:enum8 = e, mapping), do: {e, build_enum_mapping!(e, mapping)} + defp build_type(:enum16 = e, mapping), do: {e, build_enum_mapping!(e, mapping)} defp build_type(:simple_aggregate_function = saf, [t, f]), do: {saf, f, t} - defp build_type(:decimal32 = d, [s]), do: {d, s} - defp build_type(:decimal64 = d, [s]), do: {d, s} - defp build_type(:decimal128 = d, [s]), do: {d, s} - defp build_type(:decimal256 = d, [s]), do: {d, s} - defp build_type(:decimal = d, [s, p]), do: {d, p, s} + defp build_type(:decimal32 = d, [s]), do: build_decimal_type!(d, s, 9) + defp build_type(:decimal64 = d, [s]), do: build_decimal_type!(d, s, 18) + defp build_type(:decimal128 = d, [s]), do: build_decimal_type!(d, s, 38) + defp build_type(:decimal256 = d, [s]), do: build_decimal_type!(d, s, 76) + defp build_type(:decimal = d, [s, p]), do: validate_decimal_type!({d, p, s}) defp build_type(:time64 = t, [precision]), do: {t, precision} defp build_type(:variant = v, ts), do: {v, build_variant(ts)} defp build_type(:dynamic, _max_types), do: :dynamic - defp build_enum_mapping(mapping) do - mapping |> :lists.reverse() |> Enum.chunk_every(2) |> Enum.map(fn [k, v] -> {k, v} end) + defp build_enum_mapping!(type, mapping) do + mapping = + mapping |> :lists.reverse() |> Enum.chunk_every(2) |> Enum.map(fn [k, v] -> {k, v} end) + + validate_enum_mapping!(type, mapping) + end + + defp build_decimal_type!(type, scale, precision) do + validate_decimal_type!({:decimal, precision, scale}) + {type, scale} end defp build_tuple([type, name | rest]) when is_binary(name) do @@ -514,17 +522,34 @@ defmodule Ch.Types do Enum.sort_by(types, fn t -> IO.iodata_to_binary(encode(t)) end) end - # TODO '', \' + defp decode_string(<>, len, original, parts, stack, acc) do + part = :binary.part(original, 0, len) + string = parts |> then(&[part | &1]) |> Enum.reverse() |> IO.iodata_to_binary() + decode(stack, rest, [string | acc]) + end - defp decode_string(<>, len, original, stack, acc) do + defp decode_string(<>, len, original, parts, stack, acc) do part = :binary.part(original, 0, len) - decode(stack, rest, [:binary.copy(part) | acc]) + decoded = decode_escaped_char(escaped) + decode_string(rest, 0, rest, [decoded, part | parts], stack, acc) + end + + defp decode_string(<>, len, original, parts, stack, acc) do + decode_string(rest, len + utf8_size(u), original, parts, stack, acc) end - defp decode_string(<>, len, original, stack, acc) do - decode_string(rest, len + utf8_size(u), original, stack, acc) + defp decode_string(<<>>, _len, _original, _parts, _stack, _acc) do + raise ArgumentError, "unexpected end of quoted string while decoding" end + defp decode_escaped_char(?0), do: <<0>> + defp decode_escaped_char(?b), do: <<8>> + defp decode_escaped_char(?f), do: <<12>> + defp decode_escaped_char(?n), do: <<10>> + defp decode_escaped_char(?r), do: <<13>> + defp decode_escaped_char(?t), do: <<9>> + defp decode_escaped_char(char), do: <> + @compile inline: [utf8_size: 1] defp utf8_size(codepoint) when codepoint <= 0x7F, do: 1 defp utf8_size(codepoint) when codepoint <= 0x7FF, do: 2 @@ -609,13 +634,8 @@ defmodule Ch.Types do end def encode({:decimal, precision, scale}) do - [ - "Decimal(", - Integer.to_string(precision), - ", ", - Integer.to_string(scale), - ?) - ] + {:decimal, precision, scale} = validate_decimal_type!({:decimal, precision, scale}) + ["Decimal(", Integer.to_string(precision), ", ", Integer.to_string(scale), ?)] end def encode({:datetime, timezone}) when is_binary(timezone) do @@ -635,6 +655,7 @@ defmodule Ch.Types do end def encode({:enum8, mapping}) do + validate_enum_mapping!(:enum8, mapping) ["Enum8('", encode_mapping(mapping), ?)] end @@ -643,6 +664,7 @@ defmodule Ch.Types do end def encode({:enum16, mapping}) do + validate_enum_mapping!(:enum16, mapping) ["Enum16('", encode_mapping(mapping), ?)] end @@ -661,10 +683,68 @@ defmodule Ch.Types do defp encode_intersperse([] = empty, _separator), do: empty defp encode_mapping([{k, v}]) when is_binary(k) do - [k, "' = ", Integer.to_string(v)] + [encode_quoted_string(k), "' = ", Integer.to_string(v)] end defp encode_mapping([{k, v} | mapping]) when is_binary(k) do - [k, "' = ", Integer.to_string(v), ", '" | encode_mapping(mapping)] + [encode_quoted_string(k), "' = ", Integer.to_string(v), ", '" | encode_mapping(mapping)] + end + + defp encode_quoted_string(string) do + for <> do + case char do + 0 -> "\\0" + 8 -> "\\b" + 9 -> "\\t" + 10 -> "\\n" + 12 -> "\\f" + 13 -> "\\r" + ?' -> "\\'" + ?\\ -> "\\\\" + _ -> char + end + end end + + defp validate_decimal_type!({:decimal, precision, scale} = type) + when is_integer(precision) and precision in 1..76 and is_integer(scale) and scale >= 0 and + scale <= precision, + do: type + + defp validate_decimal_type!({:decimal, precision, scale}) do + raise ArgumentError, + "invalid Decimal precision and scale: precision=#{inspect(precision)}, scale=#{inspect(scale)}" + end + + defp validate_enum_mapping!(type, mapping) do + {name, min, max} = enum_metadata(type) + + if mapping == [] do + raise ArgumentError, "#{name} requires at least one mapping" + end + + unless is_list(mapping) and + Enum.all?(mapping, fn + {label, value} + when is_binary(label) and is_integer(value) and value >= min and value <= max -> + true + + _ -> + false + end) do + raise ArgumentError, "invalid #{name} mapping: #{inspect(mapping)}" + end + + labels = Enum.map(mapping, &elem(&1, 0)) + values = Enum.map(mapping, &elem(&1, 1)) + + if Enum.uniq(labels) != labels or Enum.uniq(values) != values do + raise ArgumentError, "#{name} mapping labels and values must be unique" + end + + mapping + end + + defp enum_metadata(:enum8), do: {"Enum8", -128, 127} + defp enum_metadata(:enum16), do: {"Enum16", -32_768, 32_767} end diff --git a/test/ch/connection_test.exs b/test/ch/connection_test.exs index fed245fb..c990e748 100644 --- a/test/ch/connection_test.exs +++ b/test/ch/connection_test.exs @@ -736,6 +736,38 @@ defmodule Ch.ConnectionTest do # TODO nil enum end + test "enum labels with quotes, backslashes, and controls round-trip", ctx do + type = + {:enum8, + [ + {"can't", 1}, + {"back\\slash", 2}, + {"comma, equals= parens()", 3}, + {"line\nbreak", 4}, + {"null\0byte", 5} + ]} + + encoded_type = type |> Ch.Types.encode() |> IO.iodata_to_binary() + + parameterize_query!( + ctx, + "CREATE TABLE t_enum_escaping(x #{encoded_type}) ENGINE Memory" + ) + + on_exit(fn -> Ch.Test.query("DROP TABLE t_enum_escaping") end) + + rows = Enum.map(elem(type, 1), fn {label, _value} -> [label] end) + + parameterize_query!(ctx, "INSERT INTO t_enum_escaping FORMAT RowBinary", rows, + types: [encoded_type] + ) + + assert parameterize_query!( + ctx, + "SELECT x FROM t_enum_escaping ORDER BY CAST(x, 'Int8')" + ).rows == rows + end + test "map", ctx do assert parameterize_query!( ctx, diff --git a/test/ch/row_binary_decimal_test.exs b/test/ch/row_binary_decimal_test.exs new file mode 100644 index 00000000..8a702a31 --- /dev/null +++ b/test/ch/row_binary_decimal_test.exs @@ -0,0 +1,129 @@ +defmodule Ch.RowBinaryDecimalTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Ch.RowBinary + + import Bitwise + + @decimal_types [decimal32: 32, decimal64: 64, decimal128: 128, decimal256: 256] + + property "valid coefficients preserve every signed bit pattern" do + check all {type, size, bytes, coefficient} <- decimal_coefficient() do + decimal = Decimal.new(coefficient) + encoded = type |> RowBinary.encode(decimal) |> IO.iodata_to_binary() + + assert encoded == bytes + assert [[decoded]] = RowBinary.decode_rows(encoded, [type]) + assert Decimal.equal?(decoded, decimal) + assert bit_size(encoded) == size + end + end + + test "rejects coefficients outside each storage width" do + for {name, size} <- @decimal_types do + type = {name, 0} + min = -(1 <<< (size - 1)) + max = (1 <<< (size - 1)) - 1 + + assert encoded_binary(type, Decimal.new(min)) == <> + assert encoded_binary(type, Decimal.new(max)) == <> + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode(type, Decimal.new(min - 1)) + end + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode(type, Decimal.new(max + 1)) + end + end + end + + test "regression: Decimal32 overflow never wraps or becomes zero" do + for value <- ["2147483648", "4294967296"] do + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode({:decimal32, 0}, Decimal.new(value)) + end + end + end + + test "rejects overflow introduced by scaling or rounding" do + type = {:decimal32, 2} + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode(type, Decimal.new("21474836.48")) + end + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode({:decimal32, 0}, Decimal.new("2147483647.5")) + end + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode({:decimal32, 0}, Decimal.new("-2147483648.5")) + end + end + + test "declared precision rejects coefficients that still fit the storage width" do + for precision <- [1, 9, 10, 18, 19, 38, 39, 76] do + max = Integer.pow(10, precision) - 1 + type = {:decimal, precision, 0} + + assert Decimal.equal?( + type + |> RowBinary.encode(Decimal.new(max)) + |> IO.iodata_to_binary() + |> then(&RowBinary.decode_rows(&1, [type])) + |> get_in([Access.at(0), Access.at(0)]), + Decimal.new(max) + ) + + assert_raise ArgumentError, ~r/exceeds precision/, fn -> + RowBinary.encode(type, Decimal.new(max + 1)) + end + + assert_raise ArgumentError, ~r/exceeds precision/, fn -> + RowBinary.encode(type, Decimal.new(-max - 1)) + end + end + end + + test "rejects non-finite decimals" do + for decimal <- [Decimal.new("NaN"), Decimal.new("Infinity"), Decimal.new("-Infinity")] do + assert_raise ArgumentError, "ClickHouse Decimal values must be finite", fn -> + RowBinary.encode({:decimal32, 0}, decimal) + end + end + end + + test "rejects invalid precision and scale before encoding rows" do + for type <- [ + {:decimal, 0, 0}, + {:decimal, 77, 0}, + {:decimal, 9, -1}, + {:decimal, 9, 10}, + {:decimal32, 10}, + {:decimal64, 19}, + {:decimal128, 39}, + {:decimal256, 77} + ] do + assert_raise ArgumentError, ~r/invalid Decimal/, fn -> + RowBinary.encode_rows([[Decimal.new(0)]], [type]) + end + end + end + + defp decimal_coefficient do + gen all {name, size} <- member_of(@decimal_types), + bytes <- binary(length: div(size, 8)) do + unsigned = :binary.decode_unsigned(bytes, :little) + signed_limit = 1 <<< (size - 1) + coefficient = if unsigned >= signed_limit, do: unsigned - (1 <<< size), else: unsigned + + {{name, 0}, size, bytes, coefficient} + end + end + + defp encoded_binary(type, decimal) do + type |> RowBinary.encode(decimal) |> IO.iodata_to_binary() + end +end diff --git a/test/ch/row_binary_float_test.exs b/test/ch/row_binary_float_test.exs index 45caf881..921eee58 100644 --- a/test/ch/row_binary_float_test.exs +++ b/test/ch/row_binary_float_test.exs @@ -88,6 +88,20 @@ defmodule Ch.RowBinaryFloatTest do assert message =~ "query parameter" end + test "RowBinary rejects values that overflow their float width" do + for value <- [3.5e38, -3.5e38, 1.0e300, -1.0e300] do + assert_raise ArgumentError, ~r/invalid Float32/, fn -> + RowBinary.encode(:f32, value) + end + end + + huge_integer = Integer.pow(10, 1_000) + + assert_raise ArgumentError, ~r/invalid Float64/, fn -> + RowBinary.encode(:f64, huge_integer) + end + end + property "RowBinary float inserts round-trip through ClickHouse", %{pool: pool} do Ch.Test.query(""" CREATE TABLE row_binary_float_property ( diff --git a/test/ch/row_binary_network_test.exs b/test/ch/row_binary_network_test.exs new file mode 100644 index 00000000..d00c38df --- /dev/null +++ b/test/ch/row_binary_network_test.exs @@ -0,0 +1,47 @@ +defmodule Ch.RowBinaryNetworkTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Ch.RowBinary + + property "IPv4 octets round-trip without reordering or truncation" do + check all octets <- fixed_list(List.duplicate(integer(0..255), 4)) do + address = List.to_tuple(octets) + assert RowBinary.decode_rows(encoded(:ipv4, address), [:ipv4]) == [[address]] + end + end + + property "IPv6 segments preserve every 16-bit value" do + check all bytes <- binary(length: 16) do + address = + bytes |> :binary.bin_to_list() |> Enum.chunk_every(2) |> Enum.map(&decode_segment/1) + + address = List.to_tuple(address) + + assert encoded(:ipv6, address) == bytes + assert RowBinary.decode_rows(bytes, [:ipv6]) == [[address]] + end + end + + test "rejects invalid IPv4 octets instead of returning invalid iodata" do + for invalid <- [-1, 256, "1", nil] do + assert_raise ArgumentError, ~r/invalid IPv4/, fn -> + RowBinary.encode(:ipv4, {127, 0, 0, invalid}) + end + end + end + + test "rejects invalid IPv6 segments instead of truncating them" do + for invalid <- [-1, 65_536, "1", nil] do + assert_raise ArgumentError, ~r/invalid IPv6/, fn -> + RowBinary.encode(:ipv6, {0, 0, 0, 0, 0, 0, 0, invalid}) + end + end + end + + defp decode_segment([high, low]), do: high * 256 + low + + defp encoded(type, value) do + type |> RowBinary.encode(value) |> IO.iodata_to_binary() + end +end diff --git a/test/ch/row_binary_temporal_boundary_test.exs b/test/ch/row_binary_temporal_boundary_test.exs new file mode 100644 index 00000000..62afb750 --- /dev/null +++ b/test/ch/row_binary_temporal_boundary_test.exs @@ -0,0 +1,58 @@ +defmodule Ch.RowBinaryTemporalBoundaryTest do + use ExUnit.Case, async: true + + alias Ch.RowBinary + + import Bitwise + + @epoch ~D[1970-01-01] + + test "Date accepts its UInt16 boundaries without wrapping" do + max_date = Date.add(@epoch, (1 <<< 16) - 1) + + assert RowBinary.decode_rows(encoded(:date, @epoch), [:date]) == [[@epoch]] + assert RowBinary.decode_rows(encoded(:date, max_date), [:date]) == [[max_date]] + + for date <- [Date.add(@epoch, -1), Date.add(max_date, 1)] do + assert_raise ArgumentError, ~r/out of range/, fn -> RowBinary.encode(:date, date) end + end + end + + test "DateTime accepts its UInt32 boundaries without wrapping" do + max_seconds = (1 <<< 32) - 1 + epoch = DateTime.from_unix!(0) + max_datetime = DateTime.from_unix!(max_seconds) + + assert RowBinary.decode_rows(encoded(:datetime, epoch), [:datetime]) == + [[DateTime.to_naive(epoch)]] + + assert RowBinary.decode_rows(encoded(:datetime, max_datetime), [:datetime]) == + [[DateTime.to_naive(max_datetime)]] + + for datetime <- [DateTime.from_unix!(-1), DateTime.from_unix!(max_seconds + 1)] do + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode(:datetime, datetime) + end + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode(:datetime, DateTime.to_naive(datetime)) + end + end + end + + test "DateTime64 rejects tick counts outside Int64" do + datetime = ~U[9999-12-31 23:59:59.999999Z] + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode({:datetime64, 1_000_000_000}, datetime) + end + + assert_raise ArgumentError, ~r/out of range/, fn -> + RowBinary.encode({:datetime64, 1_000_000_000}, DateTime.to_naive(datetime)) + end + end + + defp encoded(type, value) do + type |> RowBinary.encode(value) |> IO.iodata_to_binary() + end +end diff --git a/test/ch/types_property_test.exs b/test/ch/types_property_test.exs new file mode 100644 index 00000000..e01d3f3b --- /dev/null +++ b/test/ch/types_property_test.exs @@ -0,0 +1,21 @@ +defmodule Ch.TypesPropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Ch.Types + + property "enum type expressions round-trip generated hostile labels" do + check all labels <- uniq_list_of(enum_label(), min_length: 1, max_length: 8) do + type = {:enum16, labels |> Enum.with_index() |> Enum.map(fn {label, i} -> {label, i} end)} + assert type |> Types.encode() |> IO.iodata_to_binary() |> Types.decode() == type + end + end + + defp enum_label do + codepoints = Enum.to_list(?a..?z) ++ [0, 8, 9, 10, 12, 13, ?', ?\\, ?,, ?=, ?(, ?), ?\s] + + gen all chars <- list_of(member_of(codepoints), max_length: 24) do + List.to_string(chars) + end + end +end diff --git a/test/ch/types_test.exs b/test/ch/types_test.exs index d3fae2aa..a75834a5 100644 --- a/test/ch/types_test.exs +++ b/test/ch/types_test.exs @@ -200,6 +200,27 @@ defmodule Ch.TypesTest do end describe "encode/1" do + test "escapes enum labels and decodes them losslessly" do + for type <- [ + {:enum8, + [ + {"", 0}, + {"can't", 1}, + {"back\\slash", 2}, + {"comma, equals= parens()", 3}, + {"é€𐍈", 4}, + {"controls\0\b\t\n\f\r", 5} + ]}, + {:enum16, [{"quote' and slash\\", -1}, {"plain", 2}]} + ] do + encoded = type |> encode() |> IO.iodata_to_binary() + + assert encoded =~ "\\'" + assert encoded =~ "\\\\" + assert decode(encoded) == type + end + end + test "rejects empty enum mappings" do assert_raise ArgumentError, "Enum8 requires at least one mapping", fn -> encode({:enum8, []}) @@ -209,5 +230,57 @@ defmodule Ch.TypesTest do encode({:enum16, []}) end end + + test "rejects invalid decimal definitions" do + for type <- [ + {:decimal, 0, 0}, + {:decimal, 77, 0}, + {:decimal, 9, -1}, + {:decimal, 9, 10}, + {:decimal32, 10}, + {:decimal64, 19}, + {:decimal128, 39}, + {:decimal256, 77} + ] do + assert_raise ArgumentError, ~r/invalid Decimal precision and scale/, fn -> + encode(type) + end + end + + for type <- ["Decimal(0, 0)", "Decimal(77, 0)", "Decimal(9, -1)", "Decimal32(10)"] do + assert_raise ArgumentError, ~r/invalid Decimal precision and scale/, fn -> + decode(type) + end + end + end + + test "rejects invalid or ambiguous enum mappings" do + for type <- [ + {:enum8, [{"too-large", 128}]}, + {:enum8, [{"too-small", -129}]}, + {:enum16, [{"too-large", 32_768}]}, + {:enum16, [{"too-small", -32_769}]}, + {:enum8, [{"not-an-integer", "1"}]}, + {:enum8, [{:not_a_string, 1}]} + ] do + assert_raise ArgumentError, ~r/invalid Enum/, fn -> encode(type) end + end + + for type <- [ + "Enum8('too-large' = 128)", + "Enum16('too-small' = -32769)", + "Enum8('same' = 1, 'same' = 2)", + "Enum16('one' = 1, 'two' = 1)" + ] do + assert_raise ArgumentError, fn -> decode(type) end + end + + for type <- [ + {:enum8, [{"same", 1}, {"same", 2}]}, + {:enum16, [{"one", 1}, {"two", 1}]} + ] do + assert_raise ArgumentError, ~r/must be unique/, fn -> encode(type) end + end + end end end