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