~bigbes/tarantool

tarantool-protobuf

2180b76a781b1f114fbd1d634fe37c57788387df — Eugene Blikh 3 months ago a2f1209
test: regression tests for each conformance fix in this branch

Pins each fix locally so the bug is caught without the Docker conformance
runner. Grouped by fix; multiple tests per fix because each fix touches
several code paths (per scalar type, per shape).

Fix 1 — varint 32-bit truncation (0c13fd7), 7 tests:
  int32 high-bits → 0, int32 sign-extend, uint32 low-32, sint32 zigzag
  after truncation, enum singular, packed-repeated int32 element-wise,
  repeated-scalar-selects-last after truncation.

Fix 2 — wire types 6/7 reject (7442ea2), 5 tests:
  wt=6 known, wt=7 known, wt=6 unknown, wt=6 mid-stream after a valid
  field, positive control that wts 0/2/5 still parse.

Fix 3 — WKT registry auto-register (50ed9b6), 6 tests:
  pb.lookup resolves all 11 WKT descriptors plus type-URL form;
  Any/Timestamp, Any/Duration, Any/Int32Value, Any/Struct, Any/user-type
  all round-trip via JSON input.

Fix 4 — skip_field bounds checks (1712192), 5 tests:
  truncated I64, truncated I32, truncated LEN fast path, truncated LEN
  multi-byte length, complete unknown round-trips intact.

Fix 5 — drop unknown enum names in JSON (a2f1209), 5 tests:
  unknown name elided in singular, dropped from repeated, dropped from
  map value, numeric unknown preserved, numeric-string unknown preserved.
1 files changed, 306 insertions(+), 0 deletions(-)

M test/conformance_test.lua
M test/conformance_test.lua => test/conformance_test.lua +306 -0
@@ 163,6 163,312 @@ core_g.test_wkt_timestamp_field_roundtrip = function()
end

-- ---------------------------------------------------------------------------
-- Regression tests for fixes referenced in test/conformance/TRIAGE.md
-- Each test pins one code path against a conformance-shaped request so a
-- future change that re-opens the bug is caught locally without needing the
-- Docker conformance runner. Tests are grouped by fix; multiple tests per
-- fix exist because each fix touches several distinct code paths (per
-- scalar type, per shape — singular/repeated/map).
-- ---------------------------------------------------------------------------

-- Shared helpers for the regression group.
local function pb_roundtrip(input)
    return decode_resp(core.handle_request(encode_req({
        protobuf_payload = input,
        requested_output_format = PROTOBUF,
        message_type = PROTO3_NAME,
    })))
end

local function json_to_pb(json_str)
    return decode_resp(core.handle_request(encode_req({
        json_payload = json_str,
        requested_output_format = PROTOBUF,
        message_type = PROTO3_NAME,
    })))
end

-- =========================================================================
-- Fix 1: varint truncation to 32 bits (commit 0c13fd7) — int32/uint32/
-- sint32/enum decode must drop bits above bit 31 and sign-extend signed
-- variants. Distinct code paths: typed scalar decoders (wire.lua) plus
-- enum-via-decode_varint sites (codec.lua + inline.go + lazy.lua).
-- =========================================================================

core_g.test_int32_truncates_high_bits_to_zero = function()
    -- 1<<33: low 32 bits all zero → decode to 0, encode to "" (default elision)
    local resp = pb_roundtrip('\x08\x80\x80\x80\x80\x10')
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_equals(resp.protobuf_payload, '')
end

core_g.test_int32_truncates_with_sign_extension = function()
    -- (1<<33)-1: low 32 bits 0xFFFFFFFF → sign-extend to -1 → 10-byte varint
    local resp = pb_roundtrip('\x08\xff\xff\xff\xff\x1f')
    t.assert_not(resp.parse_error, resp.parse_error)
    -- -1 as int32 is encoded as uint64 0xFFFFFFFFFFFFFFFF = 10-byte varint
    t.assert_equals(resp.protobuf_payload,
        '\x08\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01')
end

core_g.test_uint32_truncates_to_low_32_bits = function()
    -- field 3 = optional_uint32. Input (1<<33)-1 → low 32 = UINT32_MAX → 5-byte
    local resp = pb_roundtrip('\x18\xff\xff\xff\xff\x1f')
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_equals(resp.protobuf_payload, '\x18\xff\xff\xff\xff\x0f')
end

core_g.test_sint32_zigzag_truncates_to_32_bits = function()
    -- field 5 = optional_sint32. zz64(INT32_MAX+2) = 4294967298 → low 32 = 2
    -- → zigzag_decode(2) = 1 → re-encoded as zz32(1) = varint(2)
    -- zz64(4294967298) varint: 0x82 0x80 0x80 0x80 0x10
    local resp = pb_roundtrip('\x28\x82\x80\x80\x80\x10')
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_equals(resp.protobuf_payload, '\x28\x02')  -- field 5, varint(2)
end

core_g.test_enum_singular_truncates_to_int32 = function()
    -- field 21 = optional_nested_enum. Input INT64_MAX varint → low 32 bits
    -- = 0xFFFFFFFF → -1 → 10-byte uint64 form on re-encode.
    -- Tag: (21<<3)|0 = 0xA8 0x01. Varint(INT64_MAX) is 9 bytes 0xFF×8 0x7F.
    local resp = pb_roundtrip(
        '\xa8\x01' .. '\xff\xff\xff\xff\xff\xff\xff\xff\x7f')
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_equals(resp.protobuf_payload,
        '\xa8\x01' .. '\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01')
end

core_g.test_packed_repeated_int32_truncates_each_element = function()
    -- field 31 = repeated_int32 (packed). Input contains one over-range value
    -- (1<<33) — must decode-then-encode as 0; since 0 is in a packed list it
    -- is still emitted (lists never elide).
    -- Tag: (31<<3)|2 = 0xFA 0x01. Length-prefixed payload of varint(1<<33).
    local payload = '\x80\x80\x80\x80\x10'
    local input = '\xfa\x01' .. string.char(#payload) .. payload
    local resp = pb_roundtrip(input)
    t.assert_not(resp.parse_error, resp.parse_error)
    local decoded = proto3.TestAllTypesProto3_decode(resp.protobuf_payload)
    t.assert_equals(decoded.repeated_int32, {0})
end

core_g.test_repeated_scalar_selects_last_after_truncation = function()
    -- proto3 last-one-wins on singular scalars: send int32 twice, both
    -- over-range, only the second value survives, and it must be truncated.
    -- Input: tag(1,VARINT) + varint(1<<33), tag(1,VARINT) + varint((1<<33)-1)
    local input = '\x08\x80\x80\x80\x80\x10'
                .. '\x08\xff\xff\xff\xff\x1f'
    local resp = pb_roundtrip(input)
    t.assert_not(resp.parse_error, resp.parse_error)
    -- Last value (1<<33)-1 truncates to -1 → 10-byte varint
    t.assert_equals(resp.protobuf_payload,
        '\x08\xff\xff\xff\xff\xff\xff\xff\xff\xff\x01')
end

-- =========================================================================
-- Fix 2: reject illegal wire types 6/7 in decode_tag (commit 7442ea2).
-- The check must fire whether the tag references a known field (typed
-- reader path) or an unknown one (skip_field path).
-- =========================================================================

core_g.test_wire_type_6_known_field_rejected = function()
    -- tag = (1<<3)|6 = 0x0e against optional_int32
    t.assert_not_equals(pb_roundtrip('\x0e\x01').parse_error, nil)
end

core_g.test_wire_type_7_known_field_rejected = function()
    -- tag = (1<<3)|7 = 0x0f against optional_int32
    t.assert_not_equals(pb_roundtrip('\x0f\x01').parse_error, nil)
end

core_g.test_wire_type_6_unknown_field_rejected = function()
    -- tag = (1000<<3)|6, varint encoded. 1000 fits in 2 bytes.
    -- (1000<<3)|6 = 8006 → varint 0xc6 0xbe 0x00. Plus one payload byte.
    -- Actually 8006 = 0x1F46 → varint two-byte: 0xc6 0x3e
    t.assert_not_equals(pb_roundtrip('\xc6\x3e\x01').parse_error, nil)
end

core_g.test_wire_type_6_after_valid_field_rejected = function()
    -- A valid field followed by an illegal one — confirms the check fires
    -- mid-stream, not only on the first tag.
    t.assert_not_equals(pb_roundtrip('\x08\x05' .. '\x0e\x01').parse_error, nil)
end

core_g.test_valid_wire_types_still_accepted = function()
    -- Positive control: wt=0 (VARINT) for known field, wt=2 (LEN) for
    -- string, wt=5 (I32) for float — none should error.
    local input = '\x08\x05'                          -- tag(1,VARINT) int32=5
              .. '\x72\x03foo'                        -- tag(14,LEN) string="foo"
              .. '\x5d\x00\x00\x00\x00'               -- tag(11,I32) float=0
    local resp = pb_roundtrip(input)
    t.assert_not(resp.parse_error, resp.parse_error)
end

-- =========================================================================
-- Fix 3: WKT registry self-registers (commit 50ed9b6) so json_to_any can
-- resolve @type for any well-known type without manual pb.register calls.
-- =========================================================================

core_g.test_pb_lookup_returns_wkt_descriptors = function()
    local pb = require('pb')
    for _, name in ipairs({
        'google.protobuf.Timestamp',
        'google.protobuf.Duration',
        'google.protobuf.FieldMask',
        'google.protobuf.Empty',
        'google.protobuf.Any',
        'google.protobuf.Struct',
        'google.protobuf.Value',
        'google.protobuf.ListValue',
        'google.protobuf.Int32Value',
        'google.protobuf.StringValue',
        'google.protobuf.BoolValue',
    }) do
        t.assert_not_equals(pb.lookup(name), nil,
            'pb.lookup must resolve ' .. name)
        t.assert_not_equals(pb.lookup('type.googleapis.com/' .. name), nil,
            'pb.lookup must resolve fully-qualified URL for ' .. name)
    end
end

core_g.test_any_with_timestamp_resolves = function()
    local resp = json_to_pb([[{"optionalAny": {
        "@type": "type.googleapis.com/google.protobuf.Timestamp",
        "value": "1970-01-01T00:00:01Z"
    }}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not_equals(resp.protobuf_payload, '')
end

core_g.test_any_with_duration_resolves = function()
    local resp = json_to_pb([[{"optionalAny": {
        "@type": "type.googleapis.com/google.protobuf.Duration",
        "value": "1.5s"
    }}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not_equals(resp.protobuf_payload, '')
end

core_g.test_any_with_int32_wrapper_resolves = function()
    local resp = json_to_pb([[{"optionalAny": {
        "@type": "type.googleapis.com/google.protobuf.Int32Value",
        "value": 42
    }}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not_equals(resp.protobuf_payload, '')
end

core_g.test_any_with_struct_resolves = function()
    local resp = json_to_pb([[{"optionalAny": {
        "@type": "type.googleapis.com/google.protobuf.Struct",
        "value": {"key": "val"}
    }}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not_equals(resp.protobuf_payload, '')
end

core_g.test_any_with_user_type_resolves = function()
    -- core.lua registers TestAllTypesProto3 in the WKT registry so Any
    -- can also embed user types — pin that behavior.
    local resp = json_to_pb(string.format([[{"optionalAny": {
        "@type": "type.googleapis.com/%s",
        "optionalInt32": 7
    }}]], PROTO3_NAME))
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not_equals(resp.protobuf_payload, '')
end

-- =========================================================================
-- Fix 4: skip_field bounds checks (commit 1712192). Truncated unknown
-- fields of every wire type must raise a parse_error rather than the
-- outer `while pos <= len` exiting silently.
-- =========================================================================

core_g.test_skip_field_rejects_truncated_i64 = function()
    -- field 105 (unknown), I64, 3 of 8 bytes
    t.assert_not_equals(pb_roundtrip('\xc9\x06\x00\x00\x00').parse_error, nil)
end

core_g.test_skip_field_rejects_truncated_i32 = function()
    -- field 105 (unknown), I32, 2 of 4 bytes
    t.assert_not_equals(pb_roundtrip('\xcd\x06\x00\x00').parse_error, nil)
end

core_g.test_skip_field_rejects_truncated_len_fast_path = function()
    -- field 105 (unknown), LEN; declared length 5, only 2 bytes follow.
    -- Length byte 5 < 0x80 → fast-path bounds check exercised.
    t.assert_not_equals(pb_roundtrip('\xca\x06\x05ab').parse_error, nil)
end

core_g.test_skip_field_rejects_truncated_len_multibyte = function()
    -- field 105 (unknown), LEN; declared length 200 (multi-byte varint),
    -- only 1 byte follows. Slow-path bounds check exercised.
    t.assert_not_equals(pb_roundtrip('\xca\x06\xc8\x01a').parse_error, nil)
end

core_g.test_skip_field_accepts_complete_unknown = function()
    -- Positive control: an unknown field whose payload IS fully present
    -- must round-trip cleanly (and our parser preserves unknowns).
    local input = '\xc9\x06' .. '\x01\x02\x03\x04\x05\x06\x07\x08'
    local resp = pb_roundtrip(input)
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_equals(resp.protobuf_payload, input)
end

-- =========================================================================
-- Fix 5: drop unrecognized enum string names in JSON input (commit
-- a2f1209). decode_enum returns nil; callers must skip nil values in
-- repeated and map shapes, and elide the field for singular.
-- =========================================================================

core_g.test_unknown_enum_string_elided_in_singular = function()
    local resp = json_to_pb(
        [[{"optionalNestedEnum": "DEFINITELY_NOT_A_VALUE"}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not(resp.serialize_error, resp.serialize_error)
    -- Field unset → encoded as empty (default elision).
    t.assert_equals(resp.protobuf_payload, '')
end

core_g.test_unknown_enum_string_dropped_from_repeated = function()
    local resp = json_to_pb(
        [[{"repeatedNestedEnum": ["FOO", "DEFINITELY_NOT_A_VALUE", "BAR"]}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not(resp.serialize_error, resp.serialize_error)
    local decoded = proto3.TestAllTypesProto3_decode(resp.protobuf_payload)
    t.assert_equals(decoded.repeated_nested_enum, {0, 1})
end

core_g.test_unknown_enum_string_dropped_from_map_value = function()
    -- map_string_nested_enum: drop the entry whose enum name is unknown,
    -- keep the entry whose name resolves.
    local resp = json_to_pb([[{"mapStringNestedEnum": {
        "good": "BAR",
        "bad":  "DEFINITELY_NOT_A_VALUE"
    }}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    t.assert_not(resp.serialize_error, resp.serialize_error)
    local decoded = proto3.TestAllTypesProto3_decode(resp.protobuf_payload)
    t.assert_equals(decoded.map_string_nested_enum, {good = 1})
end

core_g.test_unknown_enum_numeric_preserved = function()
    -- proto3 spec: unknown *integer* enum values pass through unchanged.
    -- Only string names that don't resolve get dropped.
    local resp = json_to_pb([[{"optionalNestedEnum": 999}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    local decoded = proto3.TestAllTypesProto3_decode(resp.protobuf_payload)
    t.assert_equals(decoded.optional_nested_enum, 999)
end

core_g.test_unknown_enum_numeric_string_preserved = function()
    -- Numeric strings like "999" should also be preserved as the integer.
    local resp = json_to_pb([[{"optionalNestedEnum": "999"}]])
    t.assert_not(resp.parse_error, resp.parse_error)
    local decoded = proto3.TestAllTypesProto3_decode(resp.protobuf_payload)
    t.assert_equals(decoded.optional_nested_enum, 999)
end

-- ---------------------------------------------------------------------------
-- 2. Subprocess: stdin/stdout framing
-- ---------------------------------------------------------------------------
--