- kei-artifact Rust crate (25th): schema registry + artifact store + SHA-256 id + chain walker - 5 schemas (JSON Schema 2020-12 strict): spec / plan / patch / review / research - Manifest extension: optional produces_artifact + expects_artifact per handoff (non-breaking) - Validator extension: KNOWN_ARTIFACT_SCHEMAS whitelist check + 4 new tests - 3 kei-* manifests updated with typed handoff (architect→code-implementer→critic chain) - compose-solution phase-5 cross-ref to kei-artifact Tests: 189 Rust workspace (was 167, +22 artifact tests) + 24 assembler (was 20, +4 validator tests)
198 lines
6.7 KiB
Rust
198 lines
6.7 KiB
Rust
//! Minimal JSON Schema validator — strict subset of draft 2020-12.
|
|
//!
|
|
//! Keyword support (chosen for the 5 built-in schemas):
|
|
//! - `type` (object, array, string, integer, number, boolean, null)
|
|
//! - `required` (array of property names)
|
|
//! - `properties` (object → sub-schema)
|
|
//! - `additionalProperties` (bool; default true, we set false on ours)
|
|
//! - `enum` (array of allowed scalar values)
|
|
//! - `items` (sub-schema for array elements)
|
|
//! - `minLength` (integer) / `minItems` (integer) / `minimum` (number)
|
|
//!
|
|
//! Intentionally NOT supported: $ref, oneOf/anyOf/allOf, patternProperties,
|
|
//! format validation, conditional schemas. The 5 built-in schemas are written
|
|
//! to avoid needing those — keeps the validator under 200 LOC and removes the
|
|
//! 40+ transitive-dep `jsonschema` crate.
|
|
//!
|
|
//! RULE 0.4 note: draft 2020-12 is the current JSON Schema standard
|
|
//! [VERIFIED: https://json-schema.org/draft/2020-12 — spec page].
|
|
//! This implementation is a strict subset — any schema author sticking to
|
|
//! the keywords above gets draft-2020-12-compatible semantics.
|
|
|
|
use serde_json::Value;
|
|
|
|
/// Top-level entry. Returns `Ok(())` on pass, `Err(msg)` with a path-style
|
|
/// location on first failure.
|
|
pub fn validate_content(schema: &Value, content: &Value) -> Result<(), String> {
|
|
check(schema, content, "$")
|
|
}
|
|
|
|
fn check(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
|
if let Some(t) = schema.get("type") {
|
|
check_type(t, value, path)?;
|
|
}
|
|
if let Some(e) = schema.get("enum") {
|
|
check_enum(e, value, path)?;
|
|
}
|
|
match value {
|
|
Value::Object(_) => check_object(schema, value, path)?,
|
|
Value::Array(_) => check_array(schema, value, path)?,
|
|
Value::String(s) => check_min_length(schema, s, path)?,
|
|
Value::Number(n) => check_minimum(schema, n, path)?,
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_type(schema_type: &Value, value: &Value, path: &str) -> Result<(), String> {
|
|
let want = schema_type
|
|
.as_str()
|
|
.ok_or_else(|| format!("{path}: schema 'type' must be string"))?;
|
|
let ok = match (want, value) {
|
|
("object", Value::Object(_)) => true,
|
|
("array", Value::Array(_)) => true,
|
|
("string", Value::String(_)) => true,
|
|
("boolean", Value::Bool(_)) => true,
|
|
("null", Value::Null) => true,
|
|
("integer", Value::Number(n)) => n.is_i64() || n.is_u64(),
|
|
("number", Value::Number(_)) => true,
|
|
_ => false,
|
|
};
|
|
if !ok {
|
|
return Err(format!(
|
|
"{path}: expected type '{want}', got {}",
|
|
type_of(value)
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn type_of(v: &Value) -> &'static str {
|
|
match v {
|
|
Value::Null => "null",
|
|
Value::Bool(_) => "boolean",
|
|
Value::Number(_) => "number",
|
|
Value::String(_) => "string",
|
|
Value::Array(_) => "array",
|
|
Value::Object(_) => "object",
|
|
}
|
|
}
|
|
|
|
fn check_enum(enum_schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
|
let allowed = enum_schema
|
|
.as_array()
|
|
.ok_or_else(|| format!("{path}: 'enum' must be array"))?;
|
|
if !allowed.iter().any(|a| a == value) {
|
|
return Err(format!("{path}: value {value} not in enum"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_object(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
|
let obj = value.as_object().unwrap();
|
|
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
|
|
for r in required {
|
|
if let Some(name) = r.as_str() {
|
|
if !obj.contains_key(name) {
|
|
return Err(format!("{path}: missing required property '{name}'"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let props = schema.get("properties").and_then(|v| v.as_object());
|
|
let additional = schema
|
|
.get("additionalProperties")
|
|
.and_then(|v| v.as_bool())
|
|
.unwrap_or(true);
|
|
for (k, v) in obj {
|
|
match props.and_then(|p| p.get(k)) {
|
|
Some(sub) => check(sub, v, &format!("{path}.{k}"))?,
|
|
None if !additional => {
|
|
return Err(format!("{path}: unexpected property '{k}'"));
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_array(schema: &Value, value: &Value, path: &str) -> Result<(), String> {
|
|
let arr = value.as_array().unwrap();
|
|
if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
|
|
if (arr.len() as u64) < min {
|
|
return Err(format!("{path}: array has {} items, min {min}", arr.len()));
|
|
}
|
|
}
|
|
if let Some(items) = schema.get("items") {
|
|
for (i, el) in arr.iter().enumerate() {
|
|
check(items, el, &format!("{path}[{i}]"))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_min_length(schema: &Value, s: &str, path: &str) -> Result<(), String> {
|
|
if let Some(min) = schema.get("minLength").and_then(|v| v.as_u64()) {
|
|
if (s.chars().count() as u64) < min {
|
|
return Err(format!("{path}: string shorter than minLength {min}"));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_minimum(schema: &Value, n: &serde_json::Number, path: &str) -> Result<(), String> {
|
|
if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
|
|
if let Some(v) = n.as_f64() {
|
|
if v < min {
|
|
return Err(format!("{path}: number {v} below minimum {min}"));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn type_mismatch_rejected() {
|
|
let schema = json!({"type": "string"});
|
|
let err = validate_content(&schema, &json!(42)).unwrap_err();
|
|
assert!(err.contains("expected type 'string'"), "got: {err}");
|
|
}
|
|
|
|
#[test]
|
|
fn missing_required_rejected() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"required": ["goal"],
|
|
"properties": {"goal": {"type": "string"}}
|
|
});
|
|
let err = validate_content(&schema, &json!({})).unwrap_err();
|
|
assert!(err.contains("goal"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_additional_rejected() {
|
|
let schema = json!({
|
|
"type": "object",
|
|
"additionalProperties": false,
|
|
"properties": {"a": {"type": "string"}}
|
|
});
|
|
let err = validate_content(&schema, &json!({"a":"x","b":"y"})).unwrap_err();
|
|
assert!(err.contains("unexpected property 'b'"));
|
|
}
|
|
|
|
#[test]
|
|
fn enum_and_array_items_enforced() {
|
|
let schema = json!({
|
|
"type": "array",
|
|
"items": {"type": "string", "enum": ["add", "mod", "del"]}
|
|
});
|
|
assert!(validate_content(&schema, &json!(["add", "mod"])).is_ok());
|
|
let err = validate_content(&schema, &json!(["nope"])).unwrap_err();
|
|
assert!(err.contains("enum"));
|
|
}
|
|
}
|