~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
198 lines · 9.6 KB Raw History
// 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<String, Value>);

/// 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<String, Value>) {
    if let Some(id) = doc["id"].as_str() {
        out.insert(id.to_string(), doc["defs"].clone());
    }
}

fn walk(dir: &Path, out: &mut HashMap<String, Value>) -> 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<Self, String> {
        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<Value> = (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"));
    }
}