diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Assertion.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Assertion.java index 2c50b67..205240f 100644 --- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Assertion.java +++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Assertion.java @@ -5,11 +5,13 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.function.Function; import org.oneedtech.inspect.schema.Catalog; import org.oneedtech.inspect.schema.SchemaKey; import org.oneedtech.inspect.util.resource.Resource; import org.oneedtech.inspect.vc.util.JsonNodeUtil; +import org.oneedtech.inspect.vc.util.PrimitiveValueValidator; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; @@ -125,23 +127,33 @@ public class Assertion extends Credential { } public enum ValueType { - BOOLEAN, - COMPACT_IRI, - DATA_URI, - DATA_URI_OR_URL, - DATETIME, - EMAIL, - ID, - IDENTITY_HASH, - IRI, - LANGUAGE, - MARKDOWN_TEXT, - RDF_TYPE, - TELEPHONE, - TEXT, - TEXT_OR_NUMBER, - URL, - URL_AUTHORITY; + BOOLEAN(PrimitiveValueValidator::validateBoolean), + COMPACT_IRI(PrimitiveValueValidator::validateCompactIri), + DATA_URI(PrimitiveValueValidator::validateDataUri), + DATA_URI_OR_URL(PrimitiveValueValidator::validateDataUriOrUrl), + DATETIME(PrimitiveValueValidator::validateDatetime), + EMAIL(PrimitiveValueValidator::validateEmail), + ID(null), + IDENTITY_HASH(PrimitiveValueValidator::validateIdentityHash), + IRI(PrimitiveValueValidator::validateIri), + LANGUAGE(PrimitiveValueValidator::validateLanguage), + MARKDOWN_TEXT(PrimitiveValueValidator::validateMarkdown), + RDF_TYPE(PrimitiveValueValidator::validateRdfType), + TELEPHONE(PrimitiveValueValidator::validateTelephone), + TEXT(PrimitiveValueValidator::validateText), + TEXT_OR_NUMBER(PrimitiveValueValidator::validateTextOrNumber), + URL(PrimitiveValueValidator::validateUrl), + URL_AUTHORITY(PrimitiveValueValidator::validateUrlAuthority); + + private final Function validationFunction; + + private ValueType(Function validationFunction) { + this.validationFunction = validationFunction; + } + + public Function getValidationFunction() { + return validationFunction; + } public static List primitives = List.of(BOOLEAN, DATA_URI_OR_URL, DATETIME, ID, IDENTITY_HASH, IRI, LANGUAGE, MARKDOWN_TEXT, TELEPHONE, TEXT, TEXT_OR_NUMBER, URL, URL_AUTHORITY); diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB20Inspector.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB20Inspector.java index 0f6c636..bf1eb24 100644 --- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB20Inspector.java +++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/OB20Inspector.java @@ -21,7 +21,6 @@ import org.oneedtech.inspect.util.json.ObjectMapperCache; import org.oneedtech.inspect.util.resource.Resource; import org.oneedtech.inspect.util.resource.ResourceType; import org.oneedtech.inspect.util.spec.Specification; -import org.oneedtech.inspect.vc.Assertion.Type; import org.oneedtech.inspect.vc.Credential.CredentialEnum; import org.oneedtech.inspect.vc.jsonld.JsonLdGeneratedObject; import org.oneedtech.inspect.vc.jsonld.probe.JsonLDCompactionProve; @@ -31,6 +30,7 @@ import org.oneedtech.inspect.vc.payload.SvgParser; import org.oneedtech.inspect.vc.probe.ContextPropertyProbe; import org.oneedtech.inspect.vc.probe.CredentialParseProbe; import org.oneedtech.inspect.vc.probe.TypePropertyProbe; +import org.oneedtech.inspect.vc.probe.ValidationPropertyProbe; import org.oneedtech.inspect.vc.util.CachingDocumentLoader; import com.fasterxml.jackson.databind.JsonNode; @@ -116,6 +116,21 @@ public class OB20Inspector extends Inspector { accumulator.add(new JsonLDValidationProbe(jsonLdGeneratedObject).run(assertion, ctx)); if(broken(accumulator, true)) return abort(ctx, accumulator, probeCount); + // Validates the Open Badge + List validations = assertion.getValidations(); + for (Validation validation : validations) { + probeCount++; + accumulator.add(new ValidationPropertyProbe(validation).run(assertion.getJson(), ctx)); + if(broken(accumulator)) return abort(ctx, accumulator, probeCount); + } + + // Each Badge Object contains all required properties for its class + // This could be done validating with the schema, but seems that there are some error on that file + // So, we do a manual Probe for the nodes + + // accumulator.add(new RequiredFieldsProbe(jsonLdGeneratedObject).run(assertion, ctx)); + // if(broken(accumulator, true)) return abort(ctx, accumulator, probeCount); + //canonical schema and inline schemata // SchemaKey schema = assertion.getSchemaKey().orElseThrow(); // for(Probe probe : List.of(new JsonSchemaProbe(schema), new InlineJsonSchemaProbe(schema))) { diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Validation.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Validation.java index 509665b..8ecd22c 100644 --- a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Validation.java +++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/Validation.java @@ -36,6 +36,55 @@ public class Validation { this.fullValidate = builder.fullValidate; } + + public String getName() { + return name; + } + + public Assertion.ValueType getType() { + return type; + } + + public boolean isRequired() { + return required; + } + + public boolean isMany() { + return many; + } + + public List getMustContainOne() { + return mustContainOne; + } + + public List getPrerequisites() { + return prerequisites; + } + + public List getExpectedTypes() { + return expectedTypes; + } + + public boolean isAllowRemoteUrl() { + return allowRemoteUrl; + } + + public boolean isAllowDataUri() { + return allowDataUri; + } + + public boolean isFetch() { + return fetch; + } + + public String getDefaultType() { + return defaultType; + } + + public boolean isFullValidate() { + return fullValidate; + } + public static class Builder { private String name; private Assertion.ValueType type; diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ValidationPropertyProbe.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ValidationPropertyProbe.java new file mode 100644 index 0000000..a64b0aa --- /dev/null +++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/probe/ValidationPropertyProbe.java @@ -0,0 +1,135 @@ +package org.oneedtech.inspect.vc.probe; + +import java.util.List; +import java.util.function.Function; + +import org.oneedtech.inspect.core.probe.RunContext; +import org.oneedtech.inspect.core.report.ReportItems; +import org.oneedtech.inspect.vc.Validation; +import org.oneedtech.inspect.vc.Assertion.ValueType; +import org.oneedtech.inspect.vc.util.JsonNodeUtil; + +import com.fasterxml.jackson.databind.JsonNode; + + +public class ValidationPropertyProbe extends PropertyProbe { + private final Validation validation; + + public ValidationPropertyProbe(Validation validation) { + super(ID + "<" + validation.getName() + ">", validation.getName()); + this.validation = validation; + setValidations(this::validate); + } + + + @Override + protected ReportItems reportForNonExistentProperty(JsonNode node, RunContext ctx) { + if (validation.isRequired()) { + return error("Required property " + validation.getName() + " not present in " + node.toPrettyString(), ctx); + } else { + // optional property + return success(ctx); + } + } + + /** + * Validates presence and data type of a single property that is + * expected to be one of the Open Badges Primitive data types or an ID. + * @param node node to check data type + * @param ctx associated run context + * @return validation result + */ + private ReportItems validate(JsonNode node, RunContext ctx) { + // required property + if (validation.isRequired()) { + if (node.isObject()) { + if (!node.fieldNames().hasNext()) { + return error("Required property " + validation.getName() + " value " + node.toString() + " is not acceptable", ctx); + } + } else { + List values = JsonNodeUtil.asStringList(node); + if (values == null ||values.isEmpty()) { + return error("Required property " + validation.getName() + " value " + values + " is not acceptable", ctx); + } + } + } + + List nodeList = JsonNodeUtil.asNodeList(node); + // many property + if (!validation.isMany()) { + if (nodeList.size() > 1) { + return error("Property " + validation.getName() + "has more than the single allowed value.", ctx); + } + } + + try { + if (validation.getType() != ValueType.ID) { + Function validationFunction = validation.getType().getValidationFunction(); + for (JsonNode childNode : nodeList) { + Boolean valid = validationFunction.apply(childNode); + if (!valid) { + return error(validation.getType() + " property " + validation.getName() + " value " + childNode.toString() + " not valid", ctx); + } + } + } else { + /** + for i in range(len(values_to_test)): + val = values_to_test[i] + if isinstance(prop_value, (list, tuple,)): + value_to_test_path = [node_id, prop_name, i] + else: + value_to_test_path = [node_id, prop_name] + + if isinstance(val, dict): + actions.append( + add_task(VALIDATE_EXPECTED_NODE_CLASS, node_path=value_to_test_path, + expected_class=task_meta.get('expected_class'), + full_validate=task_meta.get('full_validate', True))) + continue + elif task_meta.get('allow_data_uri') and not PrimitiveValueValidator(ValueTypes.DATA_URI_OR_URL)(val): + raise ValidationError("ID-type property {} had value `{}` that isn't URI or DATA URI in {}.".format( + prop_name, abv(val), abv_node(node_id, node_path)) + ) + elif not task_meta.get('allow_data_uri', False) and not PrimitiveValueValidator(ValueTypes.IRI)(val): + actions.append(report_message( + "ID-type property {} had value `{}` where another scheme may have been expected {}.".format( + prop_name, abv(val), abv_node(node_id, node_path) + ), message_level=MESSAGE_LEVEL_WARNING)) + raise ValidationError( + "ID-type property {} had value `{}` not embedded node or in IRI format in {}.".format( + prop_name, abv(val), abv_node(node_id, node_path)) + ) + try: + target = get_node_by_id(state, val) + except IndexError: + if not task_meta.get('fetch', False): + if task_meta.get('allow_remote_url') and PrimitiveValueValidator(ValueTypes.URL)(val): + continue + if task_meta.get('allow_data_uri') and PrimitiveValueValidator(ValueTypes.DATA_URI)(val): + continue + raise ValidationError( + 'Node {} has {} property value `{}` that appears not to be in URI format'.format( + abv_node(node_id, node_path), prop_name, abv(val) + ) + ' or did not correspond to a known local node.') + else: + actions.append( + add_task(FETCH_HTTP_NODE, url=val, + expected_class=task_meta.get('expected_class'), + source_node_path=value_to_test_path + )) + else: + actions.append( + add_task(VALIDATE_EXPECTED_NODE_CLASS, node_id=val, + expected_class=task_meta.get('expected_class'))) + */ + } + } catch (Throwable t) { + return fatal(t.getMessage(), ctx); + } + + return success(ctx); + } + + public static final String ID = ValidationPropertyProbe.class.getSimpleName(); + +} diff --git a/inspector-vc/src/main/java/org/oneedtech/inspect/vc/util/PrimitiveValueValidator.java b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/util/PrimitiveValueValidator.java new file mode 100644 index 0000000..0e81463 --- /dev/null +++ b/inspector-vc/src/main/java/org/oneedtech/inspect/vc/util/PrimitiveValueValidator.java @@ -0,0 +1,173 @@ +package org.oneedtech.inspect.vc.util; + +import java.io.IOException; +import java.io.StringReader; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.IllformedLocaleException; +import java.util.List; +import java.util.Locale; + +import org.oneedtech.inspect.core.probe.json.JsonPathEvaluator; + +import com.apicatalog.jsonld.JsonLd; +import com.apicatalog.jsonld.JsonLdError; +import com.apicatalog.jsonld.document.JsonDocument; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import com.google.common.io.Resources; + +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; + +/** + * Validator for ValueType. Translated into java from PrimitiveValueValidator in validation.py + */ +public class PrimitiveValueValidator { + + public static boolean validateBoolean(JsonNode value) { + return value.isValueNode() && value.isBoolean(); + } + + public static boolean validateCompactIri(JsonNode value) { + if (value.asText().equals("id") || validateIri(value)) { + return true; + } + + return false; + } + + public static boolean validateDataUri(JsonNode value) { + try { + URI uri = new URI(value.asText()); + return "data".equalsIgnoreCase(uri.getScheme()); + } catch (Throwable ignored) { + } + return false; + } + + public static boolean validateDataUriOrUrl(JsonNode value) { + return validateUrl(value) || validateDataUri(value); + } + + public static boolean validateDatetime(JsonNode value) { + try { + DateTimeFormatter.ISO_INSTANT.parse(value.asText()); + return true; + } catch (DateTimeParseException | NullPointerException ignored) { + } + return false; + } + + public static boolean validateEmail(JsonNode value) { + return value.asText().matches("(^[^@\\s]+@[^@\\s]+$)"); + } + + public static boolean is_hashed_identity_hash(JsonNode value) { + return value.asText().matches("md5\\$[\\da-fA-F]{32}$") || value.asText().matches("sha256\\$[\\da-fA-F]{64}$"); + } + + /** + * Validates that identity is a string. More specific rules may only be enforced at the class instance level. + * @param value + * @return + */ + public static boolean validateIdentityHash(JsonNode value) { + return validateText(value); + } + + /** + * Checks if a string matches an acceptable IRI format and scheme. For now, only accepts a few schemes, + * 'http', 'https', blank node identifiers, and 'urn:uuid' + + * @return + */ + public static boolean validateIri(JsonNode value) { + return validateUrl(value) || value.asText().matches("^_:") || value.asText().matches("^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"); + } + + public static boolean validateLanguage(JsonNode value) { + try { + return validateText(value) && new Locale.Builder().setLanguageTag(value.asText()).build() != null; + } catch (IllformedLocaleException ignored) { + // value is not a valid locale + } + return false; + } + + public static boolean validateMarkdown(JsonNode value) { + return validateText(value); + } + + public static boolean validateRdfType(JsonNode value) { + if (!validateText(value)) { + return false; + } + + ObjectMapper mapper = new ObjectMapper(); // TODO: get from RunContext + JsonPathEvaluator jsonPath = new JsonPathEvaluator(mapper); + + try { + JsonNode node = mapper.readTree(Resources.getResource("contexts/ob-v2p0.json")); + ObjectReader readerForUpdating = mapper.readerForUpdating(node); + JsonNode merged = readerForUpdating.readValue("{\"type\": \"" + value.asText() + "\"}"); + + JsonDocument jsonDocument = JsonDocument.of(new StringReader(merged.toString())); + JsonNode expanded = mapper.readTree(JsonLd.expand(jsonDocument).get().toString()); + + return validateIri(JsonNodeUtil.asNodeList(expanded, "$[0].@type[0]", jsonPath).get(0)); + + } catch (NullPointerException | IOException | JsonLdError e) { + return false; + } + } + + public static boolean validateTelephone(JsonNode value) { + return value.asText().matches("^\\+?[1-9]\\d{1,14}(;ext=\\d+)?$"); + } + + public static boolean validateText(JsonNode value) { + return value.isValueNode() && value.isTextual(); + } + + public static boolean validateTextOrNumber(JsonNode value) { + return value.isValueNode() && value.isTextual() || value.isNumber(); + } + + public static boolean validateUrl(JsonNode value) { + if (!value.isValueNode()) { + return false; + } + + try { + new URL(value.asText()); + return true; + } catch (MalformedURLException ignored) { + // value is not a valid URL + } + return false; + } + + public static boolean validateUrlAuthority(JsonNode value) { + if (!validateText(value)) { + return false; + } + + URI testUri; + try { + testUri = new URI("http://" + value.asText() + "/test"); + String host = testUri.getHost(); + if (host == null || !host.matches("(?=^.{4,253}$)(^((?!-)[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]\\.)+[a-zA-Z]{2,63}$)")) { + return false; + } + return testUri.getScheme().equals("http") && host.equals(value.asText()) && testUri.getPath().equals("/test") && testUri.getQuery() == null; + } catch (URISyntaxException e) { + return false; + } + } +}