// Lexicon validation of records before `atlibs publish` writes them (what `goat lex validate -c // lexicons` did), against this repo's lexicons/ (built into the CLI) and the publishing repo's own: // object (required, properties), string (min/maxLength, const), integer (minimum, maximum, const), // boolean, array (items, min/maxLength), ref and union (local `#def` and `nsid#def` among the loaded // lexicons), blob, cid-link, bytes, unknown. // ponytail: string formats (did, at-uri, datetime, ...), maxGraphemes and knownValues are not // checked, and refs to lexicons not in the directory (com.atproto.repo.strongRef) pass; the PDS // validates the lexicons it knows again. Add them if a bad record ever gets through. use std::collections::HashMap; use std::path::Path; use serde_json::Value; pub struct Lexicons(HashMap); /// This repo's lexicons/, built in (build.rs), so records are checked in any repo. const BUILTIN: &[(&str, &str)] = include!(concat!(env!("OUT_DIR"), "/lexicons.rs")); fn add(doc: &Value, out: &mut HashMap) { if let Some(id) = doc["id"].as_str() { out.insert(id.to_string(), doc["defs"].clone()); } } fn walk(dir: &Path, out: &mut HashMap) -> Result<(), String> { for entry in std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? { let path = entry.map_err(|e| e.to_string())?.path(); if path.is_dir() { walk(&path, out)?; } else if path.extension().is_some_and(|e| e == "json") { let doc: Value = serde_json::from_slice(&std::fs::read(&path).map_err(|e| e.to_string())?).map_err(|e| format!("{}: {e}", path.display()))?; add(&doc, out); } } Ok(()) } impl Lexicons { /// The built-in lexicons, then a repo's lexicons/ `dir` over them (its own, or its copies win). pub fn load(dir: Option<&Path>) -> Result { let mut defs = HashMap::new(); for (path, text) in BUILTIN { add(&serde_json::from_str(text).map_err(|e| format!("{path}: {e}"))?, &mut defs); } if let Some(dir) = dir { walk(dir, &mut defs)?; } Ok(Self(defs)) } /// (nsid, def name) a ref names, from inside lexicon `ctx`. fn target<'a>(ctx: &'a str, reference: &'a str) -> (&'a str, &'a str) { match reference.split_once('#') { Some(("", def)) => (ctx, def), Some((nsid, def)) => (nsid, def), None => (reference, "main"), } } fn def(&self, nsid: &str, def: &str) -> Option<&Value> { self.0.get(nsid).and_then(|defs| defs.get(def)) } /// A record against its $type's main record def; a $type with no lexicon here passes. pub fn validate(&self, record: &Value) -> Result<(), String> { let nsid = record["$type"].as_str().ok_or("record has no $type")?; match self.def(nsid, "main") { Some(main) if main["type"] == "record" => self.check(nsid, &main["record"], record, nsid), Some(_) => Err(format!("{nsid} is not a record lexicon")), None => Ok(()), } } fn check(&self, ctx: &str, schema: &Value, value: &Value, at: &str) -> Result<(), String> { let fail = |what: &str| Err(format!("{at}: {what}")); let len = |key: &str| schema[key].as_u64(); match schema["type"].as_str().unwrap_or_default() { "object" => { let Some(map) = value.as_object() else { return fail("must be an object") }; for key in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) { if !map.contains_key(key) { return fail(&format!("{key} is required")); } } for (key, property) in schema["properties"].as_object().into_iter().flatten() { if let Some(v) = map.get(key).filter(|v| !v.is_null()) { self.check(ctx, property, v, &format!("{at}.{key}"))?; } } Ok(()) } "string" => { let Some(s) = value.as_str() else { return fail("must be a string") }; if len("maxLength").is_some_and(|m| s.len() as u64 > m) || len("minLength").is_some_and(|m| (s.len() as u64) < m) { return fail("has the wrong length"); } if schema.get("const").is_some_and(|c| c != value) { return fail(&format!("must be {}", schema["const"])); } Ok(()) } "integer" => { let Some(n) = value.as_i64() else { return fail("must be an integer") }; if schema["minimum"].as_i64().is_some_and(|m| n < m) || schema["maximum"].as_i64().is_some_and(|m| n > m) { return fail("is out of range"); } if schema.get("const").is_some_and(|c| c != value) { return fail(&format!("must be {}", schema["const"])); } Ok(()) } "boolean" if !value.is_boolean() => fail("must be a boolean"), "array" => { let Some(items) = value.as_array() else { return fail("must be an array") }; if len("maxLength").is_some_and(|m| items.len() as u64 > m) || len("minLength").is_some_and(|m| (items.len() as u64) < m) { return fail("has the wrong length"); } for (i, item) in items.iter().enumerate() { self.check(ctx, &schema["items"], item, &format!("{at}[{i}]"))?; } Ok(()) } "ref" => { let (nsid, def) = Self::target(ctx, schema["ref"].as_str().unwrap_or_default()); match self.def(nsid, def) { Some(target) => self.check(nsid, target, value, at), None => Ok(()), } } "union" => { let Some(ty) = value["$type"].as_str() else { return fail("needs a $type (union)") }; let refs: Vec<&str> = schema["refs"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); let same = |r: &&str| { let (nsid, def) = Self::target(ctx, r); let (tn, td) = Self::target(ctx, ty); nsid == tn && def == td }; match refs.iter().copied().find(|r| same(&r)) { Some(r) => { let (nsid, def) = Self::target(ctx, r); self.def(nsid, def).map_or(Ok(()), |target| self.check(nsid, target, value, at)) } None if schema["closed"] == true => fail(&format!("$type {ty} is not one of {refs:?}")), None => Ok(()), } } "blob" if !(value["$type"] == "blob" && value["ref"]["$link"].is_string() && value["mimeType"].is_string() && value["size"].is_u64()) => fail("must be a blob ref"), "cid-link" if !value["$link"].is_string() => fail("must be a {$link}"), "bytes" if !value["$bytes"].is_string() => fail("must be {$bytes}"), "unknown" if !value.is_object() => fail("must be an object"), _ => Ok(()), } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn lexicons() -> Lexicons { Lexicons::load(None).unwrap() } #[test] fn every_record_in_the_repo_is_valid_and_broken_ones_are_not() { let lex = lexicons(); let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../records"); let mut checked = 0; for dir in [root.clone(), root.join("wasm"), root.join("tiles")].into_iter().filter(|d| d.is_dir()) { for entry in std::fs::read_dir(&dir).unwrap() { let path = entry.unwrap().path(); if path.extension().is_some_and(|e| e == "json") { let record: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); lex.validate(&record).unwrap_or_else(|e| panic!("{}: {e}", path.display())); checked += 1; } } } assert!(checked >= 5); let bad = json!({ "$type": "com.fedfork.exec.oauth.clientMetadata", "name": "x" }); assert!(lex.validate(&bad).unwrap_err().contains("clientMetadata is required")); let bad = json!({ "$type": "com.fedfork.exec.oauth.clientMetadata", "name": 3, "clientMetadata": {}, "createdAt": "x" }); assert!(lex.validate(&bad).unwrap_err().contains("name: must be a string")); assert!(lex.validate(&json!({ "$type": "app.bsky.feed.post" })).is_ok()); // an action takes up to 256 typed fields (the requester's contract declares 19) let module = |n: usize| { let fields: Vec = (0..n).map(|i| json!({ "key": format!("f{i}"), "label": "F", "type": "integer", "required": true, "description": "d" })).collect(); let blob = json!({ "$type": "blob", "ref": { "$link": "bafkreiggwsdecoum7emw4id7cyg7yxo7dihdd2wfdkalx6ogk5bbre3hy4" }, "mimeType": "application/wasm", "size": 1 }); json!({ "$type": "com.fedfork.exec.wasm.module", "name": "m", "module": blob, "abi": "emdash-wasm-json-v1", "createdAt": "2026-11-10T00:00:00.000Z", "actions": [{ "entry": "contract", "label": "Contract", "readOnly": false, "fields": fields }] }) }; lex.validate(&module(19)).unwrap(); lex.validate(&module(256)).unwrap(); assert!(lex.validate(&module(257)).unwrap_err().contains("fields")); } }