diff --git a/.changes/next-release/feature-22e1dd049a18d2fdf13cf205af81f5374e34a46b.json b/.changes/next-release/feature-22e1dd049a18d2fdf13cf205af81f5374e34a46b.json new file mode 100644 index 00000000000..199cb2f5964 --- /dev/null +++ b/.changes/next-release/feature-22e1dd049a18d2fdf13cf205af81f5374e34a46b.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "description": "Added a `NodeValidationVisitor` feature that enforces base64 encoding of blob values.", + "pull_requests": [ + "[#2838](https://github.com/smithy-lang/smithy/pull/2838)" + ] +} diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java index a7a2264b201..a602d9972a4 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/NodeValidationVisitor.java @@ -4,7 +4,9 @@ */ package software.amazon.smithy.model.validation; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Base64; import java.util.Collection; import java.util.Collections; import java.util.EnumSet; @@ -106,7 +108,14 @@ public enum Feature { * *

By default, null values are not allowed for optional types. */ - ALLOW_OPTIONAL_NULLS; + ALLOW_OPTIONAL_NULLS, + + /** + * Requires that blob values are validly encoded base64 strings. + * + *

By default, blob values which are not valid base64 encoded strings will be allowed. + */ + REQUIRE_BASE_64_BLOB_VALUES; public static Feature fromNode(Node node) { return Feature.valueOf(node.expectStringNode().getValue()); @@ -178,7 +187,19 @@ private NodeValidationVisitor traverse(String segment, Node node) { @Override public List blobShape(BlobShape shape) { return value.asStringNode() - .map(stringNode -> applyPlugins(shape)) + .map(stringNode -> { + if (validationContext.hasFeature(Feature.REQUIRE_BASE_64_BLOB_VALUES)) { + byte[] encodedValue = stringNode.getValue().getBytes(StandardCharsets.UTF_8); + + try { + Base64.getDecoder().decode(encodedValue); + } catch (IllegalArgumentException e) { + return ListUtils.of(event("Blob value must be a valid base64 string")); + } + } + + return applyPlugins(shape); + }) .orElseGet(() -> invalidShape(shape, NodeType.STRING)); } diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java index 69fff60e8b5..425d72ffa3f 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/validation/node/BlobLengthPlugin.java @@ -5,6 +5,7 @@ package software.amazon.smithy.model.validation.node; import java.nio.charset.StandardCharsets; +import java.util.Base64; import software.amazon.smithy.model.node.StringNode; import software.amazon.smithy.model.shapes.BlobShape; import software.amazon.smithy.model.shapes.Shape; @@ -25,8 +26,13 @@ final class BlobLengthPlugin extends MemberAndShapeTraitPlugin { if (size < min) { @@ -39,7 +45,7 @@ protected void check(Shape shape, LengthTrait trait, StringNode node, Context co }); trait.getMax().ifPresent(max -> { - if (value.getBytes(StandardCharsets.UTF_8).length > max) { + if (size > max) { emitter.accept(node, getSeverity(context), "Value provided for `" + shape.getId() diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java index 1d7ee51bacc..f2fc4fb3447 100644 --- a/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java +++ b/smithy-model/src/test/java/software/amazon/smithy/model/validation/NodeValidationVisitorTest.java @@ -691,4 +691,56 @@ public void nullSparseMapValue() { assertThat(events, empty()); } + + @ParameterizedTest + @MethodSource("requiredBase64BlobValueData") + public void withRequiredBase64BlobValuesTest(String target, String value, String[] errors) { + ShapeId targetId = ShapeId.from(target); + Node nodeValue = Node.parse(value); + NodeValidationVisitor visitor = NodeValidationVisitor.builder() + .value(nodeValue) + .model(MODEL) + .addFeature(NodeValidationVisitor.Feature.REQUIRE_BASE_64_BLOB_VALUES) + .build(); + List events = MODEL.expectShape(targetId).accept(visitor); + + if (errors != null) { + List messages = events.stream().map(ValidationEvent::getMessage).collect(Collectors.toList()); + assertThat(messages, containsInAnyOrder(errors)); + } else if (!events.isEmpty()) { + Assertions.fail("Did not expect any problems with the value, but found: " + + events.stream().map(Object::toString).collect(Collectors.joining("\n"))); + } + } + + public static Collection requiredBase64BlobValueData() { + return Arrays.asList(new Object[][] { + + // Blobs + {"ns.foo#Blob1", "\"\"", null}, + {"ns.foo#Blob1", + "\"{}\"", + new String[] { + "Blob value must be a valid base64 string" + }}, + {"ns.foo#Blob1", "\"Zm9v\"", null}, + {"ns.foo#Blob1", + "true", + new String[] { + "Expected string value for blob shape, `ns.foo#Blob1`; found boolean value, `true`" + }}, + {"ns.foo#Blob2", "\"Zg==\"", null}, + {"ns.foo#Blob2", + "\"Zm9vbw==\"", + new String[] { + "Value provided for `ns.foo#Blob2` must have no more than 3 bytes, but the provided value has 4 bytes"}}, + {"ns.foo#Blob2", + "\"\"", + new String[] { + "Value provided for `ns.foo#Blob2` must have at least 1 bytes, but the provided value only has 0 bytes"}}, + + // base64 encoded value without padding + {"ns.foo#Blob1", "\"Zg\"", null}, + }); + } }