Skip to content

OptionReader fails to parse a parenthesized extension after a regular option path component #3672

Description

@tmanninger

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:

expected '=' in option

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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions