Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions buff-json-protoc-plugin/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ For each non-WKT, non-map-entry message type:
- **Deprecated fields/types** — included in generated codecs. Both codec classes suppress Java deprecation warnings so generated calls compile with `-Werror`; protobuf deprecation does not change JSON semantics.
- **Unsigned map keys** — uint32/fixed32 use `Integer.toUnsignedLong`; uint64/fixed64 use `WellKnownTypes.writeUnsignedLongString`. Keys always remain quoted JSON strings. Long-key writes share `FieldWriter.writeLongMapKey`, which preserves key spelling under BrowserCompatible and WriteClassName; boolean keys use constant strings.

- **Container checks** — generated decoders call `FieldReader.requireObjectStart` (messages, maps, inline `Empty`), and `requireArrayStart` (repeated fields) instead of ignoring the result of `nextIfObjectStart()`/`nextIfArrayStart()`. A reader that returned without consuming a non-object token made the enclosing array loop spin until `OutOfMemoryError`. Rebuild consumers with `mvn clean install`: the protobuf plugin only regenerates when `.proto` inputs change.
- **`google.protobuf.Empty`** is NOT in the WKT set — it serializes as a regular empty message `{}`
- **`DynamicMessage`** cannot use generated encoders (would fail cast) — guarded in `ProtobufMessageWriter`
- **Map entry types** (`options.map_entry = true`) are skipped — they're synthetic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
final class DecoderGenerator {

private static final Set<String> WELL_KNOWN_TYPES = BuffJsonProtocPlugin.WELL_KNOWN_TYPES;
private static final String FIELD_READER = "io.suboptimal.buffjson.internal.FieldReader";

private DecoderGenerator() {
}
Expand All @@ -38,9 +39,14 @@ static String generate(Descriptor msgDesc, String javaPackage, String decoderSim
sb.append(" @Override\n");
sb.append(" public ").append(messageClassName).append(
" readMessage(JSONReader reader, io.suboptimal.buffjson.internal.ProtobufMessageReader msgReader) {\n");
// Every read must consume its value or throw: a message reader that returned on
// a non-object token (e.g. `1` in `"repeated": [1]`) without consuming it would
// make the enclosing array loop spin forever.
String fullNameLiteral = SourceLiterals.javaString(msgDesc.getFullName());
sb.append(" ").append(FIELD_READER).append(".requireObjectStart(reader, \"message\", ")
.append(fullNameLiteral).append(");\n");
sb.append(" ").append(messageClassName).append(".Builder builder = ").append(messageClassName)
.append(".newBuilder();\n");
sb.append(" reader.nextIfObjectStart();\n");
sb.append(" while (!reader.nextIfObjectEnd()) {\n");
sb.append(" String fieldName = reader.readFieldName();\n");
sb.append(" if (fieldName == null) break;\n");
Expand Down Expand Up @@ -130,8 +136,21 @@ private static void generateRepeatedFieldRead(StringBuilder sb, FieldDescriptor
sb.append(" if (!reader.nextIfNull()) {\n");
}

sb.append(indent).append(" reader.nextIfArrayStart();\n");
String fieldNameLiteral = SourceLiterals.javaString(fd.getFullName());
sb.append(indent).append(" ").append(FIELD_READER).append(".requireArrayStart(reader, \"repeated field\", ")
.append(fieldNameLiteral).append(");\n");
sb.append(indent).append(" while (!reader.nextIfArrayEnd()) {\n");
// proto3 JSON: a null element is a value only for Value (NullValue, handled by
// readJsonValue) and NullValue (NULL_VALUE); anything else is rejected here
// instead of reaching element readers that NPE (Timestamp, StringValue, ...)
// or coerce it to a phantom default element (int64, bool, enum, ...).
if (isNullValueField(fd)) {
sb.append(indent).append(" if (reader.nextIfNull()) { ").append(adder)
.append("Value(0); continue; }\n");
} else if (!isValueField(fd)) {
sb.append(indent).append(" ").append(FIELD_READER).append(".requireNonNullElement(reader, ")
.append(fieldNameLiteral).append(");\n");
}
emitValueRead(sb, fd, adder, protoToJavaClass, protoToDecoderClass, indent + " ");
sb.append(indent).append(" }\n");

Expand All @@ -158,7 +177,9 @@ private static void generateMapFieldRead(StringBuilder sb, FieldDescriptor fd, M
sb.append(" if (!reader.nextIfNull()) {\n");
}

sb.append(indent).append(" reader.nextIfObjectStart();\n");
String mapNameLiteral = SourceLiterals.javaString(fd.getFullName());
sb.append(indent).append(" ").append(FIELD_READER).append(".requireObjectStart(reader, \"map field\", ")
.append(mapNameLiteral).append(");\n");
sb.append(indent).append(" while (!reader.nextIfObjectEnd()) {\n");
sb.append(indent).append(" String keyStr = reader.readFieldName();\n");
sb.append(indent).append(" if (keyStr == null) break;\n");
Expand All @@ -172,9 +193,8 @@ 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("io.suboptimal.buffjson.internal.FieldReader.enumNumber(reader, ").append(enumClass)
.append(".getDescriptor(), reader.readString()));\n");
sb.append(indent).append(" ").append(valuePutter).append(FIELD_READER + ".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");
Expand Down Expand Up @@ -210,50 +230,42 @@ private static void emitValueRead(StringBuilder sb, FieldDescriptor fd, String p
case INT -> {
var type = fd.getType();
if (type == FieldDescriptor.Type.UINT32 || type == FieldDescriptor.Type.FIXED32) {
sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readStrictUint32(reader)")
sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readStrictUint32(reader)")
.append(closeSuffix).append(");\n");
} else {
sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readStrictInt32(reader)")
sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readStrictInt32(reader)")
.append(closeSuffix).append(");\n");
}
}
case LONG -> {
var type = fd.getType();
if (type == FieldDescriptor.Type.UINT64 || type == FieldDescriptor.Type.FIXED64) {
sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readUnsignedLong(reader)")
sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readUnsignedLong(reader)")
.append(closeSuffix).append(");\n");
} else {
sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readSignedLong(reader)")
sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readSignedLong(reader)")
.append(closeSuffix).append(");\n");
}
}
case FLOAT -> sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readFloatValue(reader)").append(closeSuffix)
.append(");\n");
case DOUBLE -> sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readDoubleValue(reader)").append(closeSuffix)
.append(");\n");
case FLOAT -> sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readFloatValue(reader)")
.append(closeSuffix).append(");\n");
case DOUBLE -> sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readDoubleValue(reader)")
.append(closeSuffix).append(");\n");
case BOOLEAN ->
sb.append(indent).append(prefix).append("(reader.readBoolValue()").append(closeSuffix).append(");\n");
case STRING -> sb.append(indent).append(prefix)
.append("(io.suboptimal.buffjson.internal.FieldReader.readStrictString(reader)").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 STRING -> sb.append(indent).append(prefix).append("(" + FIELD_READER + ".readStrictString(reader)")
.append(closeSuffix).append(");\n");
case BYTE_STRING -> sb.append(indent).append(prefix).append("(" + FIELD_READER + ".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("(io.suboptimal.buffjson.internal.FieldReader.enumNumber(reader, ").append(enumClass)
.append(".getDescriptor(), reader.readString())").append(closeSuffix).append(");\n");
sb.append(indent).append(" ").append(valueName).append("(" + FIELD_READER + ".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");
Expand Down Expand Up @@ -289,9 +301,12 @@ private static void emitMessageRead(StringBuilder sb, FieldDescriptor fd, String
.append("(io.suboptimal.buffjson.internal.WellKnownTypes.readListValue(reader)").append(closeSuffix)
.append(");\n");
} else if ("google.protobuf.Empty".equals(fullName)) {
sb.append(indent).append("reader.nextIfObjectStart();\n");
sb.append(indent)
.append("while (!reader.nextIfObjectEnd()) { reader.readFieldName(); reader.skipValue(); }\n");
sb.append(indent).append(FIELD_READER)
.append(".requireObjectStart(reader, \"message\", \"google.protobuf.Empty\");\n");
sb.append(indent).append("while (!reader.nextIfObjectEnd()) {\n");
sb.append(indent).append(" if (reader.readFieldName() == null) break;\n");
sb.append(indent).append(" reader.skipValue();\n");
sb.append(indent).append("}\n");
sb.append(indent).append(prefix).append("(com.google.protobuf.Empty.getDefaultInstance()")
.append(closeSuffix).append(");\n");
} else if (WELL_KNOWN_TYPES.contains(fullName)) {
Expand Down Expand Up @@ -321,16 +336,16 @@ private static String mapKeyExpr(FieldDescriptor keyFd) {
case INT -> {
var type = keyFd.getType();
if (type == FieldDescriptor.Type.UINT32 || type == FieldDescriptor.Type.FIXED32)
yield "io.suboptimal.buffjson.internal.FieldReader.parseUnsignedIntKey(reader, keyStr)";
yield "io.suboptimal.buffjson.internal.FieldReader.parseIntKey(reader, keyStr)";
yield FIELD_READER + ".parseUnsignedIntKey(reader, keyStr)";
yield FIELD_READER + ".parseIntKey(reader, keyStr)";
}
case LONG -> {
var type = keyFd.getType();
if (type == FieldDescriptor.Type.UINT64 || type == FieldDescriptor.Type.FIXED64)
yield "io.suboptimal.buffjson.internal.FieldReader.parseUnsignedLongKey(reader, keyStr)";
yield "io.suboptimal.buffjson.internal.FieldReader.parseLongKey(reader, keyStr)";
yield FIELD_READER + ".parseUnsignedLongKey(reader, keyStr)";
yield FIELD_READER + ".parseLongKey(reader, keyStr)";
}
case BOOLEAN -> "io.suboptimal.buffjson.internal.FieldReader.parseBoolKey(reader, keyStr)";
case BOOLEAN -> FIELD_READER + ".parseBoolKey(reader, keyStr)";
default -> throw new IllegalArgumentException("Unsupported map key type: " + keyFd.getJavaType());
};
}
Expand Down
1 change: 1 addition & 0 deletions buff-json-tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pure reflection).
- `BuffJsonReferenceTest.java` — 5 smoke tests (scalar, default, complex, plus two `DynamicMessage` tests on UTF-16 and UTF-8 paths — `DynamicMessage` is the only thing that exclusively exercises pure reflection in production)
- `BuffJsonEncodingRegressionTest.java` — escaped custom names compile in both generated codecs, round-trip through all encoder paths (UTF-16/UTF-8) and both decoders, and accept proto-name aliases. Concrete and actual DynamicMessage WKTs share output/range validation. Deprecated-field fixtures and unsigned key/digit boundaries live in the main conformance tests.
- `BuffJsonMemoryTest.java` — 8 reachability tests using `WeakReference` + `System.gc()` to confirm the encoder doesn't retain `Message` references after `encode`/`encodeToBytes`/`encode(stream)` on any of the three paths, including `DynamicMessage`. Steady-state allocation regressions are caught separately by `./allocation-check.sh` in CI (JMH `-prof gc`).
- `BuffJsonMalformedContainerTest.java` — untrusted-input regression tests for mismatched containers, each case on **all three decode paths** inside `assertTimeoutPreemptively` (a regression fails at the timeout instead of hanging the build; the stuck thread can't be stopped, so the fork may still OOM afterwards): non-object repeated message/Struct/ListValue/Any/Empty elements, non-array repeated values, non-object map and singular message values (including top-level), malformed objects inside containers, and truncated input and null repeated message elements (termination only). Null scalar/WKT repeated elements are rejected with a `JSONException` on codegen (they used to NPE or add a phantom default) and skipped on the runtime paths; null `repeated Value` elements are kept as `NullValue` on every path (matches `JsonFormat` and round-trips); empty input (empty/whitespace-only `String`, `byte[]`, slice or `InputStream`) decodes to `null` from every overload. Uses `TestNesting` and the official `TestAllTypesProto3`, which has repeated fields of every WKT.
- `BuffJsonCrossPathFuzzTest.java` — seeded-random (reproducible) fuzzer over `TestAllTypesProto3`. `encodePathsAgreeAndAreParseable` asserts **codegen == typed == reflection** byte-for-byte (UTF-16 and UTF-8) over 500 messages — the direct "the three paths agree" guarantee — plus a buff-json self round-trip. `decodePathsRoundTrip` asserts both decode paths reconstruct messages from `JsonFormat`-printed JSON. (It does not byte-compare encode output against `JsonFormat` because fastjson2 and protobuf may format the same float/double differently — both round-trip to the same value; curated byte-equality lives in `BuffJsonProto3ConformanceTest`.)
- `BuffJsonProto3ConformanceTest.java` — proto3 JSON **encode** coverage in nested classes, each `assertMatchesReference` validating all three paths (codegen, typed-accessor, reflection) byte-for-byte against `JsonFormat`; well-known-type groups also carry out-of-range **edge cases** asserting all three paths reject identically:
- ScalarTypes (13): all types, boundaries, NaN, Infinity, -0.0, unicode, escapes, bytes
Expand Down
6 changes: 6 additions & 0 deletions buff-json-tests/src/main/protobuf/conformance_test.proto
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,12 @@ message TestStruct {
google.protobuf.ListValue list_value = 3;
}

// Repeated google.protobuf.NullValue: a JSON null element is a value (NULL_VALUE),
// not an absent element. Exercises the generated decoder's null-element branch.
message TestRepeatedNullValue {
repeated google.protobuf.NullValue values = 1;
}

// Proto3 explicit presence (optional keyword)
message TestOptionalFields {
optional int32 optional_int32 = 1;
Expand Down
Loading
Loading