From 37a18dada1f4d21f809bde29324e7d0fd55c9e89 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 16 May 2026 19:22:12 +0300 Subject: [PATCH] json: strict validation pass closes the proto3 conformance suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six classes of relaxation that the proto3 JSON conformance corpus flagged are now enforced. All as Recommended.* tests; combined with the -0.0 codec fix this empties known_failures.txt and brings the proto3 binary+JSON suite to 1493 ✓ / 1313 skipped / 0 expected failures / 0 unexpected failures. 1. Duplicate JSON keys. Tarantool's json.decode is hash-backed and silently collapses `{"foo":1,"foo":2}` to one entry. A small byte-walker `find_duplicate_json_keys` runs before json.decode, tracks per-object brace frames and key sets, errors on the second occurrence. Closes Recommended.FieldNameDuplicate. 2. camelCase / snake_case aliases of the same proto field appearing side-by-side. Detected inside decode_message via a `field_seen` set keyed by proto-name; second hit errors. Closes FieldNameDuplicateDifferentCasing{1,2}. 3. JSON null inside repeated arrays and map values. Previously silently dropped; now errors before decode_field_value. Closes RepeatedField{Message,Primitive}ElementIsNull and MapFieldValueIsNull. 4. Unknown enum *names* (not integers). decode_enum used to return nil so callers silently dropped them; now raises by default and returns nil only when M.decode's `ignore_unknown_fields=true` opt is set. Conformance dispatch in cmd/conformance/core.lua forwards this flag when req.test_category == JSON_IGNORE_UNKNOWN_PARSING_TEST. Closes RejectUnknownEnumStringValueIn{Optional,Repeated,Map} and the paired IgnoreUnknownEnumStringValueIn* tests. 5. google.protobuf.NullValue JSON canonical form. The single enum value renders as the literal JSON `null` (not the string "NULL_VALUE"); decode accepts either, encode emits null. The decode_field_value null-handling path also treats a JSON null on a NullValue-typed field as "set" rather than "absent" so a oneof gets marked active. Closes NullValueInOtherOneof{New,Old}Format.Validator. 6. FieldMask strict round-trip. Path validity is checked on both sides: the snake_case wire form rejects uppercase letters, consecutive underscores, trailing underscore, and underscore followed by anything other than a lowercase letter — these break the snake↔camel round-trip. The JSON form rejects any underscore in the input (must be lowerCamelCase). Closes FieldMask{TooManyUnderscore,PathsDontRoundTrip, NumbersDontRoundTrip}.JsonOutput and JsonInput.FieldMaskInvalidCharacter. The pre-existing "drop unknown enum strings" unit regressions in test/conformance_test.lua were inverted to assert the new error shape. New strict-validation regressions in test/json_test.lua pin all six categories so they don't regress; the `json.strict` group runs across both codegen modes via the shared descriptor table. --- cmd/conformance/core.lua | 7 +- runtime/pb/json.lua | 195 ++++++++++++++++++++++++++-- test/conformance/known_failures.txt | 34 +---- test/conformance_test.lua | 30 ++--- test/json_test.lua | 157 ++++++++++++++++++++++ 5 files changed, 364 insertions(+), 59 deletions(-) diff --git a/cmd/conformance/core.lua b/cmd/conformance/core.lua index 51bcc138fe732e522fded14291b1ade7f4807e36..c2d30cd79d36ef4b46f496ebe0dce210ccf18cc2 100644 --- a/cmd/conformance/core.lua +++ b/cmd/conformance/core.lua @@ -52,7 +52,12 @@ local function dispatch(req) end msg = decoded elseif req.json_payload ~= nil then - local ok, decoded = pcall(pb.json.decode, desc, req.json_payload) + -- JSON_IGNORE_UNKNOWN_PARSING_TEST tells the testee to silently + -- drop unknown enum names (and unknown fields). Other categories + -- get the strict default that rejects them. + local json_opts = {ignore_unknown_fields = ( + req.test_category == conformance.TestCategory.JSON_IGNORE_UNKNOWN_PARSING_TEST)} + local ok, decoded = pcall(pb.json.decode, desc, req.json_payload, json_opts) if not ok then return {parse_error = 'json decode failed: ' .. tostring(decoded)} end diff --git a/runtime/pb/json.lua b/runtime/pb/json.lua index bff5f2aa73c63941146a7d89b0e0bce0abfb7642..f1838ca00b5eeeebc48d0a190e74703b7ef14994 100644 --- a/runtime/pb/json.lua +++ b/runtime/pb/json.lua @@ -581,6 +581,13 @@ local function encode_scalar(proto_type, v) end local function encode_enum(enum_desc, v) + -- google.protobuf.NullValue's JSON form is the literal `null`, not the + -- enum name "NULL_VALUE". Mainline's TextFormat/JSON parsers accept + -- either on input, but the canonical output is null — and the + -- NullValueInOtherOneof*Format.Validator conformance tests pin it. + if enum_desc.name == 'google.protobuf.NullValue' then + return box.NULL + end if type(v) == 'string' then return v end local name = enum_desc.by_value[v] return name or v -- unknown numeric value: emit as number @@ -654,17 +661,61 @@ value_to_json_list = function(t) return setmetatable(out, {__serialize='seq'}) end +-- google.protobuf.FieldMask paths use snake_case on the wire and a +-- lowerCamelCase JSON form. To survive the round-trip, snake_case paths +-- must be restricted so the inverse transform is unambiguous: +-- * no uppercase letters (it's not snake_case otherwise) +-- * no consecutive underscores (would lose info: "foo__bar" → "fooBar" +-- → "foo_bar"); pinned by FieldMaskTooManyUnderscore +-- * no trailing underscore +-- * underscore must precede a lowercase letter — never a digit +-- ("foo_3_bar" → "foo3Bar" → "foo3_bar"); pinned by +-- FieldMaskNumbersDontRoundTrip +-- * the path itself must already be snake-cased (no uppercase letters +-- in the input we're asked to serialize); pinned by +-- FieldMaskPathsDontRoundTrip local function fieldmask_to_json(v) if v == nil or #v == 0 then return '' end local parts = {} - for i = 1, #v do parts[i] = to_camel(v[i]) end + for i = 1, #v do + local p = v[i] + if type(p) ~= 'string' then + error('FieldMask: path is not a string', 0) + end + if p == '' then + error('FieldMask: empty path', 0) + end + if p:find('[A-Z]') then + error('FieldMask: path "' .. p .. + '" has uppercase letter (must be snake_case)', 0) + end + if p:find('__') then + error('FieldMask: path "' .. p .. + '" has consecutive underscores', 0) + end + if p:sub(-1) == '_' then + error('FieldMask: path "' .. p .. '" has trailing underscore', 0) + end + if p:find('_[^a-z]') then + error('FieldMask: path "' .. p .. + '" has underscore followed by non-letter (does not round-trip)', 0) + end + parts[i] = to_camel(p) + end return table.concat(parts, ',') end +-- JSON FieldMask paths are lowerCamelCase; the wire is snake_case. The +-- input must therefore be free of `_` (an underscore in the JSON form +-- breaks the snake↔camel inverse: see FieldMaskInvalidCharacter). local function fieldmask_from_json(s) if type(s) ~= 'string' or s == '' then return {} end local out = {} for part in (s .. ','):gmatch('([^,]+),') do + if part:find('_') then + error('FieldMask JSON: path "' .. part .. + '" contains underscore (JSON form must be lowerCamelCase)', 0) + end out[#out + 1] = (part:gsub('(%u)', function(c) return '_' .. c:lower() end)) end return out @@ -842,6 +893,12 @@ local function decode_scalar(proto_type, v) error('decode_scalar: unsupported proto type ' .. tostring(proto_type), 0) end +-- Set by M.decode for the duration of a parse. Reads are fiber-local in +-- effect because pb.json.decode never yields (pure Lua / FFI work). The +-- recursive decode chain reads this when it needs to know whether the +-- caller asked for "ignore unknown enum names / fields" behavior. +local CURRENT_OPTS = nil + local function decode_enum(enum_desc, v) if type(v) == 'string' then local n = enum_desc.by_name[v] @@ -851,8 +908,17 @@ local function decode_enum(enum_desc, v) local n2 = tonumber(v) if n2 >= -2147483648 and n2 <= 2147483647 then return n2 end end - -- Unknown name: signal "ignore" via nil. - return nil + -- Unknown enum NAMES are rejected per proto3 JSON spec + -- (RejectUnknownEnumStringValueIn{Optional,Repeated,Map}Field). + -- Unknown enum *integers* fall through to the integer branch and + -- are preserved — that's the proto3 forward-compat contract. + -- Under `ignore_unknown_fields=true` (conformance category + -- JSON_IGNORE_UNKNOWN_PARSING_TEST) we silently drop instead; + -- the caller in repeated/map context filters nil through. + if CURRENT_OPTS and CURRENT_OPTS.ignore_unknown_fields then + return nil + end + error('unknown enum value "' .. v .. '" for ' .. enum_desc.name, 0) end if type(v) == 'number' then if v ~= v or v ~= math.floor(v) then @@ -870,12 +936,18 @@ end local function decode_field_value(field, v) if v == box.NULL then -- proto3 JSON: null on a non-message field means "use the default" - -- (treat as absent). The lone exception is google.protobuf.Value - -- whose null is the NullValue.NULL_VALUE member. + -- (treat as absent). Two exceptions: + -- * google.protobuf.Value — null maps to NullValue.NULL_VALUE. + -- * NullValue-typed enum field (used as a oneof presence marker) + -- — null SETS the field to 0, marking the oneof active. if field.kind == 'message' and field.message and field.message.name == 'google.protobuf.Value' then return PB_NULL end + if field.kind == 'enum' and field.enum + and field.enum.name == 'google.protobuf.NullValue' then + return 0 + end return nil end local kind = field.kind @@ -1081,12 +1153,31 @@ decode_message = function(desc, v) local out = {} local oneof_seen -- lazily allocated + local field_seen -- proto-name set; detects camelCase/snake_case duplicates for k, jv in pairs(v) do local f = field_by_json_name[k] if f ~= nil then + -- Reject same proto field appearing under both camelCase and + -- snake_case aliases in the same JSON object (mainline rejects; + -- FieldNameDuplicateDifferentCasing{1,2} pin this). Literal + -- duplicate keys (same string twice) are caught upstream by + -- the find_duplicate_json_keys pre-scan in M.decode. + field_seen = field_seen or {} + if field_seen[f.name] then + error('duplicate field "' .. f.name .. + '" (camelCase / snake_case aliases collide)', 0) + end + field_seen[f.name] = true local is_value_field = (f.kind == 'message' and f.message and f.message.name == 'google.protobuf.Value') - local is_null_default = (jv == box.NULL) and not is_value_field + -- NullValue-typed enum (used as a oneof presence marker) treats + -- JSON null as "set to NULL_VALUE", not as "absent / use default". + -- Mainline's NullValueInOtherOneofNewFormat pins this — the + -- decoded message must record the oneof as active. + local is_null_value_enum = (f.kind == 'enum' and f.enum + and f.enum.name == 'google.protobuf.NullValue') + local is_null_default = (jv == box.NULL) + and not is_value_field and not is_null_value_enum -- Reject duplicate oneof branches. A `null` JSON value for a -- oneof branch means "field absent" and does NOT count as -- setting the oneof (matches OneofFieldNullFirst/Second tests). @@ -1106,6 +1197,12 @@ decode_message = function(desc, v) end local m = {} for mk, mv in pairs(jv) do + -- Map values must not be JSON null per spec. + -- (MapFieldValueIsNull conformance test). + if mv == box.NULL then + error('field "' .. k .. '": map value for key "' .. + tostring(mk) .. '" is JSON null', 0) + end local dv = decode_field_value(f.value, mv) if not rawequal(dv, nil) then m[decode_map_key(f.key, mk)] = dv @@ -1121,6 +1218,12 @@ decode_message = function(desc, v) local arr = {} local n = 0 for i = 1, #jv do + -- Repeated array elements must not be JSON null per + -- spec (RepeatedField{Message,Primitive}ElementIsNull). + if jv[i] == box.NULL then + error('field "' .. k .. '": array element ' .. i .. + ' is JSON null', 0) + end local dv = decode_field_value(f, jv[i]) if not rawequal(dv, nil) then n = n + 1; arr[n] = dv @@ -1138,7 +1241,75 @@ decode_message = function(desc, v) return out end -function M.decode(desc, s) +-- Walk the JSON source string, raising on any object that contains the same +-- literal key twice. Tarantool's `json.decode` is hash-backed and silently +-- collapses such duplicates, so without this pre-scan we'd accept payloads +-- like `{"foo":1,"foo":2}` which the conformance suite (FieldNameDuplicate) +-- requires us to reject. +-- +-- This is a minimal byte walker — it tracks bracket nesting and string +-- escapes well enough to find object key boundaries, and stops at the first +-- duplicate. It does NOT replicate `json.decode`'s validation; we still rely +-- on `json.decode` to surface every other JSON syntax error. +local function find_duplicate_json_keys(s) + local pos, len = 1, #s + local stacks = {} -- per-depth array of `seen` tables (object frames) + local depth = 0 + local in_object = {} -- depth → true if frame is an object (vs array) + while pos <= len do + local c = s:byte(pos) + if c == 0x22 then -- '"' — string literal + local key_start = pos + 1 + local p = key_start + while p <= len do + local b = s:byte(p) + if b == 0x5c then p = p + 2 -- '\\' + escaped char + elseif b == 0x22 then break -- closing quote + else p = p + 1 end + end + if p > len then return end -- malformed; json.decode will reject + local key = s:sub(key_start, p - 1) + pos = p + 1 + -- Lookahead: is the next non-whitespace char a `:` (object key)? + -- If so, register the key in the current object frame. + local q = pos + while q <= len do + local b = s:byte(q) + if b == 0x20 or b == 0x09 or b == 0x0a or b == 0x0d then + q = q + 1 + else break end + end + if q <= len and s:byte(q) == 0x3a and in_object[depth] then + local seen = stacks[depth] + if seen[key] then + return 'duplicate JSON key "' .. key .. '"' + end + seen[key] = true + end + elseif c == 0x7b then -- '{' + depth = depth + 1 + stacks[depth] = {} + in_object[depth] = true + pos = pos + 1 + elseif c == 0x5b then -- '[' + depth = depth + 1 + stacks[depth] = false + in_object[depth] = false + pos = pos + 1 + elseif c == 0x7d or c == 0x5d then -- '}' or ']' + stacks[depth] = nil + in_object[depth] = nil + depth = depth - 1 + pos = pos + 1 + else + pos = pos + 1 + end + end +end + +function M.decode(desc, s, opts) + local dup_err = find_duplicate_json_keys(s) + if dup_err ~= nil then error(dup_err, 0) end local v = json.decode(s) if v == nil or v == box.NULL then -- Top-level JSON null is rejected for regular messages but is a @@ -1148,7 +1319,15 @@ function M.decode(desc, s) end error('top-level JSON null is not a valid message', 0) end - return decode_message(desc, v) + -- Install opts for the recursive enum-name resolution. Cleared in a + -- finally-like xpcall guard so an error inside decode_message doesn't + -- leak state into the next caller (especially across fibers — though + -- pb.json.decode never yields, defensiveness is cheap). + CURRENT_OPTS = opts + local ok, out = pcall(decode_message, desc, v) + CURRENT_OPTS = nil + if not ok then error(out, 0) end + return out end M.to_json_value = to_json_value diff --git a/test/conformance/known_failures.txt b/test/conformance/known_failures.txt index c8730389374b64603ca13df4d4bd446ec0fdd8ec..9cd4a2b103d6f372ea93cdaa980e3766d5517181 100644 --- a/test/conformance/known_failures.txt +++ b/test/conformance/known_failures.txt @@ -1,32 +1,6 @@ # conformance_test_runner --failure_list # -# Recommended-only failures (Required tests all pass as of 2026-05-16). -# Re-captured after the proto3 JSON strict-validation pass; see commits -# spanning runtime/pb/json.lua + runtime/pb/wire.lua. -# -# Remaining categories: -# * FieldMask — round-trip tolerates names that can't survive -# lowerCamel → snake → lowerCamel. -# * FieldNameDuplicate — duplicate keys aren't rejected (Tarantool's -# json.decode silently keeps the last). -# * MapFieldValueIsNull / RepeatedField*ElementIsNull — null elements -# inside a map/repeated should be parse_error; we currently drop them. -# * NullValueInOtherOneof[New|Old]Format.Validator — Validator harness -# disagrees with our round-trip even though semantics match. -# * RejectUnknownEnumStringValueIn[Optional|Repeated|Map]Field — proto3 -# mode should reject unknown enum names (we silently drop them). -Recommended.Proto3.FieldMaskNumbersDontRoundTrip.JsonOutput -Recommended.Proto3.FieldMaskPathsDontRoundTrip.JsonOutput -Recommended.Proto3.FieldMaskTooManyUnderscore.JsonOutput -Recommended.Proto3.JsonInput.FieldMaskInvalidCharacter -Recommended.Proto3.JsonInput.FieldNameDuplicate -Recommended.Proto3.JsonInput.FieldNameDuplicateDifferentCasing1 -Recommended.Proto3.JsonInput.FieldNameDuplicateDifferentCasing2 -Recommended.Proto3.JsonInput.MapFieldValueIsNull -Recommended.Proto3.JsonInput.NullValueInOtherOneofNewFormat.Validator -Recommended.Proto3.JsonInput.NullValueInOtherOneofOldFormat.Validator -Recommended.Proto3.JsonInput.RejectUnknownEnumStringValueInMapValue -Recommended.Proto3.JsonInput.RejectUnknownEnumStringValueInOptionalField -Recommended.Proto3.JsonInput.RejectUnknownEnumStringValueInRepeatedField -Recommended.Proto3.JsonInput.RepeatedFieldMessageElementIsNull -Recommended.Proto3.JsonInput.RepeatedFieldPrimitiveElementIsNull +# Empty as of 2026-05-16: the proto3 binary + JSON suite is at +# 1493 ✓ / 1313 skipped / 0 expected failures / 0 unexpected. +# The 1313 skipped are the proto2/editions message-type buckets we +# don't generate Lua for; everything in scope passes. diff --git a/test/conformance_test.lua b/test/conformance_test.lua index 96a2c5741b8cb3cbfdf51136dcc9f04c855b77ed..90db2cb7a66391601baa697729b6ad9be6681f02 100644 --- a/test/conformance_test.lua +++ b/test/conformance_test.lua @@ -605,40 +605,30 @@ core_g.test_skip_field_accepts_complete_unknown = function() 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. +-- Unknown enum *names* in JSON input are rejected (proto3 spec; pinned +-- by RejectUnknownEnumStringValueIn{Optional,Repeated,Map}). Unknown +-- enum *integers* are forward-compat and pass through unchanged +-- (see test_unknown_enum_numeric_preserved below). -- ========================================================================= -core_g.test_unknown_enum_string_elided_in_singular = function() +core_g.test_unknown_enum_string_rejected_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, '') + t.assert_str_contains(resp.parse_error or '', 'unknown enum value') end -core_g.test_unknown_enum_string_dropped_from_repeated = function() +core_g.test_unknown_enum_string_rejected_in_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}) + t.assert_str_contains(resp.parse_error or '', 'unknown enum value') 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. +core_g.test_unknown_enum_string_rejected_in_map_value = function() 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}) + t.assert_str_contains(resp.parse_error or '', 'unknown enum value') end core_g.test_unknown_enum_numeric_preserved = function() diff --git a/test/json_test.lua b/test/json_test.lua index 9841828075bd36c20ade001d9a9ec3f996b045aa..e59045702bad5890a1d61ca238190686f81f5afa 100644 --- a/test/json_test.lua +++ b/test/json_test.lua @@ -231,3 +231,160 @@ gone.test_oneof_default_value_branch = function() local back = pb.json.decode(hello.Result_descriptor, enc) t.assert_equals(back.text, '') end + +-- --------------------------------------------------------------------------- +-- Strict validation (regression tests pinning the proto3 conformance pass) +-- --------------------------------------------------------------------------- +local gstrict = t.group('json.strict') +local proto3 = require('full.protobuf_test_messages.proto3.test_messages_proto3_pb') +local P3 = proto3.TestAllTypesProto3_descriptor + +gstrict.test_duplicate_literal_keys_rejected = function() + -- Tarantool's json.decode silently keeps the last value of a duplicate + -- key; the find_duplicate_json_keys pre-scan in M.decode catches them. + -- Pins Recommended.Proto3.JsonInput.FieldNameDuplicate. + local ok, err = pcall(pb.json.decode, P3, + '{"optionalInt32": 1, "optionalInt32": 2}') + t.assert_not(ok) + t.assert_str_contains(err, 'duplicate JSON key') +end + +gstrict.test_duplicate_camel_snake_aliases_rejected = function() + -- Both `optional_nested_message` (snake) and `optionalNestedMessage` + -- (camel) refer to the same proto field. Mainline rejects, even + -- though Lua's hash sees them as distinct keys. + local ok, err = pcall(pb.json.decode, P3, [[{ + "optional_nested_message": {"a": 1}, + "optionalNestedMessage": {"a": 2} + }]]) + t.assert_not(ok) + t.assert_str_contains(err, 'duplicate field') +end + +gstrict.test_duplicate_keys_in_nested_object_rejected = function() + -- The pre-scan tracks per-object frames; duplicate inside a nested + -- object must trigger even if the outer keys are unique. + local ok, err = pcall(pb.json.decode, P3, + '{"optionalNestedMessage": {"a": 1, "a": 2}}') + t.assert_not(ok) + t.assert_str_contains(err, 'duplicate JSON key') +end + +gstrict.test_repeated_primitive_element_null_rejected = function() + local ok, err = pcall(pb.json.decode, P3, + '{"repeatedInt32": [1, null, 2]}') + t.assert_not(ok) + t.assert_str_contains(err, 'JSON null') +end + +gstrict.test_repeated_message_element_null_rejected = function() + local ok, err = pcall(pb.json.decode, P3, + '{"repeatedNestedMessage": [{"a":1}, null]}') + t.assert_not(ok) + t.assert_str_contains(err, 'JSON null') +end + +gstrict.test_map_value_null_rejected = function() + local ok, err = pcall(pb.json.decode, P3, + '{"mapInt32Int32": {"0": null}}') + t.assert_not(ok) + t.assert_str_contains(err, 'JSON null') +end + +gstrict.test_unknown_enum_name_rejected_singular = function() + local ok, err = pcall(pb.json.decode, P3, + '{"optionalNestedEnum": "DEFINITELY_NOT_A_VALUE"}') + t.assert_not(ok) + t.assert_str_contains(err, 'unknown enum value') +end + +gstrict.test_unknown_enum_name_rejected_in_repeated = function() + local ok, err = pcall(pb.json.decode, P3, + '{"repeatedNestedEnum": ["FOO", "NOPE"]}') + t.assert_not(ok) + t.assert_str_contains(err, 'unknown enum value') +end + +gstrict.test_unknown_enum_name_rejected_in_map_value = function() + local ok, err = pcall(pb.json.decode, P3, + '{"mapStringNestedEnum": {"k": "NOPE"}}') + t.assert_not(ok) + t.assert_str_contains(err, 'unknown enum value') +end + +gstrict.test_unknown_enum_name_silently_dropped_with_ignore = function() + -- Under the `ignore_unknown_fields` opt (conformance category + -- JSON_IGNORE_UNKNOWN_PARSING_TEST), unknown enum names are dropped + -- from the result rather than raising — and other valid fields + -- come through intact. + local m = pb.json.decode(P3, + '{"repeatedNestedEnum": ["FOO", "NOPE", "BAR"]}', + {ignore_unknown_fields = true}) + t.assert_equals(m.repeated_nested_enum, {0, 1}) +end + +gstrict.test_unknown_enum_integer_passes_through = function() + -- Unknown enum *integers* are forward-compat per proto3 — never + -- rejected, never dropped, no flag needed. + local m = pb.json.decode(P3, '{"optionalNestedEnum": 999}') + t.assert_equals(m.optional_nested_enum, 999) +end + +gstrict.test_null_value_oneof_set_by_json_null = function() + -- NullValue-typed oneof member: input JSON `null` MUST mark the + -- oneof as active (set to NULL_VALUE = 0). Mainline pins this via + -- NullValueInOtherOneofNewFormat.Validator. + local m = pb.json.decode(P3, '{"oneofNullValue": null}') + t.assert_equals(m.oneof_null_value, 0) +end + +gstrict.test_null_value_oneof_emits_json_null = function() + -- Encode side: NullValue's JSON form is the literal null, not the + -- enum string "NULL_VALUE". Pins NullValueInOtherOneofOldFormat. + local enc = pb.json.encode(P3, {oneof_null_value = 0}) + t.assert_str_contains(enc, '"oneofNullValue":null') +end + +gstrict.test_fieldmask_strict_paths_round_trip = function() + -- snake_case input that round-trips cleanly through camelCase + -- (lowercase letters + underscores before lowercase letters only). + local enc = pb.json.encode(P3, + {optional_field_mask = {'foo_bar', 'baz'}}) + t.assert_str_contains(enc, '"optionalFieldMask":"fooBar,baz"') +end + +gstrict.test_fieldmask_rejects_uppercase_in_path = function() + -- Path that's not already snake_case is malformed; would lose info + -- on the round-trip. Pins FieldMaskPathsDontRoundTrip. + local ok, err = pcall(pb.json.encode, P3, + {optional_field_mask = {'fooBar'}}) + t.assert_not(ok) + t.assert_str_contains(err, 'snake_case') +end + +gstrict.test_fieldmask_rejects_double_underscore = function() + -- "foo__bar" → "fooBar" → "foo_bar" — loses one underscore. + -- Pins FieldMaskTooManyUnderscore. + local ok, err = pcall(pb.json.encode, P3, + {optional_field_mask = {'foo__bar'}}) + t.assert_not(ok) + t.assert_str_contains(err, 'consecutive underscores') +end + +gstrict.test_fieldmask_rejects_underscore_before_digit = function() + -- "foo_3_bar" → "foo3Bar" → "foo3_bar" — irreversible. + -- Pins FieldMaskNumbersDontRoundTrip. + local ok, err = pcall(pb.json.encode, P3, + {optional_field_mask = {'foo_3_bar'}}) + t.assert_not(ok) + t.assert_str_contains(err, 'non-letter') +end + +gstrict.test_fieldmask_rejects_underscore_in_json_input = function() + -- JSON form must be lowerCamelCase; underscores are illegal in + -- input. Pins FieldMaskInvalidCharacter. + local ok, err = pcall(pb.json.decode, P3, + '{"optionalFieldMask": "foo,bar_bar"}') + t.assert_not(ok) + t.assert_str_contains(err, 'underscore') +end