@@ 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
-- ---------------------------------------------------------------------------
--