From dab347cc8300816267dbc0f6698031fe1cb4c1aa Mon Sep 17 00:00:00 2001 From: Lewis Date: Thu, 12 Mar 2026 12:52:00 +0200 Subject: [PATCH] feat(lexicon): record validation engine --- crates/tranquil-lexicon/src/validate.rs | 896 ++++++++++++++++++++++++ 1 file changed, 896 insertions(+) create mode 100644 crates/tranquil-lexicon/src/validate.rs diff --git a/crates/tranquil-lexicon/src/validate.rs b/crates/tranquil-lexicon/src/validate.rs new file mode 100644 index 0000000..52db854 --- /dev/null +++ b/crates/tranquil-lexicon/src/validate.rs @@ -0,0 +1,896 @@ +use crate::formats::validate_format; +use crate::registry::LexiconRegistry; +use crate::schema::{ + LexArray, LexBlob, LexBytes, LexDef, LexObject, LexProperty, LexString, LexUnion, ParsedRef, + parse_ref, +}; +use thiserror::Error; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_RECURSION_DEPTH: u32 = 32; + +#[derive(Debug, Error)] +pub enum LexValidationError { + #[error("Lexicon not found: {0}")] + LexiconNotFound(String), + #[error("Missing required field: {path}")] + MissingRequired { path: String }, + #[error("Invalid field at {path}: {message}")] + InvalidField { path: String, message: String }, + #[error("Recursion depth exceeded at {path}")] + RecursionDepthExceeded { path: String }, +} + +impl LexValidationError { + fn field(path: &str, message: impl Into) -> Self { + Self::InvalidField { + path: path.to_string(), + message: message.into(), + } + } +} + +fn resolve_union_ref(reference: &str, context_nsid: &str) -> String { + match parse_ref(reference) { + ParsedRef::Local(local) => format!("{}#{}", context_nsid, local), + ParsedRef::Qualified { nsid, fragment } => format!("{}#{}", nsid, fragment), + ParsedRef::Bare(nsid) => nsid.to_string(), + } +} + +fn ref_to_context_nsid<'a>(reference: &'a str, current_context: &'a str) -> &'a str { + match parse_ref(reference) { + ParsedRef::Local(_) => current_context, + ParsedRef::Qualified { nsid, .. } | ParsedRef::Bare(nsid) => nsid, + } +} + +pub fn validate_record( + registry: &LexiconRegistry, + nsid: &str, + value: &serde_json::Value, +) -> Result<(), LexValidationError> { + let doc = registry + .get_record_def(nsid) + .ok_or_else(|| LexValidationError::LexiconNotFound(nsid.to_string()))?; + + let LexDef::Record(rec) = doc + .defs + .get("main") + .expect("get_record_def guarantees main exists") + else { + unreachable!("get_record_def guarantees main is Record") + }; + + validate_object(registry, nsid, &rec.record, value, "", 0) +} + +fn validate_object( + registry: &LexiconRegistry, + context_nsid: &str, + schema: &LexObject, + value: &serde_json::Value, + path: &str, + depth: u32, +) -> Result<(), LexValidationError> { + if depth > MAX_RECURSION_DEPTH { + return Err(LexValidationError::RecursionDepthExceeded { + path: path.to_string(), + }); + } + + let obj = value + .as_object() + .ok_or_else(|| LexValidationError::field(path, "expected an object"))?; + + schema.required.iter().try_for_each(|field| { + let is_present = obj + .get(field.as_str()) + .is_some_and(|v| !v.is_null() || schema.nullable.contains(field)); + if is_present { + Ok(()) + } else { + Err(LexValidationError::MissingRequired { + path: field_path(path, field), + }) + } + })?; + + schema + .properties + .iter() + .filter(|(key, _)| obj.contains_key(key.as_str())) + .try_for_each(|(key, prop)| { + let field_val = &obj[key.as_str()]; + let fp = field_path(path, key); + + if schema.nullable.contains(key) && field_val.is_null() { + return Ok(()); + } + + validate_property(registry, context_nsid, prop, field_val, &fp, depth + 1) + }) +} + +fn validate_property( + registry: &LexiconRegistry, + context_nsid: &str, + prop: &LexProperty, + value: &serde_json::Value, + path: &str, + depth: u32, +) -> Result<(), LexValidationError> { + if depth > MAX_RECURSION_DEPTH { + return Err(LexValidationError::RecursionDepthExceeded { + path: path.to_string(), + }); + } + + match prop { + LexProperty::String(lex_str) => validate_string(lex_str, value, path), + LexProperty::Integer(lex_int) => { + let n = value + .as_i64() + .or_else(|| { + value.as_f64().and_then(|f| { + (f.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&f)) + .then_some(f as i64) + }) + }) + .ok_or_else(|| LexValidationError::field(path, "expected an integer"))?; + if let Some(min) = lex_int.minimum + && n < min + { + return Err(LexValidationError::field( + path, + format!("value {} below minimum {}", n, min), + )); + } + if let Some(max) = lex_int.maximum + && n > max + { + return Err(LexValidationError::field( + path, + format!("value {} above maximum {}", n, max), + )); + } + if let Some(ref enum_vals) = lex_int.enum_values + && !enum_vals.contains(&n) + { + return Err(LexValidationError::field( + path, + format!("value {} not in enum", n), + )); + } + if let Some(const_val) = lex_int.const_value + && n != const_val + { + return Err(LexValidationError::field( + path, + format!("expected const value {}", const_val), + )); + } + Ok(()) + } + LexProperty::Boolean {} => value + .is_boolean() + .then_some(()) + .ok_or_else(|| LexValidationError::field(path, "expected a boolean")), + LexProperty::CidLink {} => validate_cid_link(value, path), + LexProperty::Blob(lex_blob) => validate_blob_ref(lex_blob, value, path), + LexProperty::Unknown {} => Ok(()), + LexProperty::Bytes(lex_bytes) => validate_bytes(lex_bytes, value, path), + LexProperty::Ref(lex_ref) => validate_ref( + registry, + context_nsid, + &lex_ref.reference, + value, + path, + depth, + ), + LexProperty::Union(union_def) => { + validate_union(registry, context_nsid, union_def, value, path, depth) + } + LexProperty::Array(array_def) => { + validate_array(registry, context_nsid, array_def, value, path, depth) + } + LexProperty::Object(obj_def) => { + validate_object(registry, context_nsid, obj_def, value, path, depth) + } + } +} + +fn validate_string( + lex_str: &LexString, + value: &serde_json::Value, + path: &str, +) -> Result<(), LexValidationError> { + let s = value + .as_str() + .ok_or_else(|| LexValidationError::field(path, "expected a string"))?; + + if let Some(max_len) = lex_str.max_length + && s.len() as u64 > max_len + { + return Err(LexValidationError::field( + path, + format!("string length {} exceeds max_length {}", s.len(), max_len), + )); + } + + if let Some(min_len) = lex_str.min_length + && (s.len() as u64) < min_len + { + return Err(LexValidationError::field( + path, + format!("string length {} below min_length {}", s.len(), min_len), + )); + } + + if lex_str.max_graphemes.is_some() || lex_str.min_graphemes.is_some() { + let count = s.graphemes(true).count() as u64; + if let Some(max_graphemes) = lex_str.max_graphemes + && count > max_graphemes + { + return Err(LexValidationError::field( + path, + format!( + "grapheme count {} exceeds max_graphemes {}", + count, max_graphemes + ), + )); + } + if let Some(min_graphemes) = lex_str.min_graphemes + && count < min_graphemes + { + return Err(LexValidationError::field( + path, + format!( + "grapheme count {} below min_graphemes {}", + count, min_graphemes + ), + )); + } + } + + if let Some(ref format) = lex_str.format + && !validate_format(format, s) + { + return Err(LexValidationError::field( + path, + format!("invalid format: {:?}", format), + )); + } + + if let Some(ref enum_vals) = lex_str.enum_values + && !enum_vals.iter().any(|v| v == s) + { + return Err(LexValidationError::field( + path, + format!("value '{}' not in enum", s), + )); + } + + if let Some(ref const_val) = lex_str.const_value + && s != const_val.as_str() + { + return Err(LexValidationError::field( + path, + format!("expected const value '{}'", const_val), + )); + } + + Ok(()) +} + +fn validate_cid_link(value: &serde_json::Value, path: &str) -> Result<(), LexValidationError> { + let obj = value + .as_object() + .ok_or_else(|| LexValidationError::field(path, "expected cid-link object"))?; + + if !obj.contains_key("$link") { + return Err(LexValidationError::field(path, "cid-link missing $link")); + } + + Ok(()) +} + +fn validate_blob_ref( + lex_blob: &LexBlob, + value: &serde_json::Value, + path: &str, +) -> Result<(), LexValidationError> { + let obj = value + .as_object() + .ok_or_else(|| LexValidationError::field(path, "expected blob object"))?; + + let has_type = obj + .get("$type") + .and_then(|v| v.as_str()) + .is_some_and(|t| t == "blob"); + + let has_ref = obj.contains_key("ref") && obj.contains_key("mimeType"); + + let has_cid = obj.contains_key("cid"); + + if !has_type && !has_ref && !has_cid { + return Err(LexValidationError::field( + path, + "invalid blob reference structure", + )); + } + + if let Some(ref accept) = lex_blob.accept { + let mime_type = obj.get("mimeType").and_then(|v| v.as_str()).unwrap_or(""); + let matched = accept + .iter() + .any(|pattern| match pattern.strip_suffix("/*") { + Some(prefix) => { + mime_type.starts_with(prefix) + && mime_type.as_bytes().get(prefix.len()) == Some(&b'/') + } + None => mime_type == pattern, + }); + if !mime_type.is_empty() && !matched { + return Err(LexValidationError::field( + path, + format!("blob mimeType '{}' not in accepted types", mime_type), + )); + } + } + + if let Some(max_size) = lex_blob.max_size { + if let Some(size) = obj.get("size").and_then(|v| v.as_u64()) { + if size > max_size { + return Err(LexValidationError::field( + path, + format!("blob size {} exceeds max_size {}", size, max_size), + )); + } + } + } + + Ok(()) +} + +fn validate_bytes( + lex_bytes: &LexBytes, + value: &serde_json::Value, + path: &str, +) -> Result<(), LexValidationError> { + let obj = value + .as_object() + .ok_or_else(|| LexValidationError::field(path, "expected bytes object with $bytes key"))?; + + let encoded = obj + .get("$bytes") + .and_then(|v| v.as_str()) + .ok_or_else(|| LexValidationError::field(path, "bytes object missing $bytes key"))?; + + let byte_len = encoded.len() as u64 * 3 / 4; + + if let Some(max_len) = lex_bytes.max_length + && byte_len > max_len + { + return Err(LexValidationError::field( + path, + format!("bytes length ~{} exceeds max_length {}", byte_len, max_len), + )); + } + + if let Some(min_len) = lex_bytes.min_length + && byte_len < min_len + { + return Err(LexValidationError::field( + path, + format!("bytes length ~{} below min_length {}", byte_len, min_len), + )); + } + + Ok(()) +} + +fn validate_ref( + registry: &LexiconRegistry, + context_nsid: &str, + reference: &str, + value: &serde_json::Value, + path: &str, + depth: u32, +) -> Result<(), LexValidationError> { + let target_context = ref_to_context_nsid(reference, context_nsid); + match registry.resolve_ref(reference, context_nsid) { + Some(resolved) => { + if resolved.is_token() { + Ok(()) + } else if let Some(obj) = resolved.as_object() { + validate_object(registry, target_context, obj, value, path, depth + 1) + } else { + Ok(()) + } + } + None => Ok(()), + } +} + +fn validate_union( + registry: &LexiconRegistry, + context_nsid: &str, + union_def: &LexUnion, + value: &serde_json::Value, + path: &str, + depth: u32, +) -> Result<(), LexValidationError> { + let obj = value + .as_object() + .ok_or_else(|| LexValidationError::field(path, "union value must be an object"))?; + + let type_str = obj + .get("$type") + .and_then(|v| v.as_str()) + .ok_or_else(|| LexValidationError::field(path, "union object missing $type"))?; + + let matched_ref = union_def.refs.iter().find(|r| { + let resolved = resolve_union_ref(r, context_nsid); + resolved == type_str + }); + + match matched_ref { + Some(reference) => validate_ref(registry, context_nsid, reference, value, path, depth), + None => { + if union_def.closed { + Err(LexValidationError::field( + path, + format!("union type '{}' not in allowed refs", type_str), + )) + } else { + Ok(()) + } + } + } +} + +fn validate_array( + registry: &LexiconRegistry, + context_nsid: &str, + array_def: &LexArray, + value: &serde_json::Value, + path: &str, + depth: u32, +) -> Result<(), LexValidationError> { + let arr = value + .as_array() + .ok_or_else(|| LexValidationError::field(path, "expected an array"))?; + + if let Some(max_len) = array_def.max_length + && arr.len() as u64 > max_len + { + return Err(LexValidationError::field( + path, + format!("array length {} exceeds max_length {}", arr.len(), max_len), + )); + } + + if let Some(min_len) = array_def.min_length + && (arr.len() as u64) < min_len + { + return Err(LexValidationError::field( + path, + format!("array length {} below min_length {}", arr.len(), min_len), + )); + } + + arr.iter().enumerate().try_for_each(|(i, item)| { + let item_path = format!("{}/{}", path, i); + validate_property( + registry, + context_nsid, + &array_def.items, + item, + &item_path, + depth + 1, + ) + }) +} + +fn field_path(parent: &str, field: &str) -> String { + if parent.is_empty() { + field.to_string() + } else { + format!("{}/{}", parent, field) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_schemas::test_registry; + use serde_json::json; + + #[test] + fn test_validate_valid_record() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "Hello, world!", + "createdAt": "2024-01-01T00:00:00.000Z" + }); + assert!(validate_record(®istry, "com.test.basic", &record).is_ok()); + } + + #[test] + fn test_validate_missing_required() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "createdAt": "2024-01-01T00:00:00.000Z" + }); + let err = validate_record(®istry, "com.test.basic", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::MissingRequired { .. })); + } + + #[test] + fn test_validate_string_too_long_bytes() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "a".repeat(101), + "createdAt": "2024-01-01T00:00:00.000Z" + }); + let err = validate_record(®istry, "com.test.basic", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_string_too_many_graphemes() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "a".repeat(51), + "createdAt": "2024-01-01T00:00:00.000Z" + }); + let err = validate_record(®istry, "com.test.basic", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_grapheme_counting_emoji() { + let registry = test_registry(); + let emoji_text = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ".repeat(11); + let record = json!({ + "$type": "com.test.profile", + "displayName": emoji_text + }); + let err = validate_record(®istry, "com.test.profile", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_integer_bounds() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "count": 101 + }); + let err = validate_record(®istry, "com.test.basic", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + + let record_neg = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "count": -1 + }); + let err = validate_record(®istry, "com.test.basic", &record_neg).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_integer_float_coercion() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "count": 5.0 + }); + assert!(validate_record(®istry, "com.test.basic", &record).is_ok()); + + let record_frac = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "count": 5.5 + }); + let err = validate_record(®istry, "com.test.basic", &record_frac).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_boolean() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "active": "not-a-bool" + }); + let err = validate_record(®istry, "com.test.basic", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_array_max_length() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "tags": ["a", "b", "c", "d"] + }); + let err = validate_record(®istry, "com.test.basic", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_validate_array_within_limit() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "tags": ["a", "b", "c"] + }); + assert!(validate_record(®istry, "com.test.basic", &record).is_ok()); + } + + #[test] + fn test_validate_cross_schema_ref() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withref", + "subject": { + "uri": "at://did:plc:abc/com.test.basic/123", + "cid": "bafyreiabcdef" + }, + "createdAt": "2024-01-01T00:00:00.000Z" + }); + assert!(validate_record(®istry, "com.test.withref", &record).is_ok()); + } + + #[test] + fn test_validate_cross_schema_ref_missing_field() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withref", + "subject": { + "cid": "bafyreiabcdef" + }, + "createdAt": "2024-01-01T00:00:00.000Z" + }); + let err = validate_record(®istry, "com.test.withref", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::MissingRequired { .. })); + } + + #[test] + fn test_validate_local_ref_resolution() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withreply", + "text": "reply", + "createdAt": "2024-01-01T00:00:00.000Z", + "reply": { + "root": { + "uri": "at://did:plc:abc/com.test.basic/123", + "cid": "bafyreiabcdef" + }, + "parent": { + "uri": "at://did:plc:abc/com.test.basic/456", + "cid": "bafyreiabcdef" + } + } + }); + assert!(validate_record(®istry, "com.test.withreply", &record).is_ok()); + } + + #[test] + fn test_validate_union_bare_nsid_ref() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withreply", + "text": "with images", + "createdAt": "2024-01-01T00:00:00.000Z", + "embed": { + "$type": "com.test.images", + "images": [ + { + "image": { + "$type": "blob", + "ref": { "$link": "bafyreiabcdef" }, + "mimeType": "image/jpeg", + "size": 12345 + }, + "alt": "test" + } + ] + } + }); + assert!(validate_record(®istry, "com.test.withreply", &record).is_ok()); + + let bad_embed = json!({ + "$type": "com.test.withreply", + "text": "bad", + "createdAt": "2024-01-01T00:00:00.000Z", + "embed": { + "$type": "com.test.images", + "images": "not-an-array" + } + }); + assert!( + validate_record(®istry, "com.test.withreply", &bad_embed).is_err(), + "union with bare NSID ref must validate the matched schema" + ); + } + + #[test] + fn test_validate_cross_schema_local_ref_in_union() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withreply", + "text": "external", + "createdAt": "2024-01-01T00:00:00.000Z", + "embed": { + "$type": "com.test.external", + "external": { + "uri": "https://example.com", + "title": "Example", + "description": "A test" + } + } + }); + assert!(validate_record(®istry, "com.test.withreply", &record).is_ok()); + + let bad_external = json!({ + "$type": "com.test.withreply", + "text": "bad", + "createdAt": "2024-01-01T00:00:00.000Z", + "embed": { + "$type": "com.test.external", + "external": { + "title": "missing uri and description" + } + } + }); + assert!( + validate_record(®istry, "com.test.withreply", &bad_external).is_err(), + "local #ref in cross-schema union must resolve against the correct schema" + ); + } + + #[test] + fn test_validate_gate_with_union_fragment_ref() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withgate", + "post": "at://did:plc:abc/com.test.basic/123", + "createdAt": "2024-01-01T00:00:00.000Z", + "rules": [ + { "$type": "com.test.withgate#disableRule" } + ] + }); + assert!(validate_record(®istry, "com.test.withgate", &record).is_ok()); + } + + #[test] + fn test_validate_did_format() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.withdid", + "subject": "did:plc:abc123", + "createdAt": "2024-01-01T00:00:00.000Z" + }); + assert!(validate_record(®istry, "com.test.withdid", &record).is_ok()); + + let bad_did = json!({ + "$type": "com.test.withdid", + "subject": "not-a-did", + "createdAt": "2024-01-01T00:00:00.000Z" + }); + assert!(validate_record(®istry, "com.test.withdid", &bad_did).is_err()); + } + + #[test] + fn test_validate_nullable_field() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.nullable", + "name": "test", + "value": null + }); + assert!(validate_record(®istry, "com.test.nullable", &record).is_ok()); + } + + #[test] + fn test_validate_unknown_lexicon() { + let registry = test_registry(); + let record = json!({"$type": "com.example.nonexistent"}); + let err = validate_record(®istry, "com.example.nonexistent", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::LexiconNotFound(_))); + } + + #[test] + fn test_validate_extra_properties_allowed() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.basic", + "text": "ok", + "createdAt": "2024-01-01T00:00:00.000Z", + "unknownField": "this is fine" + }); + assert!(validate_record(®istry, "com.test.basic", &record).is_ok()); + } + + #[test] + fn test_validate_no_required_fields() { + let registry = test_registry(); + let record = json!({"$type": "com.test.profile"}); + assert!(validate_record(®istry, "com.test.profile", &record).is_ok()); + } + + #[test] + fn test_validate_profile_display_name_graphemes() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.profile", + "displayName": "a".repeat(11) + }); + let err = validate_record(®istry, "com.test.profile", &record).unwrap_err(); + assert!(matches!(err, LexValidationError::InvalidField { .. })); + } + + #[test] + fn test_required_nullable_field_accepts_null() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.requirednullable", + "name": "test", + "value": null + }); + assert!( + validate_record(®istry, "com.test.requirednullable", &record).is_ok(), + "a field that is both required and nullable must accept null values" + ); + } + + #[test] + fn test_required_nullable_field_rejects_absent() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.requirednullable", + "name": "test" + }); + assert!( + matches!( + validate_record(®istry, "com.test.requirednullable", &record).unwrap_err(), + LexValidationError::MissingRequired { .. } + ), + "a field that is required+nullable must still be present (even if null)" + ); + } + + #[test] + fn test_required_nullable_field_accepts_value() { + let registry = test_registry(); + let record = json!({ + "$type": "com.test.requirednullable", + "name": "test", + "value": "hello" + }); + assert!( + validate_record(®istry, "com.test.requirednullable", &record).is_ok(), + "a field that is required+nullable must accept non-null values" + ); + } +}