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 extends Message> 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 extends Message> 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
+ * 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":