Ensure details are included on all gRPC error statuses

This commit is contained in:
ravi-signal
2026-02-17 14:54:16 -05:00
committed by GitHub
parent d6a0129c5a
commit 81031b7b2f
25 changed files with 292 additions and 241 deletions

View File

@@ -143,6 +143,7 @@ import org.whispersystems.textsecuregcm.filters.TimestampResponseFilter;
import org.whispersystems.textsecuregcm.grpc.AccountsAnonymousGrpcService;
import org.whispersystems.textsecuregcm.grpc.AccountsGrpcService;
import org.whispersystems.textsecuregcm.grpc.CallQualitySurveyGrpcService;
import org.whispersystems.textsecuregcm.grpc.ErrorConformanceInterceptor;
import org.whispersystems.textsecuregcm.grpc.GrpcAllowListInterceptor;
import org.whispersystems.textsecuregcm.grpc.ErrorMappingInterceptor;
import org.whispersystems.textsecuregcm.grpc.ExternalServiceCredentialsAnonymousGrpcService;
@@ -877,6 +878,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final MetricServerInterceptor metricServerInterceptor = new MetricServerInterceptor(Metrics.globalRegistry, clientReleaseManager);
final ErrorMappingInterceptor errorMappingInterceptor = new ErrorMappingInterceptor();
final ErrorConformanceInterceptor errorConformanceInterceptor = new ErrorConformanceInterceptor();
final GrpcAllowListInterceptor grpcAllowListInterceptor =
new GrpcAllowListInterceptor(config.getGrpcAllowList().enableAll(), config.getGrpcAllowList().enabledServices(), config.getGrpcAllowList().enabledMethods());
final RequestAttributesInterceptor requestAttributesInterceptor = new RequestAttributesInterceptor();
@@ -892,9 +894,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
final List<ServerServiceDefinition> authenticatedServices = Stream.of(
new AccountsGrpcService(accountsManager, rateLimiters, usernameHashZkProofVerifier, registrationRecoveryPasswordsManager),
ExternalServiceCredentialsGrpcService.createForAllExternalServices(config, rateLimiters),
new KeysGrpcService(accountsManager, keysManager, rateLimiters),
new ProfileGrpcService(clock, accountsManager, profilesManager, dynamicConfigurationManager,
config.getBadges(), profileCdnPolicyGenerator, profileCdnPolicySigner, profileBadgeConverter, rateLimiters, zkProfileOperations))
new KeysGrpcService(accountsManager, keysManager, rateLimiters))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@@ -902,6 +902,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
grpcAllowListInterceptor,
metricServerInterceptor,
errorMappingInterceptor,
errorConformanceInterceptor,
remoteDeprecationFilter,
requestAttributesInterceptor,
requireAuthenticationInterceptor))
@@ -912,8 +913,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
new CallQualitySurveyGrpcService(callQualitySurveyManager, rateLimiters),
new KeysAnonymousGrpcService(accountsManager, keysManager, zkSecretParams, Clock.systemUTC()),
new PaymentsGrpcService(currencyManager),
ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config),
new ProfileAnonymousGrpcService(accountsManager, profilesManager, profileBadgeConverter, zkSecretParams))
ExternalServiceCredentialsAnonymousGrpcService.create(accountsManager, config))
.map(bindableService -> ServerInterceptors.intercept(bindableService,
// Note: interceptors run in the reverse order they are added; the remote deprecation filter
// depends on the user-agent context so it has to come first here!
@@ -922,6 +922,7 @@ public class WhisperServerService extends Application<WhisperServerConfiguration
grpcAllowListInterceptor,
metricServerInterceptor,
errorMappingInterceptor,
errorConformanceInterceptor,
remoteDeprecationFilter,
requestAttributesInterceptor,
prohibitAuthenticationInterceptor))

View File

@@ -174,7 +174,7 @@ public class BackupsGrpcService extends SimpleBackupsGrpc.BackupsImplBase {
try {
return deserializer.deserialize(bytes);
} catch (InvalidInputException e) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid serialization").asRuntimeException();
throw GrpcExceptions.invalidArguments("invalid serialization");
}
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc;
import io.grpc.ForwardingServerCall;
import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.Status;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ErrorConformanceInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ErrorConformanceInterceptor.class);
private static final Metadata.Key<byte[]> DETAILS_HEADER_KEY =
Metadata.Key.of("grpc-status-details-bin", Metadata.BINARY_BYTE_MARSHALLER);
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
final ServerCall<ReqT, RespT> call,
final Metadata headers,
final ServerCallHandler<ReqT, RespT> next) {
return next.startCall(new ForwardingServerCall.SimpleForwardingServerCall<>(call) {
@Override
public void close(final Status status, final Metadata trailers) {
if (status.getCode() == Status.Code.OK) {
super.close(status, trailers);
return;
}
if (!trailers.containsKey(DETAILS_HEADER_KEY)) {
log.error("Intercepted call {} returned status {} but did not include status details",
call.getMethodDescriptor().getFullMethodName(), status);
assert false;
}
switch (status.getCode()) {
case UNAUTHENTICATED, UNAVAILABLE, INVALID_ARGUMENT, RESOURCE_EXHAUSTED -> {
}
default -> {
log.error("Intercepted call {} returned illegal application status {}: {}",
call.getMethodDescriptor().getFullMethodName(), status, status.getDescription());
assert false;
}
}
super.close(status, trailers);
}
}, headers);
}
}

View File

@@ -17,7 +17,7 @@ public class IdentityTypeUtil {
return switch (grpcIdentityType) {
case IDENTITY_TYPE_ACI -> IdentityType.ACI;
case IDENTITY_TYPE_PNI -> IdentityType.PNI;
case IDENTITY_TYPE_UNSPECIFIED, UNRECOGNIZED -> throw Status.INVALID_ARGUMENT.asRuntimeException();
case IDENTITY_TYPE_UNSPECIFIED, UNRECOGNIZED -> throw GrpcExceptions.invalidArguments("invalid identity type");
};
}

View File

@@ -37,7 +37,7 @@ public class PaymentsGrpcService extends ReactorPaymentsGrpc.PaymentsImplBase {
final CurrencyConversionEntityList currencyConversionEntityList = currencyManager
.getCurrencyConversions()
.orElseThrow(Status.UNAVAILABLE::asRuntimeException);
.orElseThrow(() -> GrpcExceptions.unavailable("currency conversions not available"));
final List<GetCurrencyConversionsResponse.CurrencyConversionEntity> currencyConversionEntities = currencyConversionEntityList
.getCurrencies()

View File

@@ -5,8 +5,6 @@
package org.whispersystems.textsecuregcm.grpc;
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.internalError;
import com.google.protobuf.Descriptors;
import com.google.protobuf.Message;
import io.grpc.ForwardingServerCallListener;
@@ -14,12 +12,15 @@ import io.grpc.Metadata;
import io.grpc.ServerCall;
import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor;
import io.grpc.StatusException;
import io.grpc.StatusRuntimeException;
import java.util.List;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.FieldValidationException;
import org.whispersystems.textsecuregcm.grpc.validators.FieldValidator;
import org.whispersystems.textsecuregcm.grpc.validators.NonEmptyFieldValidator;
import org.whispersystems.textsecuregcm.grpc.validators.PresentFieldValidator;
@@ -28,6 +29,7 @@ import org.whispersystems.textsecuregcm.grpc.validators.SizeFieldValidator;
public class ValidatingInterceptor implements ServerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ValidatingInterceptor.class);
private final Map<String, FieldValidator> fieldValidators = Map.of(
"org.signal.chat.require.nonEmpty", new NonEmptyFieldValidator(),
"org.signal.chat.require.present", new PresentFieldValidator(),
@@ -60,8 +62,18 @@ public class ValidatingInterceptor implements ServerInterceptor {
try {
validateMessage(message);
super.onMessage(message);
} catch (final StatusException e) {
call.close(e.getStatus(), new Metadata());
} catch (final StatusRuntimeException e) {
call.close(e.getStatus(), e.getTrailers());
forwardCalls = false;
} catch (RuntimeException runtimeException) {
final StatusRuntimeException grpcException = switch (runtimeException) {
case StatusRuntimeException e -> e;
default -> {
log.error("Failure applying request validation to message {}", call.getMethodDescriptor().getFullMethodName(), runtimeException);
yield GrpcExceptions.unavailable("failure applying request validation");
}
};
call.close(grpcException.getStatus(), grpcException.getTrailers());
forwardCalls = false;
}
}
@@ -75,40 +87,39 @@ public class ValidatingInterceptor implements ServerInterceptor {
};
}
private void validateMessage(final Object message) throws StatusException {
private void validateMessage(final Object message) {
if (message instanceof Message msg) {
try {
for (final Descriptors.FieldDescriptor fd: msg.getDescriptorForType().getFields()) {
for (final Map.Entry<Descriptors.FieldDescriptor, Object> entry: fd.getOptions().getAllFields().entrySet()) {
final Descriptors.FieldDescriptor extensionFieldDescriptor = entry.getKey();
final String extensionName = extensionFieldDescriptor.getFullName();
for (final Descriptors.FieldDescriptor fd : msg.getDescriptorForType().getFields()) {
for (final Map.Entry<Descriptors.FieldDescriptor, Object> entry : fd.getOptions().getAllFields().entrySet()) {
final Descriptors.FieldDescriptor extensionFieldDescriptor = entry.getKey();
final String extensionName = extensionFieldDescriptor.getFullName();
// first validate the field
final FieldValidator validator = fieldValidators.get(extensionName);
// not all extensions are validators, so `validator` value here could legitimately be `null`
if (validator != null) {
// first validate the field
final FieldValidator validator = fieldValidators.get(extensionName);
// not all extensions are validators, so `validator` value here could legitimately be `null`
if (validator != null) {
try {
validator.validate(entry.getValue(), fd, msg);
} catch (FieldValidationException e) {
throw GrpcExceptions.fieldViolation(fd.getName(),
"extension %s: %s".formatted(extensionName, e.getMessage()));
}
}
// Recursively validate the field's value(s) if it is a message or a repeated field
// gRPC's proto deserialization limits nesting to 100 so this has bounded stack usage
if (fd.isRepeated() && msg.getField(fd) instanceof List list) {
// Checking for repeated fields also handles maps, because maps are syntax sugar for repeated MapEntries
// which themselves are Messages that will be recursively descended.
for (final Object o : list) {
validateMessage(o);
}
} else if (fd.hasPresence() && msg.hasField(fd)) {
// If the field has presence information and is present, recursively validate it. Not all fields have
// presence, but we only validate Message type fields anyway, which always have explicit presence.
validateMessage(msg.getField(fd));
}
}
} catch (final StatusException e) {
throw e;
} catch (final Exception e) {
throw internalError(e);
// Recursively validate the field's value(s) if it is a message or a repeated field
// gRPC's proto deserialization limits nesting to 100 so this has bounded stack usage
if (fd.isRepeated() && msg.getField(fd) instanceof List list) {
// Checking for repeated fields also handles maps, because maps are syntax sugar for repeated MapEntries
// which themselves are Messages that will be recursively descended.
for (final Object o : list) {
validateMessage(o);
}
} else if (fd.hasPresence() && msg.hasField(fd)) {
// If the field has presence information and is present, recursively validate it. Not all fields have
// presence, but we only validate Message type fields anyway, which always have explicit presence.
validateMessage(msg.getField(fd));
}
}
}
}

View File

@@ -6,14 +6,10 @@
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.Message;
import io.grpc.Status;
import io.grpc.StatusException;
import java.util.Set;
public abstract class BaseFieldValidator<T> implements FieldValidator {
@@ -48,111 +44,100 @@ public abstract class BaseFieldValidator<T> implements FieldValidator {
public void validate(
final Object extensionValue,
final Descriptors.FieldDescriptor fd,
final Message msg) throws StatusException {
try {
final T extensionValueTyped = resolveExtensionValue(extensionValue);
final Message msg) throws FieldValidationException {
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 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 new FieldValidationException("extension requires a value to be set");
}
}
// for the `repeated` fields, checking if it's supported by the extension
if (fd.isRepeated()) {
if (applicableToRepeated) {
validateRepeatedField(extensionValueTyped, fd, msg);
case SUCCEED -> {
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 MESSAGE -> {
validateMessageValue(extensionValueTyped, (Message) msg.getField(fd));
}
case FLOAT, DOUBLE, BOOL, GROUP -> {
// at this moment, there are no validations specific to these types of fields
case VALIDATE_DEFAULT_VALUE -> {
// just continuing
}
}
} 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();
}
// for the `repeated` fields, checking if it's supported by the extension
if (fd.isRepeated()) {
if (applicableToRepeated) {
validateRepeatedField(extensionValueTyped, fd, msg);
return;
}
throw new IllegalArgumentException("can't apply extension %s to `repeated` field %s"
.formatted(extensionName, fd.getFullName()));
}
// checking field type against the set of supported types
final Descriptors.FieldDescriptor.Type type = fd.getType();
if (!supportedTypes.contains(type)) {
throw new IllegalArgumentException("can't apply extension %s to field %s of type %s".formatted(
extensionName, fd.getFullName(), 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 MESSAGE -> {
validateMessageValue(extensionValueTyped, (Message) msg.getField(fd));
}
case FLOAT, DOUBLE, BOOL, GROUP -> {
// at this moment, there are no validations specific to these types of fields
}
}
}
protected abstract T resolveExtensionValue(final Object extensionValue) throws StatusException;
protected abstract T resolveExtensionValue(final Object extensionValue) throws FieldValidationException;
protected void validateRepeatedField(
final T extensionValue,
final Descriptors.FieldDescriptor fd,
final Message msg) throws StatusException {
throw internalError("`validateRepeatedField` method needs to be implemented");
final Message msg) throws FieldValidationException {
throw new UnsupportedOperationException("`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");
final long fieldValue, final Descriptors.FieldDescriptor.Type type) throws FieldValidationException {
throw new UnsupportedOperationException("`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");
final String fieldValue) throws FieldValidationException {
throw new UnsupportedOperationException("`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");
final ByteString fieldValue) throws FieldValidationException {
throw new UnsupportedOperationException("`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");
final Descriptors.EnumValueDescriptor enumValueDescriptor) throws FieldValidationException {
throw new UnsupportedOperationException("`validateEnumValue` method needs to be implemented");
}
protected void validateMessageValue(
final T extensionValue,
final Message message) throws StatusException {
throw internalError("`validateMessageValue` method needs to be implemented");
final Message message) throws FieldValidationException {
throw new UnsupportedOperationException("`validateMessageValue` method needs to be implemented");
}
protected static boolean requireFlagExtension(final Object extensionValue) throws StatusException {
protected static boolean requireFlagExtension(final Object extensionValue) throws FieldValidationException {
if (extensionValue instanceof Boolean flagIsOn && flagIsOn) {
return true;
}
throw internalError("only value `true` is allowed");
throw new UnsupportedOperationException("only value `true` is allowed");
}
}

View File

@@ -5,10 +5,7 @@
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;
@@ -21,18 +18,18 @@ public class E164FieldValidator extends BaseFieldValidator<Boolean> {
}
@Override
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {
return requireFlagExtension(extensionValue);
}
@Override
protected void validateStringValue(
final Boolean extensionValue,
final String fieldValue) throws StatusException {
final String fieldValue) throws FieldValidationException {
try {
Util.requireNormalizedNumber(fieldValue);
} catch (final ImpossiblePhoneNumberException | NonNormalizedPhoneNumberException e) {
throw invalidArgument("value is not in E164 format");
throw new FieldValidationException("value is not in E164 format");
}
}
}

View File

@@ -5,10 +5,7 @@
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<Boolean> {
@@ -18,16 +15,16 @@ public class EnumSpecifiedFieldValidator extends BaseFieldValidator<Boolean> {
}
@Override
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {
return requireFlagExtension(extensionValue);
}
@Override
protected void validateEnumValue(
final Boolean extensionValue,
final Descriptors.EnumValueDescriptor enumValueDescriptor) throws StatusException {
final Descriptors.EnumValueDescriptor enumValueDescriptor) throws FieldValidationException {
if (enumValueDescriptor.getIndex() <= 0) {
throw invalidArgument("enum field must be specified");
throw new FieldValidationException("enum field must be specified");
}
}
}

View File

@@ -5,12 +5,9 @@
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.Message;
import io.grpc.StatusException;
import java.util.List;
import java.util.Set;
@@ -24,7 +21,7 @@ public class ExactlySizeFieldValidator extends BaseFieldValidator<Set<Integer>>
}
@Override
protected Set<Integer> resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Set<Integer> resolveExtensionValue(final Object extensionValue) {
//noinspection unchecked
return Set.copyOf((List<Integer>) extensionValue);
}
@@ -32,32 +29,32 @@ public class ExactlySizeFieldValidator extends BaseFieldValidator<Set<Integer>>
@Override
protected void validateBytesValue(
final Set<Integer> permittedSizes,
final ByteString fieldValue) throws StatusException {
final ByteString fieldValue) throws FieldValidationException {
if (permittedSizes.contains(fieldValue.size())) {
return;
}
throw invalidArgument("byte array length is [%d] but expected to be one of %s".formatted(fieldValue.size(), permittedSizes));
throw new FieldValidationException("byte array length is [%d] but expected to be one of %s".formatted(fieldValue.size(), permittedSizes));
}
@Override
protected void validateStringValue(
final Set<Integer> permittedSizes,
final String fieldValue) throws StatusException {
final String fieldValue) throws FieldValidationException {
if (permittedSizes.contains(fieldValue.length())) {
return;
}
throw invalidArgument("string length is [%d] but expected to be one of %s".formatted(fieldValue.length(), permittedSizes));
throw new FieldValidationException("string length is [%d] but expected to be one of %s".formatted(fieldValue.length(), permittedSizes));
}
@Override
protected void validateRepeatedField(
final Set<Integer> permittedSizes,
final Descriptors.FieldDescriptor fd,
final Message msg) throws StatusException {
final Message msg) throws FieldValidationException {
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));
throw new FieldValidationException("list size is [%d] but expected to be one of %s".formatted(size, permittedSizes));
}
}

View File

@@ -0,0 +1,11 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.whispersystems.textsecuregcm.grpc.validators;
public class FieldValidationException extends Exception {
public FieldValidationException(String message) {
super(message);
}
}

View File

@@ -7,10 +7,9 @@ package org.whispersystems.textsecuregcm.grpc.validators;
import com.google.protobuf.Descriptors;
import com.google.protobuf.Message;
import io.grpc.StatusException;
public interface FieldValidator {
void validate(Object extensionValue, Descriptors.FieldDescriptor fd, Message msg)
throws StatusException;
throws FieldValidationException;
}

View File

@@ -5,12 +5,9 @@
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.Message;
import io.grpc.StatusException;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
@@ -24,38 +21,38 @@ public class NonEmptyFieldValidator extends BaseFieldValidator<Boolean> {
}
@Override
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {
return requireFlagExtension(extensionValue);
}
@Override
protected void validateBytesValue(
final Boolean extensionValue,
final ByteString fieldValue) throws StatusException {
final ByteString fieldValue) throws FieldValidationException {
if (!fieldValue.isEmpty()) {
return;
}
throw invalidArgument("byte array expected to be non-empty");
throw new FieldValidationException("byte array expected to be non-empty");
}
@Override
protected void validateStringValue(
final Boolean extensionValue,
final String fieldValue) throws StatusException {
final String fieldValue) throws FieldValidationException {
if (StringUtils.isNotEmpty(fieldValue)) {
return;
}
throw invalidArgument("string expected to be non-empty");
throw new FieldValidationException("string expected to be non-empty");
}
@Override
protected void validateRepeatedField(
final Boolean extensionValue,
final Descriptors.FieldDescriptor fd,
final Message msg) throws StatusException {
final Message msg) throws FieldValidationException {
if (msg.getRepeatedFieldCount(fd) > 0) {
return;
}
throw invalidArgument("repeated field is expected to be non-empty");
throw new FieldValidationException("repeated field is expected to be non-empty");
}
}

View File

@@ -5,11 +5,8 @@
package org.whispersystems.textsecuregcm.grpc.validators;
import static org.whispersystems.textsecuregcm.grpc.validators.ValidatorUtils.invalidArgument;
import com.google.protobuf.Descriptors;
import com.google.protobuf.Message;
import io.grpc.StatusException;
import java.util.Set;
public class PresentFieldValidator extends BaseFieldValidator<Boolean> {
@@ -22,14 +19,14 @@ public class PresentFieldValidator extends BaseFieldValidator<Boolean> {
}
@Override
protected Boolean resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Boolean resolveExtensionValue(final Object extensionValue) throws FieldValidationException {
return requireFlagExtension(extensionValue);
}
@Override
protected void validateMessageValue(final Boolean extensionValue, final Message msg) throws StatusException {
protected void validateMessageValue(final Boolean extensionValue, final Message msg) throws FieldValidationException {
if (msg == null) {
throw invalidArgument("message expected to be present");
throw new FieldValidationException("message expected to be present");
}
}
}

View File

@@ -5,10 +5,7 @@
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;
@@ -37,7 +34,7 @@ public class RangeFieldValidator extends BaseFieldValidator<Range> {
}
@Override
protected Range resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Range resolveExtensionValue(final Object extensionValue) {
final ValueRangeConstraint rangeConstraint = (ValueRangeConstraint) extensionValue;
final long min = rangeConstraint.hasMin() ? rangeConstraint.getMin() : Long.MIN_VALUE;
final long max = rangeConstraint.hasMax() ? rangeConstraint.getMax() : Long.MAX_VALUE;
@@ -48,13 +45,13 @@ public class RangeFieldValidator extends BaseFieldValidator<Range> {
protected void validateIntegerNumber(
final Range range,
final long fieldValue,
final Descriptors.FieldDescriptor.Type type) throws StatusException {
final Descriptors.FieldDescriptor.Type type) throws FieldValidationException {
if (fieldValue < 0 && UNSIGNED_TYPES.contains(type)) {
throw invalidArgument("field value is expected to be within the [%d, %d] range".formatted(
throw new FieldValidationException("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(
throw new FieldValidationException("field value is [%d] but expected to be within the [%d, %d] range".formatted(
fieldValue, range.min(), range.max()));
}
}

View File

@@ -5,12 +5,9 @@
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.Message;
import io.grpc.StatusException;
import java.util.Set;
import org.signal.chat.require.SizeConstraint;
@@ -24,7 +21,7 @@ public class SizeFieldValidator extends BaseFieldValidator<Range> {
}
@Override
protected Range resolveExtensionValue(final Object extensionValue) throws StatusException {
protected Range resolveExtensionValue(final Object extensionValue) throws FieldValidationException {
final SizeConstraint sizeConstraint = (SizeConstraint) extensionValue;
final int min = sizeConstraint.hasMin() ? sizeConstraint.getMin() : 0;
final int max = sizeConstraint.hasMax() ? sizeConstraint.getMax() : Integer.MAX_VALUE;
@@ -32,26 +29,26 @@ public class SizeFieldValidator extends BaseFieldValidator<Range> {
}
@Override
protected void validateBytesValue(final Range range, final ByteString fieldValue) throws StatusException {
protected void validateBytesValue(final Range range, final ByteString fieldValue) throws FieldValidationException {
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(
throw new FieldValidationException("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 {
protected void validateStringValue(final Range range, final String fieldValue) throws FieldValidationException {
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(
throw new FieldValidationException("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 Message msg) throws StatusException {
protected void validateRepeatedField(final Range range, final Descriptors.FieldDescriptor fd, final Message msg) throws FieldValidationException {
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(
throw new FieldValidationException("field value is [%d] but expected to be within the [%d, %d] range".formatted(
size, range.min(), range.max()));
}
}

View File

@@ -23,18 +23,6 @@ public final class 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<Auth> serviceAuthExtensionValue(final ServerServiceDefinition serviceDefinition) {
return serviceExtensionValueByName(serviceDefinition, REQUIRE_AUTH_EXTENSION_NAME)
.map(val -> Auth.valueOf((Descriptors.EnumValueDescriptor) val));