diff --git a/buff-json-protoc-plugin/CLAUDE.md b/buff-json-protoc-plugin/CLAUDE.md index da95db2..6cce171 100644 --- a/buff-json-protoc-plugin/CLAUDE.md +++ b/buff-json-protoc-plugin/CLAUDE.md @@ -77,6 +77,7 @@ For each non-WKT, non-map-entry message type: - **Cross-file nested encoder calls** — `protoToEncoderClass` only contains messages from `filesToGenerate`. If a nested message is defined in a non-generated file, the fallback to `writer.writeMessage(jsonWriter, nested)` is used (which still finds the encoder at runtime via `instanceof BuffJsonCodecHolder`) - **Insertion point file paths** — for `java_multiple_files = true`, message insertion points target `package/MessageName.java`; for `false`, they target `package/OuterClassName.java`. The `outer_class_scope` insertion point always targets the outer class file - **Block comments** (`/** */`) — the `*` prefix on each line is stripped by `CommentGenerator.stripLines()`, producing clean multiline text +- **Generated decoders route fallible parses through `FieldReader`** — `DecoderGenerator` emits `FieldReader.readBytes(reader)` for bytes, `FieldReader.enumNumber(reader, EnumClass.getDescriptor(), name)` for enum names, and `FieldReader.parseIntKey`/`parseUnsignedIntKey`/`parseLongKey`/`parseUnsignedLongKey(reader, keyStr)` for numeric map keys — never inline `BASE64.decode`/`Enum.valueOf`/`Long.parseLong`. This gives generated code the same `JSONException`-for-bad-input contract as the runtime path (see buff-json `Error Contract`); the helpers are `public` because generated code lives in the user's package ## Build diff --git a/buff-json-protoc-plugin/src/main/java/io/suboptimal/buffjson/protoc/DecoderGenerator.java b/buff-json-protoc-plugin/src/main/java/io/suboptimal/buffjson/protoc/DecoderGenerator.java index dafac81..a1679c8 100644 --- a/buff-json-protoc-plugin/src/main/java/io/suboptimal/buffjson/protoc/DecoderGenerator.java +++ b/buff-json-protoc-plugin/src/main/java/io/suboptimal/buffjson/protoc/DecoderGenerator.java @@ -157,8 +157,9 @@ private static void generateMapFieldRead(StringBuilder sb, FieldDescriptor fd, M String valuePutter = putter + "Value(" + keyExpr + ", "; String enumClass = protoToJavaClass.get(valueFd.getEnumType().getFullName()); sb.append(indent).append(" if (reader.isString()) {\n"); - sb.append(indent).append(" ").append(valuePutter).append(enumClass) - .append(".valueOf(reader.readString()).getNumber());\n"); + sb.append(indent).append(" ").append(valuePutter) + .append("io.suboptimal.buffjson.internal.FieldReader.enumNumber(reader, ").append(enumClass) + .append(".getDescriptor(), reader.readString()));\n"); sb.append(indent).append(" } else {\n"); sb.append(indent).append(" ").append(valuePutter).append("reader.readInt32Value());\n"); sb.append(indent).append(" }\n"); @@ -223,17 +224,18 @@ private static void emitValueRead(StringBuilder sb, FieldDescriptor fd, String p sb.append(indent).append(prefix).append("(reader.readBoolValue()").append(closeSuffix).append(");\n"); case STRING -> sb.append(indent).append(prefix).append("(reader.readString()").append(closeSuffix).append(");\n"); - case BYTE_STRING -> sb.append(indent).append(prefix).append( - "(com.google.protobuf.ByteString.copyFrom(io.suboptimal.buffjson.internal.FieldReader.BASE64.decode(reader.readString()))") - .append(closeSuffix).append(");\n"); + case BYTE_STRING -> sb.append(indent).append(prefix) + .append("(io.suboptimal.buffjson.internal.FieldReader.readBytes(reader)").append(closeSuffix) + .append(");\n"); case ENUM -> { // Enum fields use the Value variant: setFoo -> setFooValue, addFoo -> // addFooValue String valueName = prefix + "Value"; String enumClass = protoToJavaClass.get(fd.getEnumType().getFullName()); sb.append(indent).append("if (reader.isString()) {\n"); - sb.append(indent).append(" ").append(valueName).append("(").append(enumClass) - .append(".valueOf(reader.readString()).getNumber()").append(closeSuffix).append(");\n"); + sb.append(indent).append(" ").append(valueName) + .append("(io.suboptimal.buffjson.internal.FieldReader.enumNumber(reader, ").append(enumClass) + .append(".getDescriptor(), reader.readString())").append(closeSuffix).append(");\n"); sb.append(indent).append("} else {\n"); sb.append(indent).append(" ").append(valueName).append("(reader.readInt32Value()") .append(closeSuffix).append(");\n"); @@ -301,16 +303,16 @@ private static String mapKeyExpr(FieldDescriptor keyFd) { case INT -> { var type = keyFd.getType(); if (type == FieldDescriptor.Type.UINT32 || type == FieldDescriptor.Type.FIXED32) - yield "(int) Long.parseLong(keyStr)"; - yield "Integer.parseInt(keyStr)"; + yield "io.suboptimal.buffjson.internal.FieldReader.parseUnsignedIntKey(reader, keyStr)"; + yield "io.suboptimal.buffjson.internal.FieldReader.parseIntKey(reader, keyStr)"; } case LONG -> { var type = keyFd.getType(); if (type == FieldDescriptor.Type.UINT64 || type == FieldDescriptor.Type.FIXED64) - yield "Long.parseUnsignedLong(keyStr)"; - yield "Long.parseLong(keyStr)"; + yield "io.suboptimal.buffjson.internal.FieldReader.parseUnsignedLongKey(reader, keyStr)"; + yield "io.suboptimal.buffjson.internal.FieldReader.parseLongKey(reader, keyStr)"; } - case BOOLEAN -> "Boolean.parseBoolean(keyStr)"; + case BOOLEAN -> "io.suboptimal.buffjson.internal.FieldReader.parseBoolKey(reader, keyStr)"; default -> throw new IllegalArgumentException("Unsupported map key type: " + keyFd.getJavaType()); }; } diff --git a/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonHardeningTest.java b/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonHardeningTest.java new file mode 100644 index 0000000..e565258 --- /dev/null +++ b/buff-json-tests/src/test/java/io/suboptimal/buffjson/BuffJsonHardeningTest.java @@ -0,0 +1,359 @@ +package io.suboptimal.buffjson; + +import static org.junit.jupiter.api.Assertions.*; + +import com.alibaba.fastjson2.JSONException; +import com.google.protobuf.*; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import io.suboptimal.buffjson.proto.*; + +/** + * Decoder hardening tests for untrusted JSON input: + * + * + * + *

+ * Each behavior is checked on both the codegen and runtime decoder paths where + * applicable (both route scalar/WKT/Struct reads through the same helpers). + */ +class BuffJsonHardeningTest { + + private static final BuffJsonDecoder CODEGEN_DECODER = BuffJson.decoder(); + private static final BuffJsonDecoder RUNTIME_DECODER = BuffJson.decoder().setGeneratedDecoders(false); + + // ========================================================================= + // Recursion depth cap (StackOverflow protection) + // ========================================================================= + + @Nested + class RecursionDepth { + + private String nestedStruct(int depth) { + return "{\"structValue\":" + "{\"a\":".repeat(depth) + "1" + "}".repeat(depth) + "}"; + } + + private String nestedList(int depth) { + return "{\"listValue\":" + "[".repeat(depth) + "]".repeat(depth) + "}"; + } + + @Test + void deeplyNestedStructThrowsCleanly() { + String json = nestedStruct(500); + // Must be a clean JSONException, NOT a StackOverflowError. + JSONException ex = assertThrows(JSONException.class, () -> CODEGEN_DECODER.decode(json, TestStruct.class)); + assertTrue(ex.getMessage().contains("nesting depth"), + "Expected nesting-depth message, got: " + ex.getMessage()); + assertThrows(JSONException.class, () -> RUNTIME_DECODER.decode(json, TestStruct.class)); + } + + @Test + void deeplyNestedListValueThrowsCleanly() { + String json = nestedList(500); + JSONException ex = assertThrows(JSONException.class, () -> CODEGEN_DECODER.decode(json, TestStruct.class)); + assertTrue(ex.getMessage().contains("nesting depth"), + "Expected nesting-depth message, got: " + ex.getMessage()); + assertThrows(JSONException.class, () -> RUNTIME_DECODER.decode(json, TestStruct.class)); + } + + @Test + void deeplyNestedValueThrowsCleanly() { + // google.protobuf.Value dispatches into the same Struct recursion. + String json = "{\"value\":" + "{\"a\":".repeat(500) + "1" + "}".repeat(500) + "}"; + assertThrows(JSONException.class, () -> CODEGEN_DECODER.decode(json, TestStruct.class)); + } + + @Test + void moderatelyNestedStructDecodes() { + // Well under the limit — must still decode successfully on both paths. + String json = nestedStruct(50); + TestStruct codegen = CODEGEN_DECODER.decode(json, TestStruct.class); + assertTrue(codegen.hasStructValue()); + TestStruct runtime = RUNTIME_DECODER.decode(json, TestStruct.class); + assertEquals(codegen, runtime); + } + } + + // ========================================================================= + // Normalized parse errors carrying the JSON offset + // ========================================================================= + + @Nested + class ParseErrorOffset { + + private void assertJsonExceptionWithOffset(BuffJsonDecoder decoder, String json, Class clazz, + String hint) { + JSONException ex = assertThrows(JSONException.class, () -> decoder.decode(json, clazz)); + assertTrue(ex.getMessage().contains("offset"), "Expected JSON offset in message, got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains(hint), + "Expected hint '" + hint + "' in message, got: " + ex.getMessage()); + } + + @Test + void invalidInt64() { + String json = "{\"optionalInt64\": \"not-a-number\"}"; + assertJsonExceptionWithOffset(CODEGEN_DECODER, json, TestAllScalars.class, "int64"); + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestAllScalars.class, "int64"); + } + + @Test + void invalidUint64() { + String json = "{\"optionalUint64\": \"xyz\"}"; + assertJsonExceptionWithOffset(CODEGEN_DECODER, json, TestAllScalars.class, "uint64"); + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestAllScalars.class, "uint64"); + } + + @Test + void invalidDouble() { + String json = "{\"optionalDouble\": \"NotANumber\"}"; + assertJsonExceptionWithOffset(CODEGEN_DECODER, json, TestAllScalars.class, "double"); + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestAllScalars.class, "double"); + } + + @Test + void invalidFloat() { + String json = "{\"optionalFloat\": \"NotANumber\"}"; + assertJsonExceptionWithOffset(CODEGEN_DECODER, json, TestAllScalars.class, "float"); + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestAllScalars.class, "float"); + } + + @Test + void invalidTimestamp() { + String json = "{\"value\": \"not-a-timestamp\"}"; + assertJsonExceptionWithOffset(CODEGEN_DECODER, json, TestTimestamp.class, "timestamp"); + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestTimestamp.class, "timestamp"); + } + + @Test + void invalidDuration() { + String json = "{\"value\": \"3600\"}"; + assertJsonExceptionWithOffset(CODEGEN_DECODER, json, TestDuration.class, "duration"); + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestDuration.class, "duration"); + } + + @Test + void invalidIntMapKey() { + // Map keys go through FieldReader on the runtime path. + String json = "{\"int32ToString\": {\"not-a-number\": \"value\"}}"; + assertJsonExceptionWithOffset(RUNTIME_DECODER, json, TestMaps.class, "map key"); + } + } + + // ========================================================================= + // Any: @type-first (fast path) vs @type-after-content (slow path) parity + // ========================================================================= + + @Nested + class AnyFieldOrder { + + private static final TypeRegistry REGISTRY = TypeRegistry.newBuilder().add(TestAllScalars.getDescriptor()) + .add(Timestamp.getDescriptor()).add(TestAny.getDescriptor()).build(); + + // Separate decoder instances — setGeneratedDecoders mutates and returns this. + private static final BuffJsonDecoder CODEGEN = BuffJson.decoder().setTypeRegistry(REGISTRY); + private static final BuffJsonDecoder RUNTIME = BuffJson.decoder().setTypeRegistry(REGISTRY) + .setGeneratedDecoders(false); + + private void assertBothPaths(String json, TestAny expected) { + assertEquals(expected, CODEGEN.decode(json, TestAny.class), "codegen, json=" + json); + assertEquals(expected, RUNTIME.decode(json, TestAny.class), "runtime, json=" + json); + } + + @Test + void regularMessageTypeFirstAndLastAgree() { + TestAllScalars inner = TestAllScalars.newBuilder().setOptionalInt32(42).setOptionalString("hi").build(); + String typeUrl = Any.pack(inner).getTypeUrl(); + TestAny expected = TestAny.newBuilder().setValue(Any.pack(inner)).build(); + + String fast = "{\"value\":{\"@type\":\"" + typeUrl + "\",\"optionalInt32\":42,\"optionalString\":\"hi\"}}"; + String slow = "{\"value\":{\"optionalInt32\":42,\"optionalString\":\"hi\",\"@type\":\"" + typeUrl + "\"}}"; + String mid = "{\"value\":{\"optionalInt32\":42,\"@type\":\"" + typeUrl + "\",\"optionalString\":\"hi\"}}"; + + assertBothPaths(fast, expected); + assertBothPaths(slow, expected); + assertBothPaths(mid, expected); + } + + @Test + void wktTypeFirstAndLastAgree() { + Timestamp ts = Timestamp.newBuilder().setSeconds(1704067200L).build(); // 2024-01-01T00:00:00Z + String typeUrl = Any.pack(ts).getTypeUrl(); + TestAny expected = TestAny.newBuilder().setValue(Any.pack(ts)).build(); + + String fast = "{\"value\":{\"@type\":\"" + typeUrl + "\",\"value\":\"2024-01-01T00:00:00Z\"}}"; + String slow = "{\"value\":{\"value\":\"2024-01-01T00:00:00Z\",\"@type\":\"" + typeUrl + "\"}}"; + + assertBothPaths(fast, expected); + assertBothPaths(slow, expected); + } + + @Test + void emptyTypeUrlFirstYieldsDefault() { + // @type present but empty, with trailing content — degrades to default Any. + String json = "{\"value\":{\"@type\":\"\",\"optionalInt32\":42}}"; + TestAny expected = TestAny.newBuilder().setValue(Any.getDefaultInstance()).build(); + assertBothPaths(json, expected); + } + } + + // ========================================================================= + // Native fastjson2 error type: encode/decode failures throw JSONException + // (not IllegalArgumentException / IllegalStateException / + // NumberFormatException) + // ========================================================================= + + @Nested + class NativeErrorType { + + private void assertDecodeThrowsJson(String json, Class clazz) { + assertThrows(JSONException.class, () -> CODEGEN_DECODER.decode(json, clazz), + "codegen should throw JSONException for: " + json); + assertThrows(JSONException.class, () -> RUNTIME_DECODER.decode(json, clazz), + "runtime should throw JSONException for: " + json); + } + + @Test + void invalidBase64() { + assertDecodeThrowsJson("{\"optionalBytes\": \"not-valid-base64!!!\"}", TestAllScalars.class); + } + + @Test + void unknownEnumName() { + assertDecodeThrowsJson("{\"enumValue\": \"DOES_NOT_EXIST\"}", TestNesting.class); + } + + @Test + void invalidNumericMapKey() { + // Now normalized on both codegen and runtime paths. + assertDecodeThrowsJson("{\"int32ToString\": {\"not-a-number\": \"v\"}}", TestMaps.class); + } + + @Test + void anyDecodeWithoutRegistry() { + // Missing TypeRegistry is a server-side config error, not bad input. + String json = "{\"value\":{\"@type\":\"type.googleapis.com/io.suboptimal.buffjson.proto.NestedMessage\"," + + "\"value\":1}}"; + assertThrows(IllegalStateException.class, () -> BuffJson.decoder().decode(json, TestAny.class)); + } + + @Test + void anyDecodeUnregisteredType() { + // Client submitted an @type the (configured) registry doesn't know — + // user-facing, + // so JSONException with the JSON offset. + var decoder = BuffJson.decoder() + .setTypeRegistry(TypeRegistry.newBuilder().add(TestAllScalars.getDescriptor()).build()); + String json = "{\"value\":{\"@type\":\"type.googleapis.com/some.unknown.Type\",\"value\":1}}"; + JSONException ex = assertThrows(JSONException.class, () -> decoder.decode(json, TestAny.class)); + assertTrue(ex.getMessage().contains("offset"), "Expected JSON offset in message, got: " + ex.getMessage()); + } + + @Test + void anyEncodeWithoutRegistry() { + // Missing TypeRegistry on the encoder is a server-side config error. + var any = Any.pack(NestedMessage.newBuilder().setValue(1).setName("x").build()); + var msg = TestAny.newBuilder().setValue(any).build(); + assertThrows(IllegalStateException.class, () -> BuffJson.encoder().encode(msg)); + } + } + + // ========================================================================= + // Any-packed WKT with null/missing "value" must not NullPointerException + // ========================================================================= + + @Nested + class PackedWktNullValue { + + private static final TypeRegistry REGISTRY = TypeRegistry.newBuilder().add(Timestamp.getDescriptor()) + .add(BytesValue.getDescriptor()).add(Value.getDescriptor()).add(TestAny.getDescriptor()).build(); + private static final BuffJsonDecoder CODEGEN = BuffJson.decoder().setTypeRegistry(REGISTRY); + private static final BuffJsonDecoder RUNTIME = BuffJson.decoder().setTypeRegistry(REGISTRY) + .setGeneratedDecoders(false); + + private void assertDecodes(String json, TestAny expected) { + assertEquals(expected, CODEGEN.decode(json, TestAny.class), "codegen, json=" + json); + assertEquals(expected, RUNTIME.decode(json, TestAny.class), "runtime, json=" + json); + } + + @Test + void timestampNullValueDecodesToDefaultBothOrders() { + String url = Any.pack(Timestamp.getDefaultInstance()).getTypeUrl(); + TestAny expected = TestAny.newBuilder().setValue(Any.pack(Timestamp.getDefaultInstance())).build(); + // fast path (@type first) and slow path (@type last) — neither may NPE + assertDecodes("{\"value\":{\"@type\":\"" + url + "\",\"value\":null}}", expected); + assertDecodes("{\"value\":{\"value\":null,\"@type\":\"" + url + "\"}}", expected); + } + + @Test + void timestampMissingValueDecodesToDefault() { + String url = Any.pack(Timestamp.getDefaultInstance()).getTypeUrl(); + TestAny expected = TestAny.newBuilder().setValue(Any.pack(Timestamp.getDefaultInstance())).build(); + assertDecodes("{\"value\":{\"@type\":\"" + url + "\"}}", expected); + } + + @Test + void bytesValueNullValueDecodesToDefault() { + BytesValue def = BytesValue.getDefaultInstance(); + String url = Any.pack(def).getTypeUrl(); + TestAny expected = TestAny.newBuilder().setValue(Any.pack(def)).build(); + assertDecodes("{\"value\":{\"@type\":\"" + url + "\",\"value\":null}}", expected); + } + + @Test + void valueNullBecomesNullValue() { + // google.protobuf.Value with explicit null → NullValue (mirrors the field + // path). + Value nullVal = Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + String url = Any.pack(Value.getDefaultInstance()).getTypeUrl(); + TestAny expected = TestAny.newBuilder().setValue(Any.pack(nullVal)).build(); + assertDecodes("{\"value\":{\"@type\":\"" + url + "\",\"value\":null}}", expected); + assertDecodes("{\"value\":{\"value\":null,\"@type\":\"" + url + "\"}}", expected); + } + } + + // ========================================================================= + // Bool map keys: only "true"/"false" accepted; bad keys throw (no silent + // coerce) + // ========================================================================= + + @Nested + class BoolMapKey { + + @Test + void validBoolKeysDecodeOnBothPaths() { + String json = "{\"boolToString\":{\"true\":\"t\",\"false\":\"f\"}}"; + TestMaps codegen = CODEGEN_DECODER.decode(json, TestMaps.class); + assertEquals("t", codegen.getBoolToStringMap().get(true)); + assertEquals("f", codegen.getBoolToStringMap().get(false)); + assertEquals(codegen, RUNTIME_DECODER.decode(json, TestMaps.class)); + } + + @Test + void invalidBoolKeyThrowsBothPaths() { + // Was silently coerced to false (data loss); now a clean JSONException. + String json = "{\"boolToString\":{\"garbage\":\"v\"}}"; + assertThrows(JSONException.class, () -> CODEGEN_DECODER.decode(json, TestMaps.class)); + assertThrows(JSONException.class, () -> RUNTIME_DECODER.decode(json, TestMaps.class)); + } + + @Test + void wrongCaseBoolKeyThrowsBothPaths() { + // "TRUE" was silently accepted as true; proto3 keys are lowercase. + String json = "{\"boolToString\":{\"TRUE\":\"v\"}}"; + assertThrows(JSONException.class, () -> CODEGEN_DECODER.decode(json, TestMaps.class)); + assertThrows(JSONException.class, () -> RUNTIME_DECODER.decode(json, TestMaps.class)); + } + } +} diff --git a/buff-json/CLAUDE.md b/buff-json/CLAUDE.md index 82305dc..35fcf2e 100644 --- a/buff-json/CLAUDE.md +++ b/buff-json/CLAUDE.md @@ -115,6 +115,28 @@ JSON.parseObject(json, MyMessage.class); // uses the reader's settings - **Duration nanos**: Format to 3, 6, or 9 digits (not arbitrary precision) - **Any**: Requires TypeRegistry. Regular messages: `{"@type":..., ...fields}`. WKTs: `{"@type":..., "value":...}` +## Decoder Input Hardening (untrusted JSON) + +The decoder consumes untrusted JSON, so a few defenses are built into the read path. All are zero-cost on the success path. + +- **Recursion depth cap (`WellKnownTypes.MAX_RECURSION_DEPTH = 100`)**: The `Struct`/`Value`/`ListValue` reader (`readStruct`/`readListValue`/`readJsonValueImpl`) threads an `int depth` and throws a clean `JSONException` past 100 levels instead of `StackOverflowError`. 100 matches protobuf's own limit (`CodedInputStream.DEFAULT_RECURSION_LIMIT` and `JsonFormat.Parser`'s default). Public single-arg entry points (`readStruct(reader)`, etc.) delegate to private `(reader, depth)` overloads, so generated decoders keep calling the unchanged signatures — no codegen ABI change. Note: this caps the universal Struct/Value/ListValue vector; arbitrary message nesting (self-referential message types) is not capped because that would require threading depth through the `BuffJsonGeneratedDecoder` ABI. +- **Any `@type`-first fast path** (`WellKnownTypes.readAny`): the canonical proto3 form lists `@type` first, so the descriptor is resolved before any content and the remaining fields are decoded straight off the live reader via `ProtobufMessageReader.readRemainingMessageFields` (regular messages → `DynamicMessage`) or direct WKT read — no `LinkedHashMap` buffering, no `JSON.toJSONString` + re-parse. The buffer-and-reparse slow path is retained only for the rare case where `@type` arrives after content. + +## Error Contract: `JSONException` for bad input, JDK exceptions for config errors + +Errors are split by *who caused them*, so a config bug never masquerades as "bad JSON": + +- **User-facing — bad untrusted JSON content → `com.alibaba.fastjson2.JSONException`** (fastjson2's native type), with position context attached via `JSONReader.info(msg)` (appends offset/line/column — note fastjson2 also appends the input document to the message). Callers catch one type for any malformed payload, on **all three paths** (codegen, typed, reflection). Covers: malformed int64/uint64/float/double, timestamp, duration, base64, enum names, numeric map keys, JSON nesting depth, and a malformed/unregistered `@type` the client submitted in an `Any`. +- **Internal — server config / programmer / unreachable invariants → JDK `IllegalStateException`/`IllegalArgumentException`** (unchanged from fastjson-agnostic behavior). These are *not* driven by untrusted input, so they stay distinguishable. Covers: missing `TypeRegistry` on the encoder or decoder, encode-side `Any` type-resolution/content-parse failures (the server is serializing its own data), a bad target `Class` passed to `decode`, and the unreachable "Unknown well-known type" / "Unsupported map key type" guard arms. + +Implementation: + +- **Where conversion happens** — value parsing lives in `FieldReader` helpers (`readSignedLong`, `readUnsignedLong`, `readFloatValue`, `readDoubleValue`, `readBytes`, `enumNumber`, `parseIntKey`/`parseUnsignedIntKey`/`parseLongKey`/`parseUnsignedLongKey`) and `WellKnownTypes` (`readTimestamp`, `readDuration`, `readAny`/`resolveAnyType`). Each wraps the JDK exception (`NumberFormatException`, `DateTimeParseException`, base64/enum `IllegalArgumentException`) and rethrows `JSONException`, preserving the original as the cause. +- **Codegen routes through the same helpers** — `DecoderGenerator` emits calls to `FieldReader.readBytes`/`enumNumber`/`parse*Key` (not inline `BASE64.decode`/`Enum.valueOf`/`Long.parseLong`), so generated decoders get the identical contract without duplicating try/catch. These helpers are `public` precisely because generated code lives in the user's package. +- **`try/catch` is free on the success path** (HotSpot exception tables), so this is zero-cost normalization. +- **Internal helpers stay JDK-typed** — `parseTimestamp`/`parseDuration` throw `DateTimeParseException`/`IllegalArgumentException` but are always wrapped by their `read*` callers, so the type never escapes (`readDuration`'s `catch (IllegalArgumentException)` depends on this). +- **`Any` registry split** — in `resolveAnyType`, a `null` registry → `IllegalStateException` (decoder was never configured); a well-formed-but-unregistered or malformed `@type` → `JSONException` + offset (the client sent it). + ## Dependencies - `com.google.protobuf:protobuf-java` — Message, Descriptor, TypeRegistry, DynamicMessage diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/FieldReader.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/FieldReader.java index 1d9ebdd..3615d36 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/FieldReader.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/FieldReader.java @@ -2,9 +2,11 @@ import java.util.Base64; +import com.alibaba.fastjson2.JSONException; import com.alibaba.fastjson2.JSONReader; import com.google.protobuf.ByteString; import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.EnumDescriptor; import com.google.protobuf.Descriptors.EnumValueDescriptor; import com.google.protobuf.Descriptors.FieldDescriptor; import com.google.protobuf.DynamicMessage; @@ -49,12 +51,26 @@ public static Object readValue(JSONReader reader, FieldDescriptor fd, ProtobufMe case DOUBLE -> readDoubleValue(reader); case BOOLEAN -> reader.readBoolValue(); case STRING -> reader.readString(); - case BYTE_STRING -> ByteString.copyFrom(BASE64.decode(reader.readString())); + case BYTE_STRING -> readBytes(reader); case ENUM -> readEnumValue(reader, fd); case MESSAGE -> readMessageValue(reader, fd, msgReader); }; } + /** + * Reads a base64 string into a {@link ByteString}, normalizing decode errors to + * {@link JSONException}. Public so generated decoders (in other packages) can + * call it. + */ + public static ByteString readBytes(JSONReader reader) { + String s = reader.readString(); + try { + return ByteString.copyFrom(BASE64.decode(s)); + } catch (IllegalArgumentException e) { + throw new JSONException(reader.info("Invalid base64 value"), e); + } + } + private static int readIntValue(JSONReader reader, FieldDescriptor fd) { var type = fd.getType(); if (type == FieldDescriptor.Type.UINT32 || type == FieldDescriptor.Type.FIXED32) { @@ -75,7 +91,12 @@ private static long readLongValue(JSONReader reader, FieldDescriptor fd) { /** Reads a signed int64 value that may be a quoted string or number. */ public static long readSignedLong(JSONReader reader) { if (reader.isString()) { - return Long.parseLong(reader.readString()); + String s = reader.readString(); + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid int64 value"), e); + } } return reader.readInt64Value(); } @@ -83,7 +104,12 @@ public static long readSignedLong(JSONReader reader) { /** Reads an unsigned uint64 value that may be a quoted string or number. */ public static long readUnsignedLong(JSONReader reader) { if (reader.isString()) { - return Long.parseUnsignedLong(reader.readString()); + String s = reader.readString(); + try { + return Long.parseUnsignedLong(s); + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid uint64 value"), e); + } } return reader.readInt64Value(); } @@ -95,12 +121,16 @@ public static long readUnsignedLong(JSONReader reader) { public static float readFloatValue(JSONReader reader) { if (reader.isString()) { String s = reader.readString(); - return switch (s) { - case "NaN" -> Float.NaN; - case "Infinity" -> Float.POSITIVE_INFINITY; - case "-Infinity" -> Float.NEGATIVE_INFINITY; - default -> Float.parseFloat(s); - }; + try { + return switch (s) { + case "NaN" -> Float.NaN; + case "Infinity" -> Float.POSITIVE_INFINITY; + case "-Infinity" -> Float.NEGATIVE_INFINITY; + default -> Float.parseFloat(s); + }; + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid float value"), e); + } } return reader.readFloatValue(); } @@ -112,25 +142,23 @@ public static float readFloatValue(JSONReader reader) { public static double readDoubleValue(JSONReader reader) { if (reader.isString()) { String s = reader.readString(); - return switch (s) { - case "NaN" -> Double.NaN; - case "Infinity" -> Double.POSITIVE_INFINITY; - case "-Infinity" -> Double.NEGATIVE_INFINITY; - default -> Double.parseDouble(s); - }; + try { + return switch (s) { + case "NaN" -> Double.NaN; + case "Infinity" -> Double.POSITIVE_INFINITY; + case "-Infinity" -> Double.NEGATIVE_INFINITY; + default -> Double.parseDouble(s); + }; + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid double value"), e); + } } return reader.readDoubleValue(); } private static EnumValueDescriptor readEnumValue(JSONReader reader, FieldDescriptor fd) { if (reader.isString()) { - String name = reader.readString(); - EnumValueDescriptor evd = fd.getEnumType().findValueByName(name); - if (evd == null) { - throw new IllegalArgumentException( - "Unknown enum value: " + name + " for " + fd.getEnumType().getFullName()); - } - return evd; + return enumValueByName(reader, fd.getEnumType(), reader.readString()); } int number = reader.readInt32Value(); EnumValueDescriptor evd = fd.getEnumType().findValueByNumber(number); @@ -142,6 +170,28 @@ private static EnumValueDescriptor readEnumValue(JSONReader reader, FieldDescrip return fd.getEnumType().findValueByNumber(0); } + /** + * Resolves an enum value name to its descriptor, throwing {@link JSONException} + * (not {@code IllegalArgumentException}) on an unknown name. Shared by the + * reflection path and {@link #enumNumber}. + */ + private static EnumValueDescriptor enumValueByName(JSONReader reader, EnumDescriptor enumType, String name) { + EnumValueDescriptor evd = enumType.findValueByName(name); + if (evd == null) { + throw new JSONException(reader.info("Unknown enum value: " + name + " for " + enumType.getFullName())); + } + return evd; + } + + /** + * Resolves an enum value name to its number, throwing {@link JSONException} on + * an unknown name. Public so generated decoders (in other packages) can call it + * instead of {@code Enum.valueOf}. + */ + public static int enumNumber(JSONReader reader, EnumDescriptor enumType, String name) { + return enumValueByName(reader, enumType, name).getNumber(); + } + private static Message readMessageValue(JSONReader reader, FieldDescriptor fd, ProtobufMessageReader msgReader) { Descriptor msgDesc = fd.getMessageType(); if (WellKnownTypes.isWellKnownType(msgDesc)) { @@ -181,7 +231,7 @@ public static void readMap(JSONReader reader, Message.Builder builder, FieldDesc break; } - Object key = parseMapKey(keyStr, keyFd); + Object key = parseMapKey(reader, keyStr, keyFd); Object value; if (reader.nextIfNull()) { @@ -197,28 +247,86 @@ public static void readMap(JSONReader reader, Message.Builder builder, FieldDesc } } - private static Object parseMapKey(String keyStr, FieldDescriptor keyFd) { + private static Object parseMapKey(JSONReader reader, String keyStr, FieldDescriptor keyFd) { return switch (keyFd.getJavaType()) { case STRING -> keyStr; case INT -> { var type = keyFd.getType(); if (type == FieldDescriptor.Type.UINT32 || type == FieldDescriptor.Type.FIXED32) { - yield (int) Long.parseLong(keyStr); + yield parseUnsignedIntKey(reader, keyStr); } - yield Integer.parseInt(keyStr); + yield parseIntKey(reader, keyStr); } case LONG -> { var type = keyFd.getType(); if (type == FieldDescriptor.Type.UINT64 || type == FieldDescriptor.Type.FIXED64) { - yield Long.parseUnsignedLong(keyStr); + yield parseUnsignedLongKey(reader, keyStr); } - yield Long.parseLong(keyStr); + yield parseLongKey(reader, keyStr); } - case BOOLEAN -> Boolean.parseBoolean(keyStr); + case BOOLEAN -> parseBoolKey(reader, keyStr); + // Unreachable for valid descriptors — proto restricts map keys to + // integral/bool/string. Internal invariant, not untrusted input. default -> throw new IllegalArgumentException("Unsupported map key type: " + keyFd.getJavaType()); }; } + // Map-key parse helpers — normalize NumberFormatException to JSONException with + // the JSON offset. Public so generated decoders (in other packages) can call + // them. + + /** Parses a signed int32 map key. */ + public static int parseIntKey(JSONReader reader, String keyStr) { + try { + return Integer.parseInt(keyStr); + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid int32 map key"), e); + } + } + + /** Parses an unsigned uint32/fixed32 map key. */ + public static int parseUnsignedIntKey(JSONReader reader, String keyStr) { + try { + return (int) Long.parseLong(keyStr); + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid uint32 map key"), e); + } + } + + /** Parses a signed int64 map key. */ + public static long parseLongKey(JSONReader reader, String keyStr) { + try { + return Long.parseLong(keyStr); + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid int64 map key"), e); + } + } + + /** Parses an unsigned uint64/fixed64 map key. */ + public static long parseUnsignedLongKey(JSONReader reader, String keyStr) { + try { + return Long.parseUnsignedLong(keyStr); + } catch (NumberFormatException e) { + throw new JSONException(reader.info("Invalid uint64 map key"), e); + } + } + + /** + * Parses a bool map key. Proto3 JSON only allows the exact strings + * {@code "true"}/{@code "false"}; anything else throws {@link JSONException} + * rather than silently coercing to {@code false} (which would also collide keys + * — e.g. {@code "true"} and {@code "TRUE"} both mapping to {@code true}). + */ + public static boolean parseBoolKey(JSONReader reader, String keyStr) { + if ("true".equals(keyStr)) { + return true; + } + if ("false".equals(keyStr)) { + return false; + } + throw new JSONException(reader.info("Invalid bool map key")); + } + private static Object getDefaultMapValue(FieldDescriptor valueFd) { return switch (valueFd.getJavaType()) { case INT -> 0; diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/ProtobufMessageReader.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/ProtobufMessageReader.java index 3f66279..3bf945e 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/ProtobufMessageReader.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/ProtobufMessageReader.java @@ -116,9 +116,32 @@ public Message readMessage(JSONReader reader, Descriptor descriptor, Message def */ Message readMessageRuntime(JSONReader reader, Descriptor descriptor, Message defaultInstance) { Message.Builder builder = defaultInstance.newBuilderForType(); + reader.nextIfObjectStart(); + readFieldsInto(reader, builder, descriptor); + return builder.build(); + } + + /** + * Reads a {@code DynamicMessage} of {@code descriptor} from the reader's + * current position — the object-start (and possibly some leading + * fields) have already been consumed by the caller. Used by the Any + * {@code @type}-first fast path to decode content fields directly off the live + * reader without buffering and re-parsing. + */ + public Message readRemainingMessageFields(JSONReader reader, Descriptor descriptor) { + Message.Builder builder = DynamicMessage.newBuilder(descriptor); + readFieldsInto(reader, builder, descriptor); + return builder.build(); + } + + /** + * Reads JSON object fields into {@code builder} until object-end. Assumes the + * reader is positioned just after the opening brace (at the first field name or + * the closing brace). + */ + private void readFieldsInto(JSONReader reader, Message.Builder builder, Descriptor descriptor) { MessageSchema schema = MessageSchema.forDescriptor(descriptor); - reader.nextIfObjectStart(); while (!reader.nextIfObjectEnd()) { String fieldName = reader.readFieldName(); if (fieldName == null) { @@ -151,8 +174,6 @@ Message readMessageRuntime(JSONReader reader, Descriptor descriptor, Message def builder.setField(fd, value); } } - - return builder.build(); } @SuppressWarnings("unchecked") diff --git a/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java b/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java index 161644e..6de27d0 100644 --- a/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java +++ b/buff-json/src/main/java/io/suboptimal/buffjson/internal/WellKnownTypes.java @@ -1,6 +1,7 @@ package io.suboptimal.buffjson.internal; import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.Base64; import java.util.LinkedHashMap; import java.util.List; @@ -8,6 +9,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import com.alibaba.fastjson2.JSONException; import com.alibaba.fastjson2.JSONReader; import com.alibaba.fastjson2.JSONWriter; import com.google.protobuf.*; @@ -118,6 +120,8 @@ private static void writeAny(JSONWriter jsonWriter, Message message, ProtobufMes TypeRegistry registry = writer.typeRegistry(); if (registry == null) { + // Server-side configuration error (encoder has no registry) — not driven by + // untrusted input, so an IllegalStateException, not a JSONException. throw new IllegalStateException("Cannot serialize google.protobuf.Any without a TypeRegistry. " + "Use BuffJson.encoder().setTypeRegistry(registry).encode(message)."); } @@ -510,6 +514,22 @@ private static String snakeToCamel(String snake) { // Deserialization (JSON → protobuf) // ========================================================================= + /** + * Maximum nesting depth for the recursive {@code Struct}/{@code Value}/ + * {@code ListValue} reader. Matches protobuf's own recursion limit + * ({@code CodedInputStream.DEFAULT_RECURSION_LIMIT} and + * {@code JsonFormat.Parser}'s default, both 100), so deeply nested untrusted + * JSON fails with a clean {@link JSONException} instead of a + * {@code StackOverflowError}. + */ + private static final int MAX_RECURSION_DEPTH = 100; + + private static void checkDepth(JSONReader reader, int depth) { + if (depth > MAX_RECURSION_DEPTH) { + throw new JSONException(reader.info("JSON nesting depth exceeds " + MAX_RECURSION_DEPTH)); + } + } + /** * Reads a well-known type from JSON. Dispatches by descriptor full name. */ @@ -530,8 +550,7 @@ public static Message readWkt(JSONReader reader, Descriptor descriptor, Protobuf case "google.protobuf.UInt32Value" -> UInt32Value.of((int) reader.readInt64Value()); case "google.protobuf.BoolValue" -> BoolValue.of(reader.readBoolValue()); case "google.protobuf.StringValue" -> StringValue.of(reader.readString()); - case "google.protobuf.BytesValue" -> - BytesValue.of(ByteString.copyFrom(FieldReader.BASE64.decode(reader.readString()))); + case "google.protobuf.BytesValue" -> BytesValue.of(FieldReader.readBytes(reader)); default -> throw new IllegalArgumentException("Unknown well-known type: " + descriptor.getFullName()); }; } @@ -539,7 +558,12 @@ public static Message readWkt(JSONReader reader, Descriptor descriptor, Protobuf /** Reads an RFC 3339 timestamp string and returns a Timestamp message. */ public static Timestamp readTimestamp(JSONReader reader) { String rfc3339 = reader.readString(); - return parseTimestamp(rfc3339); + try { + return parseTimestamp(rfc3339); + } catch (DateTimeParseException e) { + // Normalize to JSONException and attach position context via reader.info(...). + throw new JSONException(reader.info("Invalid RFC 3339 timestamp for google.protobuf.Timestamp"), e); + } } /** @@ -547,7 +571,13 @@ public static Timestamp readTimestamp(JSONReader reader) { */ public static Duration readDuration(JSONReader reader) { String s = reader.readString(); - return parseDuration(s); + try { + return parseDuration(s); + } catch (IllegalArgumentException e) { + // IllegalArgumentException covers NumberFormatException (parse) and the + // explicit "missing 's' suffix" check in parseDuration. + throw new JSONException(reader.info("Invalid duration for google.protobuf.Duration"), e); + } } static Timestamp parseTimestamp(String rfc3339) { @@ -600,6 +630,11 @@ private static FieldMask readFieldMask(JSONReader reader) { /** Reads a native JSON object as a protobuf Struct. */ public static Struct readStruct(JSONReader reader) { + return readStruct(reader, 1); + } + + private static Struct readStruct(JSONReader reader, int depth) { + checkDepth(reader, depth); Struct.Builder builder = Struct.newBuilder(); reader.nextIfObjectStart(); while (!reader.nextIfObjectEnd()) { @@ -607,7 +642,7 @@ public static Struct readStruct(JSONReader reader) { if (key == null) { break; } - Value value = readJsonValueImpl(reader); + Value value = readJsonValueImpl(reader, depth); builder.putFields(key, value); } return builder.build(); @@ -615,10 +650,10 @@ public static Struct readStruct(JSONReader reader) { /** Reads a native JSON value as a protobuf Value (dispatch on token type). */ public static Value readJsonValue(JSONReader reader) { - return readJsonValueImpl(reader); + return readJsonValueImpl(reader, 1); } - private static Value readJsonValueImpl(JSONReader reader) { + private static Value readJsonValueImpl(JSONReader reader, int depth) { if (reader.nextIfNull()) { return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); } @@ -630,10 +665,10 @@ private static Value readJsonValueImpl(JSONReader reader) { return Value.newBuilder().setBoolValue(reader.readBoolValue()).build(); } if (c == '{') { - return Value.newBuilder().setStructValue(readStruct(reader)).build(); + return Value.newBuilder().setStructValue(readStruct(reader, depth + 1)).build(); } if (c == '[') { - return Value.newBuilder().setListValue(readListValue(reader)).build(); + return Value.newBuilder().setListValue(readListValue(reader, depth + 1)).build(); } // Must be a number return Value.newBuilder().setNumberValue(reader.readDoubleValue()).build(); @@ -641,69 +676,173 @@ private static Value readJsonValueImpl(JSONReader reader) { /** Reads a native JSON array as a protobuf ListValue. */ public static ListValue readListValue(JSONReader reader) { + return readListValue(reader, 1); + } + + private static ListValue readListValue(JSONReader reader, int depth) { + checkDepth(reader, depth); ListValue.Builder builder = ListValue.newBuilder(); reader.nextIfArrayStart(); while (!reader.nextIfArrayEnd()) { - builder.addValues(readJsonValueImpl(reader)); + builder.addValues(readJsonValueImpl(reader, depth)); } return builder.build(); } private static Any readAny(JSONReader reader, ProtobufMessageReader msgReader) { - // Read the entire JSON object into a map to extract @type first reader.nextIfObjectStart(); + if (reader.nextIfObjectEnd()) { + return Any.getDefaultInstance(); + } + + String firstKey = reader.readFieldName(); + + // Fast path: the canonical proto3 form lists "@type" first. When it does, we + // can resolve the content descriptor before reading any content and decode + // the remaining fields straight off the live reader — no buffering into a + // Map, no JSON.toJSONString + re-parse (which roughly doubles the work). + if ("@type".equals(firstKey)) { + String typeUrl = reader.readString(); + if (typeUrl == null || typeUrl.isEmpty()) { + skipToObjectEnd(reader); + return Any.getDefaultInstance(); + } + Descriptor type = resolveAnyType(reader, typeUrl, msgReader); + Message content = isWellKnownType(type) + ? readPackedWktValue(reader, type, msgReader) + : msgReader.readRemainingMessageFields(reader, type); + return Any.pack(content); + } + + // Slow path: "@type" appears after content (or is absent), so we cannot know + // the descriptor up front. Buffer every field, then resolve and re-parse. String typeUrl = null; Map allFields = new LinkedHashMap<>(); - while (!reader.nextIfObjectEnd()) { - String key = reader.readFieldName(); - if (key == null) { - break; - } + String key = firstKey; + while (key != null) { if ("@type".equals(key)) { typeUrl = reader.readString(); } else { allFields.put(key, reader.readAny()); } + if (reader.nextIfObjectEnd()) { + break; + } + key = reader.readFieldName(); } if (typeUrl == null || typeUrl.isEmpty()) { return Any.getDefaultInstance(); } + Descriptor type = resolveAnyType(reader, typeUrl, msgReader); + + Message contentMessage; + if (isWellKnownType(type)) { + // WKT packed in Any: {"@type": "...", "value": } + Object valueObj = allFields.get("value"); + if (valueObj == null) { + // Missing or explicit-null "value": match the fast path / top-level field + // rule instead of stringifying null and NPE-ing inside the WKT reader. + contentMessage = allFields.containsKey("value") + ? nullPackedWktValue(type) + : DynamicMessage.getDefaultInstance(type); + } else { + String valueJson = com.alibaba.fastjson2.JSON.toJSONString(valueObj); + try (JSONReader valueReader = JSONReader.of(valueJson)) { + contentMessage = readWkt(valueReader, type, msgReader); + } + } + } else { + // Regular message: {"@type": "...", ...fields...} + String fieldsJson = com.alibaba.fastjson2.JSON.toJSONString(allFields); + try (JSONReader fieldsReader = JSONReader.of(fieldsJson)) { + contentMessage = msgReader.readMessage(fieldsReader, type); + } + } + + return Any.pack(contentMessage); + } + + /** + * Resolves an Any {@code @type} URL (from untrusted JSON) to its message + * descriptor via the registry. + * + *

+ * A missing {@link TypeRegistry} is a server-side configuration error + * (the decoder was never given a registry) and throws + * {@link IllegalStateException}. A malformed or unregistered {@code @type} is a + * client input error and throws {@link JSONException} with the JSON + * offset. + */ + private static Descriptor resolveAnyType(JSONReader reader, String typeUrl, ProtobufMessageReader msgReader) { TypeRegistry registry = msgReader.typeRegistry(); if (registry == null) { throw new IllegalStateException("Cannot deserialize google.protobuf.Any without a TypeRegistry. " + "Use BuffJson.decoder().setTypeRegistry(registry).decode(json, clazz)."); } - Descriptor type; try { type = registry.getDescriptorForTypeUrl(typeUrl); } catch (InvalidProtocolBufferException e) { - throw new IllegalStateException("Invalid type URL in Any: " + typeUrl, e); + throw new JSONException(reader.info("Invalid type URL in google.protobuf.Any"), e); } if (type == null) { - throw new IllegalStateException("Cannot find type for url: " + typeUrl); + throw new JSONException(reader.info("Cannot find type for url: " + typeUrl)); } + return type; + } - Message contentMessage; - if (isWellKnownType(type)) { - // WKT packed in Any: {"@type": "...", "value": } - Object valueObj = allFields.get("value"); - String valueJson = com.alibaba.fastjson2.JSON.toJSONString(valueObj); - try (JSONReader valueReader = JSONReader.of(valueJson)) { - contentMessage = readWkt(valueReader, type, msgReader); + /** + * Fast-path read of a well-known type packed in an Any whose {@code @type} was + * already consumed. Reads the remaining fields off the live reader, parsing the + * {@code "value"} field directly. A JSON {@code null} value is handled like the + * top-level field path ({@code google.protobuf.Value} → {@code NullValue}, + * other WKTs → default instance) rather than fed into the WKT reader (which + * would {@code NullPointerException}). Returns the type's default instance if + * no {@code "value"} field is present. + */ + private static Message readPackedWktValue(JSONReader reader, Descriptor type, ProtobufMessageReader msgReader) { + Message content = null; + while (!reader.nextIfObjectEnd()) { + String name = reader.readFieldName(); + if (name == null) { + break; } - } else { - // Regular message: {"@type": "...", ...fields...} - String fieldsJson = com.alibaba.fastjson2.JSON.toJSONString(allFields); - try (JSONReader fieldsReader = JSONReader.of(fieldsJson)) { - contentMessage = msgReader.readMessage(fieldsReader, type); + if ("value".equals(name)) { + content = reader.nextIfNull() ? nullPackedWktValue(type) : readWkt(reader, type, msgReader); + } else { + reader.skipValue(); } } + return content != null ? content : DynamicMessage.getDefaultInstance(type); + } - return Any.pack(contentMessage); + /** + * Content message for a packed WKT whose {@code "value"} is an explicit JSON + * {@code null}. Mirrors the top-level field rule: {@code google.protobuf.Value} + * becomes {@code NullValue}; every other WKT becomes its default instance. + */ + private static Message nullPackedWktValue(Descriptor type) { + if ("google.protobuf.Value".equals(type.getFullName())) { + return Value.newBuilder().setNullValue(NullValue.NULL_VALUE).build(); + } + return DynamicMessage.getDefaultInstance(type); + } + + /** + * Skips any remaining entries of the current JSON object and consumes its + * closing brace. fastjson2 has no single "skip rest of object" call, so this + * uses its {@code skipName()}/{@code skipValue()} pair — the same idiom + * fastjson2 applies internally — where {@code skipName()} advances past the key + * and colon without materializing the (discarded) key String. + */ + private static void skipToObjectEnd(JSONReader reader) { + while (!reader.nextIfObjectEnd()) { + reader.skipName(); + reader.skipValue(); + } } /**