use serde_json::Value;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use super::get_schema;
pub fn validate_instance(schema: &Value, instance: &Value) -> Result<(), AppError> {
let validator = jsonschema::validator_for(schema).map_err(|e| {
AppError::Internal(format!("invalid credentialSchema: {}", describe_bad(&e)))
})?;
if let Err(error) = validator.validate(instance) {
return Err(AppError::Validation(format!(
"credential does not conform to its registered schema: {error}"
)));
}
Ok(())
}
pub fn check_schema(schema: &Value) -> Result<(), String> {
if !schema.is_object() {
return Err(format!(
"expected a JSON object, found {}",
json_type_name(schema)
));
}
jsonschema::validator_for(schema)
.map(|_| ())
.map_err(|e| describe_bad(&e))
}
fn describe_bad(e: &jsonschema::ValidationError<'_>) -> String {
let at = e.instance_path().to_string();
if at.is_empty() || at == "/" {
e.to_string()
} else {
format!("at {at}: {e}")
}
}
fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
fn candidate_types(credential: &Value) -> Vec<String> {
credential
.get("type")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.filter(|t| *t != "VerifiableCredential")
.map(String::from)
.collect()
})
.unwrap_or_default()
}
pub async fn validate_issued(
schemas_ks: &KeyspaceHandle,
credential: &Value,
) -> Result<(), AppError> {
for type_uri in candidate_types(credential) {
let Some(entry) = get_schema(schemas_ks, &type_uri).await? else {
continue;
};
return match &entry.credential_schema {
Some(schema) => {
let subject = credential.get("credentialSubject").unwrap_or(&Value::Null);
validate_instance(schema, subject)
}
None => Ok(()),
};
}
Ok(())
}
#[cfg(test)]
mod external_ref_tests {
use super::validate_instance;
use serde_json::json;
#[test]
fn an_external_ref_is_refused_rather_than_fetched() {
let dir = tempfile::tempdir().expect("temp dir");
let target = dir.path().join("ref-target.json");
std::fs::write(&target, br#"{"type": "string"}"#).expect("write target");
assert!(target.exists());
let by_file = json!({ "$ref": format!("file://{}", target.display()) });
let err = validate_instance(&by_file, &json!("anything"))
.expect_err("a file:// $ref must not be read from disk");
assert!(
matches!(err, vti_common::error::AppError::Internal(_)),
"the schema is at fault, not the instance: {err:?}"
);
let by_http = json!({ "$ref": "http://127.0.0.1:1/schema.json" });
assert!(
validate_instance(&by_http, &json!("anything")).is_err(),
"an http:// $ref must not be fetched"
);
let internal = json!({
"$defs": { "name": { "type": "string" } },
"properties": { "name": { "$ref": "#/$defs/name" } },
"required": ["name"],
});
assert!(validate_instance(&internal, &json!({ "name": "ok" })).is_ok());
assert!(validate_instance(&internal, &json!({ "name": 7 })).is_err());
}
}
#[cfg(test)]
mod tests {
use super::super::{SchemaEntry, SchemaKind, store_schema};
use super::*;
use chrono::Utc;
use serde_json::json;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn membership_credential() -> Value {
json!({
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential", "MembershipCredential"],
"issuer": "did:web:acme",
"credentialSubject": { "id": "did:key:zMember", "tier": "gold" }
})
}
#[test]
fn check_schema_names_the_part_that_is_not_a_schema() {
check_schema(&json!({
"type": "object",
"properties": { "level": { "type": "integer" } },
"required": ["level"]
}))
.expect("a valid JSON Schema compiles");
for (schema, expected_at) in [
(json!({ "type": "not-a-type" }), "/type"),
(json!({ "properties": "nope" }), "/properties"),
(json!({ "required": "nope" }), "/required"),
(
json!({ "type": "object", "properties": { "level": { "type": "intiger" } } }),
"/properties/level/type",
),
] {
let detail = check_schema(&schema).expect_err("not a JSON Schema");
assert!(
detail.starts_with(&format!("at {expected_at}:")),
"{schema} → {detail}"
);
}
}
#[test]
fn check_schema_refuses_a_non_object() {
for (doc, named) in [
(json!(true), "a boolean"),
(json!("{}"), "a string"),
(json!([{ "type": "object" }]), "an array"),
(json!(null), "null"),
] {
let detail = check_schema(&doc).expect_err("not an object");
assert_eq!(detail, format!("expected a JSON object, found {named}"));
}
}
#[test]
fn validate_instance_accepts_and_rejects() {
let schema = json!({
"type": "object",
"properties": { "id": { "type": "string" }, "tier": { "enum": ["gold", "silver"] } },
"required": ["id", "tier"]
});
validate_instance(&schema, &json!({ "id": "x", "tier": "gold" })).expect("conforms");
assert!(matches!(
validate_instance(&schema, &json!({ "id": "x" })),
Err(AppError::Validation(_))
));
assert!(matches!(
validate_instance(&schema, &json!({ "id": "x", "tier": "bronze" })),
Err(AppError::Validation(_))
));
}
async fn ks_with(schema: Option<Value>) -> (tempfile::TempDir, Store, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("schemas").unwrap();
let entry = SchemaEntry {
type_uri: "MembershipCredential".into(),
dtg_type: Some("MembershipCredential".into()),
credential_schema: schema,
kind: SchemaKind::Issues,
description: None,
created_at: Utc::now(),
created_by_did: "did:key:zAdmin".into(),
};
store_schema(&ks, &entry).await.unwrap();
(dir, store, ks)
}
#[tokio::test]
async fn validate_issued_enforces_a_registered_schema() {
let (_d, _s, ks) = ks_with(Some(json!({
"type": "object",
"required": ["id", "tier"]
})))
.await;
validate_issued(&ks, &membership_credential())
.await
.expect("conforming credential passes");
let (_d2, _s2, ks2) = ks_with(Some(json!({
"type": "object",
"required": ["id", "endorsedBy"]
})))
.await;
assert!(matches!(
validate_issued(&ks2, &membership_credential()).await,
Err(AppError::Validation(_))
));
}
#[tokio::test]
async fn validate_issued_is_a_noop_for_unregistered_or_schemaless() {
let (_d, _s, ks) = ks_with(None).await;
validate_issued(&ks, &membership_credential())
.await
.expect("schemaless registration accepts");
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let empty = store.keyspace("schemas").unwrap();
validate_issued(&empty, &membership_credential())
.await
.expect("unregistered type accepts");
}
}