From 2180b76a781b1f114fbd1d634fe37c57788387df Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 16 May 2026 00:23:31 +0300 Subject: [PATCH] test: regression tests for each conformance fix in this branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/conformance_test.lua | 306 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) diff --git a/test/conformance_test.lua b/test/conformance_test.lua index f4af69572b65d05bd8e5a39af9efad88d91623d9..4ed84266d58a4fc90822dad788a0df7085afe5c3 100644 --- a/test/conformance_test.lua +++ b/test/conformance_test.lua @@ -162,6 +162,312 @@ core_g.test_wkt_timestamp_field_roundtrip = function() t.assert_equals(resp.protobuf_payload, input) 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 -- ---------------------------------------------------------------------------