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