From db63ff6b88c36098c9f7601112beff436a9dfa09 Mon Sep 17 00:00:00 2001 From: Sergey Skrobotov Date: Tue, 31 Oct 2023 16:31:34 -0700 Subject: [PATCH] gRPC validations --- .../grpc/ValidatingInterceptor.java | 96 +++++ .../grpc/validators/BaseFieldValidator.java | 149 +++++++ .../grpc/validators/E164FieldValidator.java | 38 ++ .../EnumSpecifiedFieldValidator.java | 33 ++ .../validators/ExactlySizeFieldValidator.java | 63 +++ .../grpc/validators/FieldValidator.java | 16 + .../validators/NonEmptyFieldValidator.java | 61 +++ .../textsecuregcm/grpc/validators/Range.java | 14 + .../grpc/validators/RangeFieldValidator.java | 61 +++ .../grpc/validators/SizeFieldValidator.java | 58 +++ .../grpc/validators/ValidatorUtils.java | 57 +++ .../main/proto/org/signal/chat/require.proto | 167 ++++++++ .../grpc/ValidatingInterceptorTest.java | 396 ++++++++++++++++++ service/src/test/proto/validation_test.proto | 80 ++++ 14 files changed, 1289 insertions(+) create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/BaseFieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/E164FieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/EnumSpecifiedFieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ExactlySizeFieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/FieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/NonEmptyFieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Range.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/RangeFieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/SizeFieldValidator.java create mode 100644 service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ValidatorUtils.java create mode 100644 service/src/main/proto/org/signal/chat/require.proto create mode 100644 service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java create mode 100644 service/src/test/proto/validation_test.proto diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java new file mode 100644 index 000000000..524711d53 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptor.java @@ -0,0 +1,96 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.internalError; + +import com.google.protobuf.Descriptors; +import com.google.protobuf.GeneratedMessageV3; +import io.grpc.ForwardingServerCallListener; +import io.grpc.Metadata; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.StatusException; +import java.util.Map; +import org.whispersystems.textsecuregcm.grpc.validators.E164FieldValidator; +import org.whispersystems.textsecuregcm.grpc.validators.EnumSpecifiedFieldValidator; +import org.whispersystems.textsecuregcm.grpc.validators.ExactlySizeFieldValidator; +import org.whispersystems.textsecuregcm.grpc.validators.FieldValidator; +import org.whispersystems.textsecuregcm.grpc.validators.NonEmptyFieldValidator; +import org.whispersystems.textsecuregcm.grpc.validators.RangeFieldValidator; +import org.whispersystems.textsecuregcm.grpc.validators.SizeFieldValidator; + +public class ValidatingInterceptor implements ServerInterceptor { + + private final Map fieldValidators = Map.of( + "org.signal.chat.require.nonEmpty", new NonEmptyFieldValidator(), + "org.signal.chat.require.specified", new EnumSpecifiedFieldValidator(), + "org.signal.chat.require.e164", new E164FieldValidator(), + "org.signal.chat.require.exactlySize", new ExactlySizeFieldValidator(), + "org.signal.chat.require.range", new RangeFieldValidator(), + "org.signal.chat.require.size", new SizeFieldValidator() + ); + + @Override + public ServerCall.Listener interceptCall( + final ServerCall call, + final Metadata headers, + final ServerCallHandler next) { + return new ForwardingServerCallListener.SimpleForwardingServerCallListener<>(next.startCall(call, headers)) { + + // The way `UnaryServerCallHandler` (which is what we're wrapping here) is implemented + // is when `onMessage()` is called, the processing of the message doesn't immediately start + // and instead is delayed until `onHalfClose()` (which is the point when client says + // that no more messages will be sent). Then, in `onHalfClose()` it either tries to process + // the message if it's there, or reports an error if the message is not there. + // This means that the logic is not designed for the case of the call being closed by the interceptor. + // The only workaround is to not delegate calls to it in the case when we're closing the call + // because of the validation error. + private boolean forwardCalls = true; + + @Override + public void onMessage(final ReqT message) { + try { + validateMessage(message); + super.onMessage(message); + } catch (final StatusException e) { + call.close(e.getStatus(), new Metadata()); + forwardCalls = false; + } + } + + @Override + public void onHalfClose() { + if (forwardCalls) { + super.onHalfClose(); + } + } + }; + } + + private void validateMessage(final Object message) throws StatusException { + if (message instanceof GeneratedMessageV3 msg) { + try { + for (final Descriptors.FieldDescriptor fd: msg.getDescriptorForType().getFields()) { + for (final Map.Entry entry: fd.getOptions().getAllFields().entrySet()) { + final Descriptors.FieldDescriptor extensionFieldDescriptor = entry.getKey(); + final String extensionName = extensionFieldDescriptor.getFullName(); + final FieldValidator validator = fieldValidators.get(extensionName); + // not all extensions are validators, so `validator` value here could legitimately be `null` + if (validator != null) { + validator.validate(entry.getValue(), fd, msg); + } + } + } + } catch (final StatusException e) { + throw e; + } catch (final Exception e) { + throw internalError(e); + } + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/BaseFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/BaseFieldValidator.java new file mode 100644 index 000000000..a27f3af26 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/BaseFieldValidator.java @@ -0,0 +1,149 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static java.util.Objects.requireNonNull; +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.internalError; +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors; +import com.google.protobuf.GeneratedMessageV3; +import io.grpc.Status; +import io.grpc.StatusException; +import java.util.Set; + +public abstract class BaseFieldValidator implements FieldValidator { + + private final String extensionName; + + private final Set supportedTypes; + + private final MissingOptionalAction missingOptionalAction; + + private final boolean applicableToRepeated; + + protected enum MissingOptionalAction { + FAIL, + SUCCEED, + VALIDATE_DEFAULT_VALUE + } + + + protected BaseFieldValidator( + final String extensionName, + final Set supportedTypes, + final MissingOptionalAction missingOptionalAction, + final boolean applicableToRepeated) { + this.extensionName = requireNonNull(extensionName); + this.supportedTypes = requireNonNull(supportedTypes); + this.missingOptionalAction = missingOptionalAction; + this.applicableToRepeated = applicableToRepeated; + } + + @Override + public void validate( + final Object extensionValue, + final Descriptors.FieldDescriptor fd, + final GeneratedMessageV3 msg) throws StatusException { + try { + final T extensionValueTyped = resolveExtensionValue(extensionValue); + + // for the fields with an `optional` modifier, checking if the field was set + // and if not, checking if extension allows missing optional field + if (fd.hasPresence() && !msg.hasField(fd)) { + switch (missingOptionalAction) { + case FAIL -> { + throw invalidArgument("extension requires a value to be set"); + } + case SUCCEED -> { + return; + } + case VALIDATE_DEFAULT_VALUE -> { + // just continuing + } + } + } + + // for the `repeated` fields, checking if it's supported by the extension + if (fd.isRepeated()) { + if (applicableToRepeated) { + validateRepeatedField(extensionValueTyped, fd, msg); + return; + } + throw internalError("can't apply extension to a `repeated` field"); + } + + // checking field type against the set of supported types + final Descriptors.FieldDescriptor.Type type = fd.getType(); + if (!supportedTypes.contains(type)) { + throw internalError("can't apply extension to a field of type [%s]".formatted(type)); + } + switch (type) { + case INT64, UINT64, INT32, FIXED64, FIXED32, UINT32, SFIXED32, SFIXED64, SINT32, SINT64 -> + validateIntegerNumber(extensionValueTyped, ((Number) msg.getField(fd)).longValue(), type); + case STRING -> + validateStringValue(extensionValueTyped, (String) msg.getField(fd)); + case BYTES -> + validateBytesValue(extensionValueTyped, (ByteString) msg.getField(fd)); + case ENUM -> + validateEnumValue(extensionValueTyped, (Descriptors.EnumValueDescriptor) msg.getField(fd)); + case FLOAT, DOUBLE, BOOL, MESSAGE, GROUP -> { + // at this moment, there are no validations specific to these types of fields + } + } + } catch (StatusException e) { + throw new StatusException(e.getStatus().withDescription( + "field [%s], extension [%s]: %s".formatted(fd.getName(), extensionName, e.getStatus().getDescription()) + ), e.getTrailers()); + } catch (RuntimeException e) { + throw Status.INTERNAL + .withDescription("field [%s], extension [%s]: %s".formatted(fd.getName(), extensionName, e.getMessage())) + .withCause(e) + .asException(); + } + } + + protected abstract T resolveExtensionValue(final Object extensionValue) throws StatusException; + + protected void validateRepeatedField( + final T extensionValue, + final Descriptors.FieldDescriptor fd, + final GeneratedMessageV3 msg) throws StatusException { + throw internalError("`validateRepeatedField` method needs to be implemented"); + } + + protected void validateIntegerNumber( + final T extensionValue, + final long fieldValue, final Descriptors.FieldDescriptor.Type type) throws StatusException { + throw internalError("`validateIntegerNumber` method needs to be implemented"); + } + + protected void validateStringValue( + final T extensionValue, + final String fieldValue) throws StatusException { + throw internalError("`validateStringValue` method needs to be implemented"); + } + + protected void validateBytesValue( + final T extensionValue, + final ByteString fieldValue) throws StatusException { + throw internalError("`validateBytesValue` method needs to be implemented"); + } + + protected void validateEnumValue( + final T extensionValue, + final Descriptors.EnumValueDescriptor enumValueDescriptor) throws StatusException { + throw internalError("`validateEnumValue` method needs to be implemented"); + } + + protected static boolean requireFlagExtension(final Object extensionValue) throws StatusException { + if (extensionValue instanceof Boolean flagIsOn && flagIsOn) { + return true; + } + throw internalError("only value `true` is allowed"); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/E164FieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/E164FieldValidator.java new file mode 100644 index 000000000..e675f032f --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/E164FieldValidator.java @@ -0,0 +1,38 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.Descriptors; +import io.grpc.StatusException; +import java.util.Set; +import org.whispersystems.textsecuregcm.util.ImpossiblePhoneNumberException; +import org.whispersystems.textsecuregcm.util.NonNormalizedPhoneNumberException; +import org.whispersystems.textsecuregcm.util.Util; + +public class E164FieldValidator extends BaseFieldValidator { + + public E164FieldValidator() { + super("e164", Set.of(Descriptors.FieldDescriptor.Type.STRING), MissingOptionalAction.SUCCEED, false); + } + + @Override + protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException { + return requireFlagExtension(extensionValue); + } + + @Override + protected void validateStringValue( + final Boolean extensionValue, + final String fieldValue) throws StatusException { + try { + Util.requireNormalizedNumber(fieldValue); + } catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) { + throw invalidArgument("value is not in E164 format"); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/EnumSpecifiedFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/EnumSpecifiedFieldValidator.java new file mode 100644 index 000000000..a86ecee77 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/EnumSpecifiedFieldValidator.java @@ -0,0 +1,33 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.Descriptors; +import io.grpc.StatusException; +import java.util.Set; + +public class EnumSpecifiedFieldValidator extends BaseFieldValidator { + + public EnumSpecifiedFieldValidator() { + super("specified", Set.of(Descriptors.FieldDescriptor.Type.ENUM), MissingOptionalAction.FAIL, false); + } + + @Override + protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException { + return requireFlagExtension(extensionValue); + } + + @Override + protected void validateEnumValue( + final Boolean extensionValue, + final Descriptors.EnumValueDescriptor enumValueDescriptor) throws StatusException { + if (enumValueDescriptor.getIndex() <= 0) { + throw invalidArgument("enum field must be specified"); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ExactlySizeFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ExactlySizeFieldValidator.java new file mode 100644 index 000000000..90c0e4b54 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ExactlySizeFieldValidator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors; +import com.google.protobuf.GeneratedMessageV3; +import io.grpc.StatusException; +import java.util.List; +import java.util.Set; + +public class ExactlySizeFieldValidator extends BaseFieldValidator> { + + public ExactlySizeFieldValidator() { + super("exactlySize", Set.of( + Descriptors.FieldDescriptor.Type.STRING, + Descriptors.FieldDescriptor.Type.BYTES + ), MissingOptionalAction.VALIDATE_DEFAULT_VALUE, true); + } + + @Override + protected Set resolveExtensionValue(final Object extensionValue) throws StatusException { + //noinspection unchecked + return Set.copyOf((List) extensionValue); + } + + @Override + protected void validateBytesValue( + final Set permittedSizes, + final ByteString fieldValue) throws StatusException { + if (permittedSizes.contains(fieldValue.size())) { + return; + } + throw invalidArgument("byte arrray length is [%d] but expected to be one of %s".formatted(fieldValue.size(), permittedSizes)); + } + + @Override + protected void validateStringValue( + final Set permittedSizes, + final String fieldValue) throws StatusException { + if (permittedSizes.contains(fieldValue.length())) { + return; + } + throw invalidArgument("string length is [%d] but expected to be one of %s".formatted(fieldValue.length(), permittedSizes)); + } + + @Override + protected void validateRepeatedField( + final Set permittedSizes, + final Descriptors.FieldDescriptor fd, + final GeneratedMessageV3 msg) throws StatusException { + final int size = msg.getRepeatedFieldCount(fd); + if (permittedSizes.contains(size)) { + return; + } + throw invalidArgument("list size is [%d] but expected to be one of %s".formatted(size, permittedSizes)); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/FieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/FieldValidator.java new file mode 100644 index 000000000..7283602bb --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/FieldValidator.java @@ -0,0 +1,16 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import com.google.protobuf.Descriptors; +import com.google.protobuf.GeneratedMessageV3; +import io.grpc.StatusException; + +public interface FieldValidator { + + void validate(Object extensionValue, Descriptors.FieldDescriptor fd, GeneratedMessageV3 msg) + throws StatusException; +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/NonEmptyFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/NonEmptyFieldValidator.java new file mode 100644 index 000000000..05460d0c3 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/NonEmptyFieldValidator.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors; +import com.google.protobuf.GeneratedMessageV3; +import io.grpc.StatusException; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +public class NonEmptyFieldValidator extends BaseFieldValidator { + + public NonEmptyFieldValidator() { + super("nonEmpty", Set.of( + Descriptors.FieldDescriptor.Type.STRING, + Descriptors.FieldDescriptor.Type.BYTES + ), MissingOptionalAction.FAIL, true); + } + + @Override + protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException { + return requireFlagExtension(extensionValue); + } + + @Override + protected void validateBytesValue( + final Boolean extensionValue, + final ByteString fieldValue) throws StatusException { + if (!fieldValue.isEmpty()) { + return; + } + throw invalidArgument("byte array expected to be non-empty"); + } + + @Override + protected void validateStringValue( + final Boolean extensionValue, + final String fieldValue) throws StatusException { + if (StringUtils.isNotEmpty(fieldValue)) { + return; + } + throw invalidArgument("string expected to be non-empty"); + } + + @Override + protected void validateRepeatedField( + final Boolean extensionValue, + final Descriptors.FieldDescriptor fd, + final GeneratedMessageV3 msg) throws StatusException { + if (msg.getRepeatedFieldCount(fd) > 0) { + return; + } + throw invalidArgument("repeated field is expected to be non-empty"); + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Range.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Range.java new file mode 100644 index 000000000..c0f03e76e --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/Range.java @@ -0,0 +1,14 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +public record Range(int min, int max) { + public Range { + if (min > max) { + throw new IllegalArgumentException("invalid range values: expected min <= max but have [%d, %d],".formatted(min, max)); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/RangeFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/RangeFieldValidator.java new file mode 100644 index 000000000..697d50548 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/RangeFieldValidator.java @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.Descriptors; +import io.grpc.StatusException; +import java.util.Set; +import org.signal.chat.require.ValueRangeConstraint; + +public class RangeFieldValidator extends BaseFieldValidator { + + private static final Set UNSIGNED_TYPES = Set.of( + Descriptors.FieldDescriptor.Type.FIXED32, + Descriptors.FieldDescriptor.Type.UINT32, + Descriptors.FieldDescriptor.Type.FIXED64, + Descriptors.FieldDescriptor.Type.UINT64 + ); + + public RangeFieldValidator() { + super("range", Set.of( + Descriptors.FieldDescriptor.Type.INT64, + Descriptors.FieldDescriptor.Type.UINT64, + Descriptors.FieldDescriptor.Type.INT32, + Descriptors.FieldDescriptor.Type.FIXED64, + Descriptors.FieldDescriptor.Type.FIXED32, + Descriptors.FieldDescriptor.Type.UINT32, + Descriptors.FieldDescriptor.Type.SFIXED32, + Descriptors.FieldDescriptor.Type.SFIXED64, + Descriptors.FieldDescriptor.Type.SINT32, + Descriptors.FieldDescriptor.Type.SINT64 + ), MissingOptionalAction.SUCCEED, false); + } + + @Override + protected Range resolveExtensionValue(final Object extensionValue) throws StatusException { + final ValueRangeConstraint rangeConstraint = (ValueRangeConstraint) extensionValue; + final int min = rangeConstraint.hasMin() ? rangeConstraint.getMin() : Integer.MIN_VALUE; + final int max = rangeConstraint.hasMax() ? rangeConstraint.getMax() : Integer.MAX_VALUE; + return new Range(min, max); + } + + @Override + protected void validateIntegerNumber( + final Range range, + final long fieldValue, + final Descriptors.FieldDescriptor.Type type) throws StatusException { + if (fieldValue < 0 && UNSIGNED_TYPES.contains(type)) { + throw invalidArgument("field value is expected to be within the [%d, %d] range".formatted( + range.min(), range.max())); + } + if (fieldValue < range.min() || fieldValue > range.max()) { + throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted( + fieldValue, range.min(), range.max())); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/SizeFieldValidator.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/SizeFieldValidator.java new file mode 100644 index 000000000..532b84059 --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/SizeFieldValidator.java @@ -0,0 +1,58 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Descriptors; +import com.google.protobuf.GeneratedMessageV3; +import io.grpc.StatusException; +import java.util.Set; +import org.signal.chat.require.SizeConstraint; + +public class SizeFieldValidator extends BaseFieldValidator { + + public SizeFieldValidator() { + super("size", Set.of( + Descriptors.FieldDescriptor.Type.STRING, + Descriptors.FieldDescriptor.Type.BYTES + ), MissingOptionalAction.VALIDATE_DEFAULT_VALUE, true); + } + + @Override + protected Range resolveExtensionValue(final Object extensionValue) throws StatusException { + final SizeConstraint sizeConstraint = (SizeConstraint) extensionValue; + final int min = sizeConstraint.hasMin() ? sizeConstraint.getMin() : 0; + final int max = sizeConstraint.hasMax() ? sizeConstraint.getMax() : Integer.MAX_VALUE; + return new Range(min, max); + } + + @Override + protected void validateBytesValue(final Range range, final ByteString fieldValue) throws StatusException { + if (fieldValue.size() < range.min() || fieldValue.size() > range.max()) { + throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted( + fieldValue.size(), range.min(), range.max())); + } + } + + @Override + protected void validateStringValue(final Range range, final String fieldValue) throws StatusException { + if (fieldValue.length() < range.min() || fieldValue.length() > range.max()) { + throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted( + fieldValue.length(), range.min(), range.max())); + } + } + + @Override + protected void validateRepeatedField(final Range range, final Descriptors.FieldDescriptor fd, final GeneratedMessageV3 msg) throws StatusException { + final int size = msg.getRepeatedFieldCount(fd); + if (size < range.min() || size > range.max()) { + throw invalidArgument("field value is [%d] but expected to be within the [%d, %d] range".formatted( + size, range.min(), range.max())); + } + } +} diff --git a/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ValidatorUtils.java b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ValidatorUtils.java new file mode 100644 index 000000000..04afa4adb --- /dev/null +++ b/service/src/main/java/org/whispersystems/textsecuregcm/grpc/validators/ValidatorUtils.java @@ -0,0 +1,57 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc.validators; + +import com.google.protobuf.DescriptorProtos; +import com.google.protobuf.Descriptors; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.StatusException; +import io.grpc.protobuf.ProtoServiceDescriptorSupplier; +import java.util.Map; +import java.util.Optional; +import org.signal.chat.require.Auth; + +public final class ValidatorUtils { + + public static final String REQUIRE_AUTH_EXTENSION_NAME = "org.signal.chat.require.auth"; + + private ValidatorUtils() { + // noop + } + + public static StatusException invalidArgument(final String description) { + return Status.INVALID_ARGUMENT.withDescription(description).asException(); + } + + public static StatusException internalError(final String description) { + return Status.INTERNAL.withDescription(description).asException(); + } + + public static StatusException internalError(final Exception cause) { + return Status.INTERNAL.withCause(cause).asException(); + } + + public static Optional serviceAuthExtensionValue(final ServerServiceDefinition serviceDefinition) { + return serviceExtensionValueByName(serviceDefinition, REQUIRE_AUTH_EXTENSION_NAME) + .map(val -> Auth.valueOf((Descriptors.EnumValueDescriptor) val)); + } + + private static Optional serviceExtensionValueByName( + final ServerServiceDefinition serviceDefinition, + final String fullExtensionName) { + final Object schemaDescriptor = serviceDefinition.getServiceDescriptor().getSchemaDescriptor(); + if (schemaDescriptor instanceof ProtoServiceDescriptorSupplier protoServiceDescriptorSupplier) { + final DescriptorProtos.ServiceOptions options = protoServiceDescriptorSupplier.getServiceDescriptor().getOptions(); + return options.getAllFields().entrySet() + .stream() + .filter(e -> e.getKey().getFullName().equals(fullExtensionName)) + .map(Map.Entry::getValue) + .findFirst(); + } + return Optional.empty(); + } +} diff --git a/service/src/main/proto/org/signal/chat/require.proto b/service/src/main/proto/org/signal/chat/require.proto new file mode 100644 index 000000000..f99f290a7 --- /dev/null +++ b/service/src/main/proto/org/signal/chat/require.proto @@ -0,0 +1,167 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.require; + +import "google/protobuf/descriptor.proto"; + +extend google.protobuf.FieldOptions { + /* + Requires a field to have content of non-zero size/length. + Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value, + it's considered to be empty. + + ``` + import "org/signal/chat/require.proto"; + + message Data { + + string nonEmptyString = 1 [(require.nonEmpty) = true]; + + bytes nonEmptyBytes = 2 [(require.nonEmpty) = true]; + + optional string nonEmptyStringOptional = 3 [(require.nonEmpty) = true]; + + optional bytes nonEmptyBytesOptional = 4 [(require.nonEmpty) = true]; + + repeated string nonEmptyList = 5 [(require.nonEmpty) = true]; + } + ``` + + Applicable to fields of type `string`, `byte`, and `repeated` fields. + */ + optional bool nonEmpty = 70001; + + /* + Requires a enum field to have value with an index greater than zero. + Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value, + its index will be <= 0. + + ``` + import "org/signal/chat/require.proto"; + + message Data { + Color color = 1 [(require.specified) = true]; + } + + enum Color { + COLOR_UNSPECIFIED = 0; + COLOR_RED = 1; + COLOR_GREEN = 2; + COLOR_BLUE = 3; + } + ``` + */ + optional bool specified = 70002; + + /* + Requires a size/length of a field to be withing certain bounderies. + Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value, + its size considered to be zero. + + ``` + import "org/signal/chat/require.proto"; + + message Data { + + string name = 1 [(require.size) = {min: 3, max: 8}]; + + optional string address = 2 [(require.size) = {min: 3, max: 8}]; + } + ``` + + Applicable to fields of type `string`, `byte`, and `repeated` fields. + */ + optional SizeConstraint size = 70003; + + /* + Requires a size/length of a field to be one of the specified values. + Applies to both `optional` and regular fields, i.e. if the field is not set or has a default value, + its size considered to be zero. + + ``` + import "org/signal/chat/require.proto"; + + message Data { + + string zip = 1 [(require.exactlySize) = 5]; + + optional string exactlySizeVariants = 2 [(require.exactlySize) = 2, (require.exactlySize) = 4]; + } + ``` + + Applicable to fields of type `string`, `byte`, and `repeated` fields. + */ + repeated uint32 exactlySize = 70004; + + /* + Requires a value of a string field to be a valid E164-normalized phone number. + If the field is `optional`, this check allows a value to be not set. + + ``` + import "org/signal/chat/require.proto"; + + message Data { + string number = 1 [(require.e164)]; + } + ``` + */ + optional bool e164 = 70005; + + /* + Requires an integer value to be within a certain range. The range bounderies are specified + with the values of type `int32`, which should be enough for all practical purposes. + + If the field is `optional`, this check allows a value to be not set. + + ``` + import "org/signal/chat/require.proto"; + + message Data { + int32 byte = 1 [(require.range) = {min = -128, max = 127}]; + uint32 unsignedByte = 2 [(require.range).max = 255]; + } + ``` + */ + optional ValueRangeConstraint range = 70006; +} + +message SizeConstraint { + optional uint32 min = 1; + optional uint32 max = 2; +} + +message ValueRangeConstraint { + optional int32 min = 1; + optional int32 max = 2; +} + +extend google.protobuf.ServiceOptions { + /* + Indicates that all methods in a given service require a certain kind of authentication. + + ``` + import "org/signal/chat/require.proto"; + + service AuthService { + option (require.auth) = AUTH_ONLY_AUTHENTICATED; + + rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {} + } + ``` + */ + optional Auth auth = 71001; +} + +enum Auth { + AUTH_UNSPECIFIED = 0; + AUTH_ONLY_AUTHENTICATED = 1; + AUTH_ONLY_ANONYMOUS = 2; +} + diff --git a/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java new file mode 100644 index 000000000..6cdf7cdee --- /dev/null +++ b/service/src/test/java/org/whispersystems/textsecuregcm/grpc/ValidatingInterceptorTest.java @@ -0,0 +1,396 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.whispersystems.textsecuregcm.grpc; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.protobuf.ByteString; +import com.google.protobuf.Empty; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.RandomUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.signal.chat.require.Auth; +import org.signal.chat.rpc.Color; +import org.signal.chat.rpc.ReactorAnonymousServiceGrpc; +import org.signal.chat.rpc.ReactorAuthServiceGrpc; +import org.signal.chat.rpc.ReactorValidationTestServiceGrpc; +import org.signal.chat.rpc.ValidationTestServiceGrpc; +import org.signal.chat.rpc.ValidationsRequest; +import org.signal.chat.rpc.ValidationsResponse; +import org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils; +import reactor.core.publisher.Mono; + +public class ValidatingInterceptorTest { + + @RegisterExtension + static final GrpcServerExtension GRPC_SERVER_EXTENSION = new GrpcServerExtension(); + + private static final class ValidationTestGrpcServiceImpl extends ReactorValidationTestServiceGrpc.ValidationTestServiceImplBase { + @Override + public Mono validationsEndpoint(final ValidationsRequest request) { + return Mono.just(ValidationsResponse.newBuilder().build()); + } + } + + private static final class AuthGrpcServiceImpl extends ReactorAuthServiceGrpc.AuthServiceImplBase { + @Override + public Mono authenticatedMethod(final Empty request) { + return Mono.just(Empty.getDefaultInstance()); + } + } + + private static final class AnonymousGrpcServiceImpl extends ReactorAnonymousServiceGrpc.AnonymousServiceImplBase { + @Override + public Mono anonymousMethod(final Empty request) { + return Mono.just(Empty.getDefaultInstance()); + } + } + + private ValidationTestServiceGrpc.ValidationTestServiceBlockingStub stub; + + + @BeforeEach + void setUp() { + final ValidationTestGrpcServiceImpl validationTestGrpcService = new ValidationTestGrpcServiceImpl(); + final AuthGrpcServiceImpl authGrpcService = new AuthGrpcServiceImpl(); + final AnonymousGrpcServiceImpl anonymousGrpcService = new AnonymousGrpcServiceImpl(); + + GRPC_SERVER_EXTENSION.getServiceRegistry() + .addService(ServerInterceptors.intercept(validationTestGrpcService, new ValidatingInterceptor())); + GRPC_SERVER_EXTENSION.getServiceRegistry() + .addService(authGrpcService); + GRPC_SERVER_EXTENSION.getServiceRegistry() + .addService(anonymousGrpcService); + + stub = ValidationTestServiceGrpc.newBlockingStub(GRPC_SERVER_EXTENSION.getChannel()); + } + + @ParameterizedTest + @ValueSource(strings = {"15551234567", "", "123", "+1 555 1234567", "asdf"}) + public void testE164ValidationFailure(final String invalidNumber) throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setNumber(invalidNumber) + .build() + )); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 2, 3, 4, 6, 1000}) + public void testExactlySizeValidationFailure(final int size) throws Exception { + final String stringValue = RandomStringUtils.randomAlphanumeric(size); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setFixedSizeString(stringValue) + .build() + )); + + final ByteString byteValue = ByteString.copyFrom(RandomUtils.nextBytes(size)); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setFixedSizeBytes(byteValue) + .build() + )); + + final List listValue = IntStream.range(0, size) + .mapToObj(i -> RandomStringUtils.randomAlphabetic(10)) + .toList(); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearFixedSizeList() + .addAllFixedSizeList(listValue) + .build() + )); + } + + @Test + public void testExactlySizeMultiplePermittedValues() throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setExactlySizeVariants("abc") + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setExactlySizeVariants("") + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearExactlySizeVariants() + .build() + )); + stub.validationsEndpoint( + builderWithValidDefaults() + .setExactlySizeVariants("ab") + .build() + ); + stub.validationsEndpoint( + builderWithValidDefaults() + .setExactlySizeVariants("abcd") + .build() + ); + stub.validationsEndpoint( + builderWithValidDefaults() + .build() + ); + } + + public static Stream testRangeSizeValidationFailure() { + return Stream.of( + Arguments.of(0, Status.INVALID_ARGUMENT), + Arguments.of(1, Status.INVALID_ARGUMENT), + Arguments.of(2, Status.INVALID_ARGUMENT), + Arguments.of(3, Status.OK), + Arguments.of(4, Status.OK), + Arguments.of(5, Status.OK), + Arguments.of(6, Status.OK), + Arguments.of(7, Status.OK), + Arguments.of(8, Status.OK), + Arguments.of(9, Status.INVALID_ARGUMENT), + Arguments.of(1000, Status.INVALID_ARGUMENT) + ); + } + + @ParameterizedTest + @MethodSource + public void testRangeSizeValidationFailure(final int size, final Status expectedStatus) throws Exception { + final String stringValue = RandomStringUtils.randomAlphanumeric(size); + assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint( + builderWithValidDefaults() + .setRangeSizeString(stringValue) + .build() + )).getCode()); + + final ByteString byteValue = ByteString.copyFrom(RandomUtils.nextBytes(size)); + assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint( + builderWithValidDefaults() + .setRangeSizeBytes(byteValue) + .build() + )).getCode()); + + final List listValue = IntStream.range(0, size) + .mapToObj(i -> RandomStringUtils.randomAlphabetic(10)) + .toList(); + assertEquals(expectedStatus.getCode(), requestStatus(() -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearRangeSizeList() + .addAllRangeSizeList(listValue) + .build() + )).getCode()); + } + + @Test + public void testNotOptionalWithMaxLimit() throws Exception { + stub.validationsEndpoint( + builderWithValidDefaults() + .clearWithMaxBytes() + .build() + ); + stub.validationsEndpoint( + builderWithValidDefaults() + .clearWithMaxString() + .build() + ); + } + + @Test + public void testNotOptionalWithMinLimit() throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearWithMinBytes() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearWithMinString() + .build() + )); + } + + @Test + public void testServiceExtensionValueExtraction() throws Exception { + final Map> authValues = GRPC_SERVER_EXTENSION.getServiceRegistry().getServices() + .stream() + .map(sd -> Pair.of( + sd.getServiceDescriptor().getName(), + ValidatorUtils.serviceAuthExtensionValue(sd) + )) + .collect(Collectors.toMap(Pair::getKey, Pair::getValue)); + assertEquals(Map.of( + "org.signal.chat.rpc.ValidationTestService", Optional.empty(), + "org.signal.chat.rpc.AuthService", Optional.of(Auth.AUTH_ONLY_AUTHENTICATED), + "org.signal.chat.rpc.AnonymousService", Optional.of(Auth.AUTH_ONLY_ANONYMOUS) + ), authValues); + } + + @Test + public void testNonEmpty() throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearNonEmptyList() + .build() + )); + // check not setting a value + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearNonEmptyBytes() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearNonEmptyBytesOptional() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearNonEmptyString() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearNonEmptyStringOptional() + .build() + )); + // now check explicitly setting an empty value + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setNonEmptyBytes(ByteString.EMPTY) + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setNonEmptyBytesOptional(ByteString.EMPTY) + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setNonEmptyString("") + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setNonEmptyStringOptional("") + .build() + )); + } + + @Test + public void testEnumSpecified() throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearColor() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setColor(Color.COLOR_UNSPECIFIED) + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearColorOptional() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setColorOptional(Color.COLOR_UNSPECIFIED) + .build() + )); + } + + @Test + public void testRange() throws Exception { + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setI32(1000) + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setUi32(-1) + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .clearI32Range() + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setI32OptRange(5) + .build() + )); + assertStatusException(Status.INVALID_ARGUMENT, () -> stub.validationsEndpoint( + builderWithValidDefaults() + .setI32OptRange(1000) + .build() + )); + } + + @Test + public void testAllFieldsValidationSuccess() throws Exception { + stub.validationsEndpoint(builderWithValidDefaults().build()); + } + + @Nonnull + private static ValidationsRequest.Builder builderWithValidDefaults() { + return ValidationsRequest.newBuilder() + .setNumber("+15551234567") + .setFixedSizeString("12345") + .setFixedSizeBytes(ByteString.copyFrom(new byte[5])) + .setWithMinBytes(ByteString.copyFrom(new byte[5])) + .setWithMaxBytes(ByteString.copyFrom(new byte[5])) + .setWithMinString("12345") + .setWithMaxString("12345") + .setExactlySizeVariants("ab") + .setRangeSizeString("abc") + .setNonEmptyString("abc") + .setNonEmptyStringOptional("abc") + .setColor(Color.COLOR_GREEN) + .setColorOptional(Color.COLOR_GREEN) + .setNonEmptyBytes(ByteString.copyFrom(new byte[5])) + .setNonEmptyBytesOptional(ByteString.copyFrom(new byte[5])) + .addAllNonEmptyList(List.of("a", "b", "c", "d", "e")) + .setRangeSizeBytes(ByteString.copyFrom(new byte[3])) + .addAllFixedSizeList(List.of("a", "b", "c", "d", "e")) + .addAllRangeSizeList(List.of("a", "b", "c", "d", "e")) + .setI32Range(15); + } + + private static void assertStatusException(final Status expected, final Executable serviceCall) { + final StatusRuntimeException exception = Assertions.assertThrows(StatusRuntimeException.class, serviceCall); + assertEquals(expected.getCode(), exception.getStatus().getCode()); + } + + private static Status requestStatus(final Runnable runnable) { + try { + runnable.run(); + return Status.OK; + } catch (final StatusRuntimeException e) { + return e.getStatus(); + } + } +} diff --git a/service/src/test/proto/validation_test.proto b/service/src/test/proto/validation_test.proto new file mode 100644 index 000000000..b1fc1208f --- /dev/null +++ b/service/src/test/proto/validation_test.proto @@ -0,0 +1,80 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +syntax = "proto3"; + +option java_multiple_files = true; + +package org.signal.chat.rpc; + +import "org/signal/chat/require.proto"; +import "google/protobuf/empty.proto"; + +service ValidationTestService { + rpc ValidationsEndpoint (ValidationsRequest) returns (ValidationsResponse) {} +} + +service AuthService { + option (require.auth) = AUTH_ONLY_AUTHENTICATED; + + rpc AuthenticatedMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {} +} + +service AnonymousService { + option (require.auth) = AUTH_ONLY_ANONYMOUS; + + rpc AnonymousMethod (google.protobuf.Empty) returns (google.protobuf.Empty) {} +} + +message ValidationsRequest { + optional string number = 1 [(require.e164) = true]; + + optional string fixedSizeString = 2 [(require.exactlySize) = 5]; + optional string rangeSizeString = 3 [(require.size) = {min: 3, max: 8}]; + + optional bytes fixedSizeBytes = 4 [(require.exactlySize) = 5]; + optional bytes rangeSizeBytes = 5 [(require.size) = {min: 3, max: 8}]; + + repeated string fixedSizeList = 6 [(require.exactlySize) = 5]; + repeated string rangeSizeList = 7 [(require.size) = {min: 3, max: 8}]; + + bytes withMinBytes = 8 [(require.size).min = 3]; + string withMinString = 9 [(require.size).min = 3]; + + bytes withMaxBytes = 10 [(require.size).max = 8]; + string withMaxString = 11 [(require.size).max = 8]; + + optional string exactlySizeVariants = 12 [(require.exactlySize) = 2, (require.exactlySize) = 4]; + optional string exactlySizeVariantsWithZero = 13 [(require.exactlySize) = 0, (require.exactlySize) = 4]; + + optional string nonEmptyStringOptional = 14 [(require.nonEmpty) = true]; + optional bytes nonEmptyBytesOptional = 15 [(require.nonEmpty) = true]; + string nonEmptyString = 16 [(require.nonEmpty) = true]; + bytes nonEmptyBytes = 17 [(require.nonEmpty) = true]; + repeated string nonEmptyList = 18 [(require.nonEmpty) = true]; + + optional Color colorOptional = 19 [(require.specified) = true]; + Color color = 20 [(require.specified) = true]; + + int32 i32 = 21 [(require.range).max = 100]; + uint32 ui32 = 22 [(require.range).max = 100]; + int32 i32range = 23 [(require.range) = {min: 10, max: 20}]; + optional int32 i32OptRange = 24 [(require.range) = {min: 10, max: 20}]; +} + +message MessageWithInvalidRangeConstraint { + int32 i32 = 1 [(require.range) = {min: 10, max: 5}]; +} + +enum Color { + COLOR_UNSPECIFIED = 0; + COLOR_RED = 1; + COLOR_GREEN = 2; + COLOR_BLUE = 3; +} + +message ValidationsResponse { +} +