Description
Wire's ProtoParser fails to parse a parenthesized extension when it follows a regular field component in a custom option path.
This fails:
(foo.field).string.(foo.datetime) = true
Adding whitespace before the second dot makes the same option parse successfully:
(foo.field).string .(foo.datetime) = true
This occurs in real-world Protovalidate predefined rules, for example:
(buf.validate.field).string.(company.validate.datetime) = true
Protovalidate documents this form of nested extension syntax:
(buf.validate.field).float.(foo.bar.required_with_max)
https://protovalidate.com/schemas/predefined-rules/#applying-predefined-rules
Environment
com.squareup.wire:wire-schema-jvm:5.4.0
- Java
21.0.9
- Reproduced by invoking
ProtoParser directly
- Initially encountered with
confluentinc/cp-schema-registry:8.0.3, which bundles Wire 5.4.0
The failure does not require Schema Registry and can be reproduced by calling Wire directly.
Minimal reproduction
import com.squareup.wire.schema.Location;
import com.squareup.wire.schema.internal.parser.ProtoParser;
public final class WireParseTest {
public static void main(String[] args) {
String schema =
"syntax = \"proto3\";\n" +
"\n" +
"message TestEvent {\n" +
" string started_at = 1 [\n" +
" (foo.field).string.(foo.datetime) = true\n" +
" ];\n" +
"}\n";
var parsed = ProtoParser.Companion.parse(
Location.get("test.proto"),
schema
);
System.out.println(parsed);
}
}
Compile and execute it against Wire 5.4.0:
javac \
-proc:none \
-cp 'wire-schema-jvm-5.4.0.jar:<required-runtime-dependencies>' \
WireParseTest.java
java \
-cp '.:wire-schema-jvm-5.4.0.jar:<required-runtime-dependencies>' \
WireParseTest
It can also be reproduced using the Schema Registry image that contains Wire 5.4.0:
docker run --rm \
--volume "$PWD:/work" \
--workdir /work \
--entrypoint sh \
confluentinc/cp-schema-registry:8.0.3 \
-c '
javac \
-proc:none \
-cp "/usr/share/java/schema-registry/*" \
WireParseTest.java &&
java \
-cp "/work:/usr/share/java/schema-registry/*" \
WireParseTest
'
Actual result
Exception in thread "main" java.lang.IllegalStateException:
Syntax error in test.proto:5:34: expected '=' in option
at com.squareup.wire.schema.internal.parser.SyntaxReader.unexpected(SyntaxReader.kt:425)
at com.squareup.wire.schema.internal.parser.OptionReader.readOption(OptionReader.kt:215)
at com.squareup.wire.schema.internal.parser.OptionReader.readOptions(OptionReader.kt:38)
at com.squareup.wire.schema.internal.parser.ProtoParser.readField(ProtoParser.kt:369)
at com.squareup.wire.schema.internal.parser.ProtoParser.readField(ProtoParser.kt:353)
at com.squareup.wire.schema.internal.parser.ProtoParser.readDeclaration(ProtoParser.kt:168)
at com.squareup.wire.schema.internal.parser.ProtoParser.readMessage(ProtoParser.kt:206)
at com.squareup.wire.schema.internal.parser.ProtoParser.readDeclaration(ProtoParser.kt:150)
at com.squareup.wire.schema.internal.parser.ProtoParser.readProtoFile(ProtoParser.kt:71)
at com.squareup.wire.schema.internal.parser.ProtoParser$Companion.parse(ProtoParser.kt:680)
at WireParseTest.main(WireParseTest.java:15)
Expected result
The option should parse successfully without requiring whitespace before the second dot:
(foo.field).string.(foo.datetime) = true
Whitespace around the dot should not change the meaning or validity of the option path.
Whitespace workaround
Changing only this:
(foo.field).string.(foo.datetime) = true
to this:
(foo.field).string .(foo.datetime) = true
makes Wire parse the schema successfully.
The equivalent Protovalidate message-literal syntax also works:
(buf.validate.field).string = {
[company.validate.datetime]: true
}
Suspected cause
OptionReader.readOption() appears to support nested parenthesized option components:
val subName = reader.readName(retainWrap = true)
if (subName.startsWith("(")) {
subNames.add(subName)
} else {
subNames.addAll(subName.split("."))
}
However, SyntaxReader.readWord() treats . as part of a word:
when (data[pos]) {
in 'a'..'z', in 'A'..'Z', in '0'..'9', '_', '-', '.' -> pos++
else -> break@loop
}
When parsing:
(foo.field).string.(foo.datetime)
the call that should read string appears to consume string. instead. The separator dot before (foo.datetime) therefore never reaches the loop in OptionReader.
OptionReader subsequently encounters ( where it expects =, producing:
The successful whitespace workaround supports this analysis: whitespace prevents readWord() from consuming the separator dot as part of string..
Relevant sources:
Wire already contains support and tests for some nested extension options, such as:
option (my_message_option_six).(More.more_string) = "foobar";
However, that form does not contain a regular path component between two parenthesized extensions. The failing structure is specifically:
(parenthesized extension).regular field.(parenthesized extension)
Description
Wire's
ProtoParserfails to parse a parenthesized extension when it follows a regular field component in a custom option path.This fails:
Adding whitespace before the second dot makes the same option parse successfully:
This occurs in real-world Protovalidate predefined rules, for example:
Protovalidate documents this form of nested extension syntax:
https://protovalidate.com/schemas/predefined-rules/#applying-predefined-rules
Environment
com.squareup.wire:wire-schema-jvm:5.4.021.0.9ProtoParserdirectlyconfluentinc/cp-schema-registry:8.0.3, which bundles Wire 5.4.0The failure does not require Schema Registry and can be reproduced by calling Wire directly.
Minimal reproduction
Compile and execute it against Wire 5.4.0:
It can also be reproduced using the Schema Registry image that contains Wire 5.4.0:
Actual result
Expected result
The option should parse successfully without requiring whitespace before the second dot:
Whitespace around the dot should not change the meaning or validity of the option path.
Whitespace workaround
Changing only this:
to this:
makes Wire parse the schema successfully.
The equivalent Protovalidate message-literal syntax also works:
(buf.validate.field).string = { [company.validate.datetime]: true }Suspected cause
OptionReader.readOption()appears to support nested parenthesized option components:However,
SyntaxReader.readWord()treats.as part of a word:When parsing:
the call that should read
stringappears to consumestring.instead. The separator dot before(foo.datetime)therefore never reaches the loop inOptionReader.OptionReadersubsequently encounters(where it expects=, producing:The successful whitespace workaround supports this analysis: whitespace prevents
readWord()from consuming the separator dot as part ofstring..Relevant sources:
Wire already contains support and tests for some nested extension options, such as:
However, that form does not contain a regular path component between two parenthesized extensions. The failing structure is specifically: