~bigbes/tarantool

tarantool-protobuf

37a18dada1f4d21f809bde29324e7d0fd55c9e89 — Eugene Blikh 3 months ago f5c5ee6
json: strict validation pass closes the proto3 conformance suite

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.
M cmd/conformance/core.lua => cmd/conformance/core.lua +6 -1
@@ 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

M runtime/pb/json.lua => runtime/pb/json.lua +187 -8
@@ 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

M test/conformance/known_failures.txt => test/conformance/known_failures.txt +4 -30
@@ 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.

M test/conformance_test.lua => test/conformance_test.lua +10 -20
@@ 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()

M test/json_test.lua => test/json_test.lua +157 -0
@@ 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