LocalJsonSchemaValidator.java
package org.hammer.audio.experiment.document;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.hammer.audio.plugin.document.DocumentDiagnostic;
import org.hammer.audio.plugin.document.DocumentValue;
/**
* Small deterministic validator for the safe JSON Schema subset used by plugin parameter sections.
*
* <p>Supported keywords are {@code type}, {@code required}, {@code properties}, {@code
* additionalProperties}, {@code enum}, {@code minimum}, {@code maximum}, {@code minLength}, {@code
* maxLength}, {@code minItems} and {@code maxItems}. Network and implementation references are not
* supported.
*/
@SuppressWarnings({
"PMD.LooseCoupling",
"PMD.AvoidDuplicateLiterals",
"PMD.LiteralsFirstInComparisons",
"PMD.ConfusingTernary"
})
final class LocalJsonSchemaValidator {
private static final int MAX_SCHEMA_BYTES = 256 * 1024;
private static final Set<String> ALLOWED_KEYWORDS =
Set.of(
"$id",
"$schema",
"title",
"description",
"type",
"required",
"properties",
"additionalProperties",
"enum",
"minimum",
"maximum",
"minLength",
"maxLength",
"minItems",
"maxItems");
private final ObjectMapper mapper;
LocalJsonSchemaValidator() {
JsonFactory factory =
JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build();
mapper = new ObjectMapper(factory);
}
List<DocumentDiagnostic> validate(
String schemaJson, String expectedSha256, DocumentValue value, String pointer) {
ArrayList<DocumentDiagnostic> diagnostics = new ArrayList<>();
byte[] schemaBytes = schemaJson.getBytes(StandardCharsets.UTF_8);
if (schemaBytes.length > MAX_SCHEMA_BYTES) {
diagnostics.add(error(pointer, "schema-too-large", "Plugin schema exceeds 256 KiB"));
return diagnostics;
}
if (!DocumentHashes.sha256(schemaBytes).equals(expectedSha256)) {
diagnostics.add(
error(pointer, "schema-digest", "Bundled plugin schema digest does not match"));
return diagnostics;
}
JsonNode parsed;
try {
parsed = mapper.readTree(schemaBytes);
} catch (IOException exception) {
diagnostics.add(error(pointer, "invalid-schema", "Bundled plugin schema is not strict JSON"));
return diagnostics;
}
if (!(parsed instanceof ObjectNode root)) {
diagnostics.add(error(pointer, "invalid-schema", "Plugin schema root must be an object"));
return diagnostics;
}
validateSchemaNode(root, value, pointer, diagnostics, 0);
return diagnostics;
}
private void validateSchemaNode(
ObjectNode schema,
DocumentValue value,
String pointer,
List<DocumentDiagnostic> diagnostics,
int depth) {
if (depth > ExperimentDocumentFormat.MAX_NESTING_DEPTH) {
diagnostics.add(error(pointer, "schema-depth", "Plugin schema nesting exceeds the limit"));
return;
}
Iterator<String> names = schema.fieldNames();
while (names.hasNext()) {
String name = names.next();
if (name.equals("$ref")) {
diagnostics.add(error(pointer, "schema-ref", "Plugin schemas must not contain $ref"));
} else if (!ALLOWED_KEYWORDS.contains(name)) {
diagnostics.add(
error(pointer, "schema-keyword", "Unsupported plugin schema keyword: " + name));
}
}
String type = schema.path("type").isTextual() ? schema.path("type").textValue() : "";
if (!type.isBlank() && !matchesType(type, value)) {
diagnostics.add(error(pointer, "schema-type", "Expected plugin data type " + type));
return;
}
validateEnum(schema.get("enum"), value, pointer, diagnostics);
switch (value) {
case DocumentValue.ObjectValue objectValue ->
validateObject(schema, objectValue, pointer, diagnostics, depth);
case DocumentValue.ArrayValue arrayValue ->
validateArray(schema, arrayValue, pointer, diagnostics);
case DocumentValue.StringValue stringValue ->
validateString(schema, stringValue, pointer, diagnostics);
case DocumentValue.NumberValue numberValue ->
validateNumber(schema, numberValue, pointer, diagnostics);
case DocumentValue.BooleanValue ignored -> {
// Type and enum checks are sufficient.
}
case DocumentValue.NullValue ignored -> {
// Type and enum checks are sufficient.
}
}
}
private void validateObject(
ObjectNode schema,
DocumentValue.ObjectValue value,
String pointer,
List<DocumentDiagnostic> diagnostics,
int depth) {
Set<String> required = textSet(schema.get("required"), pointer, diagnostics);
for (String requiredField : required) {
if (!value.fields().containsKey(requiredField)) {
diagnostics.add(
error(
DocumentValueJson.pointer(pointer, requiredField),
"schema-required",
"Missing required plugin field"));
}
}
JsonNode propertiesNode = schema.get("properties");
ObjectNode properties = propertiesNode instanceof ObjectNode objectNode ? objectNode : null;
boolean additional =
!schema.has("additionalProperties") || schema.path("additionalProperties").asBoolean(true);
for (Map.Entry<String, DocumentValue> entry : value.fields().entrySet()) {
String childPointer = DocumentValueJson.pointer(pointer, entry.getKey());
JsonNode propertySchema = properties == null ? null : properties.get(entry.getKey());
if (propertySchema instanceof ObjectNode objectSchema) {
validateSchemaNode(objectSchema, entry.getValue(), childPointer, diagnostics, depth + 1);
} else if (!additional) {
diagnostics.add(error(childPointer, "schema-additional", "Unknown plugin field"));
}
}
}
private static void validateArray(
ObjectNode schema,
DocumentValue.ArrayValue value,
String pointer,
List<DocumentDiagnostic> diagnostics) {
int size = value.values().size();
if (schema.has("minItems") && size < schema.path("minItems").asInt()) {
diagnostics.add(error(pointer, "schema-min-items", "Plugin array has too few items"));
}
if (schema.has("maxItems") && size > schema.path("maxItems").asInt()) {
diagnostics.add(error(pointer, "schema-max-items", "Plugin array has too many items"));
}
}
private static void validateString(
ObjectNode schema,
DocumentValue.StringValue value,
String pointer,
List<DocumentDiagnostic> diagnostics) {
int length = value.value().length();
if (schema.has("minLength") && length < schema.path("minLength").asInt()) {
diagnostics.add(error(pointer, "schema-min-length", "Plugin string is too short"));
}
if (schema.has("maxLength") && length > schema.path("maxLength").asInt()) {
diagnostics.add(error(pointer, "schema-max-length", "Plugin string is too long"));
}
}
private static void validateNumber(
ObjectNode schema,
DocumentValue.NumberValue value,
String pointer,
List<DocumentDiagnostic> diagnostics) {
BigDecimal number = value.value();
if (schema.has("minimum") && number.compareTo(schema.path("minimum").decimalValue()) < 0) {
diagnostics.add(error(pointer, "schema-minimum", "Plugin number is below minimum"));
}
if (schema.has("maximum") && number.compareTo(schema.path("maximum").decimalValue()) > 0) {
diagnostics.add(error(pointer, "schema-maximum", "Plugin number is above maximum"));
}
}
private static void validateEnum(
JsonNode enumNode,
DocumentValue value,
String pointer,
List<DocumentDiagnostic> diagnostics) {
if (!(enumNode instanceof ArrayNode values)) {
return;
}
JsonNode actual = DocumentValueJson.toJson(value);
for (JsonNode candidate : values) {
if (candidate.equals(actual)) {
return;
}
}
diagnostics.add(error(pointer, "schema-enum", "Plugin value is not in the allowed set"));
}
private static Set<String> textSet(
JsonNode node, String pointer, List<DocumentDiagnostic> diagnostics) {
if (node == null) {
return Set.of();
}
if (!(node instanceof ArrayNode array)) {
diagnostics.add(error(pointer, "invalid-schema", "Schema required must be an array"));
return Set.of();
}
HashSet<String> result = new HashSet<>();
for (JsonNode item : array) {
if (!item.isTextual()) {
diagnostics.add(error(pointer, "invalid-schema", "Schema required items must be strings"));
} else {
result.add(item.textValue());
}
}
return result;
}
private static boolean matchesType(String type, DocumentValue value) {
return switch (type) {
case "object" -> value instanceof DocumentValue.ObjectValue;
case "array" -> value instanceof DocumentValue.ArrayValue;
case "string" -> value instanceof DocumentValue.StringValue;
case "number", "integer" -> value instanceof DocumentValue.NumberValue;
case "boolean" -> value instanceof DocumentValue.BooleanValue;
case "null" -> value instanceof DocumentValue.NullValue;
default -> false;
};
}
private static DocumentDiagnostic error(String pointer, String code, String message) {
return new DocumentDiagnostic(DocumentDiagnostic.Severity.ERROR, pointer, code, message);
}
}