diff --git a/Cargo.lock b/Cargo.lock index c880b8c..47a9701 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6212,6 +6212,24 @@ dependencies = [ "tranquil-config", ] +[[package]] +name = "tranquil-lexicon" +version = "0.3.1" +dependencies = [ + "chrono", + "hickory-resolver", + "parking_lot", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "unicode-segmentation", + "urlencoding", + "wiremock", +] + [[package]] name = "tranquil-oauth" version = "0.3.1" @@ -6309,6 +6327,7 @@ dependencies = [ "tranquil-crypto", "tranquil-db", "tranquil-db-traits", + "tranquil-lexicon", "tranquil-oauth", "tranquil-repo", "tranquil-ripple", diff --git a/Cargo.toml b/Cargo.toml index 0753c22..4b0d47a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "crates/tranquil-db-traits", "crates/tranquil-db", "crates/tranquil-pds", + "crates/tranquil-lexicon", ] [workspace.package] @@ -38,6 +39,9 @@ tranquil-comms = { path = "crates/tranquil-comms" } tranquil-db-traits = { path = "crates/tranquil-db-traits" } tranquil-db = { path = "crates/tranquil-db" } tranquil-ripple = { path = "crates/tranquil-ripple" } +tranquil-lexicon = { path = "crates/tranquil-lexicon" } + +unicode-segmentation = "1" aes-gcm = "0.10" backon = "1" diff --git a/crates/tranquil-lexicon/Cargo.toml b/crates/tranquil-lexicon/Cargo.toml new file mode 100644 index 0000000..6a9a037 --- /dev/null +++ b/crates/tranquil-lexicon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tranquil-lexicon" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = [] +resolve = ["dep:reqwest", "dep:hickory-resolver", "dep:tokio", "dep:parking_lot", "dep:tracing", "dep:urlencoding"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +unicode-segmentation = { workspace = true } +chrono = { workspace = true } +reqwest = { workspace = true, optional = true } +hickory-resolver = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +parking_lot = { workspace = true, optional = true } +tracing = { workspace = true, optional = true } +urlencoding = { workspace = true, optional = true } + +[dev-dependencies] +wiremock = { workspace = true } +tokio = { workspace = true } diff --git a/crates/tranquil-lexicon/src/formats.rs b/crates/tranquil-lexicon/src/formats.rs new file mode 100644 index 0000000..86ce679 --- /dev/null +++ b/crates/tranquil-lexicon/src/formats.rs @@ -0,0 +1,217 @@ +pub fn is_valid_did(s: &str) -> bool { + s.strip_prefix("did:") + .and_then(|rest| rest.split_once(':')) + .is_some_and(|(method, id)| { + !method.is_empty() + && method + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + && !id.is_empty() + }) +} + +pub fn is_valid_handle(s: &str) -> bool { + !s.is_empty() + && s.len() <= 253 + && s.contains('.') + && s.split('.').all(|seg| { + !seg.is_empty() + && seg.len() <= 63 + && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + && !seg.starts_with('-') + && !seg.ends_with('-') + }) +} + +pub fn is_valid_at_uri(s: &str) -> bool { + s.strip_prefix("at://").is_some_and(|rest| { + let authority = rest.split('/').next().unwrap_or(""); + is_valid_did(authority) || is_valid_handle(authority) + }) +} + +pub fn is_valid_datetime(s: &str) -> bool { + chrono::DateTime::parse_from_rfc3339(s).is_ok() +} + +pub fn is_valid_uri(s: &str) -> bool { + s.split_once("://").is_some_and(|(scheme, rest)| { + !scheme.is_empty() + && scheme + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-') + && scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && !rest.is_empty() + }) +} + +pub fn is_valid_cid(s: &str) -> bool { + s.len() >= 8 + && s.chars().all(|c| c.is_ascii_alphanumeric()) + && s.starts_with(|c: char| c == 'b' || c == 'z' || c == 'Q') +} + +pub fn is_valid_language(s: &str) -> bool { + !s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +pub fn is_valid_tid(s: &str) -> bool { + s.len() == 13 + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) +} + +pub fn is_valid_record_key(s: &str) -> bool { + !s.is_empty() + && s.len() <= 512 + && s != "." + && s != ".." + && s.chars().all(|c| { + c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~' || c == ':' + }) +} + +pub fn is_valid_at_identifier(s: &str) -> bool { + is_valid_did(s) || is_valid_handle(s) +} + +pub fn is_valid_nsid(s: &str) -> bool { + !s.is_empty() + && s.split('.').count() >= 3 + && s.split('.').all(|seg| { + !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + }) +} + +use crate::schema::StringFormat; + +pub fn validate_format(format: &StringFormat, value: &str) -> bool { + match format { + StringFormat::Did => is_valid_did(value), + StringFormat::Handle => is_valid_handle(value), + StringFormat::AtUri => is_valid_at_uri(value), + StringFormat::Datetime => is_valid_datetime(value), + StringFormat::Uri => is_valid_uri(value), + StringFormat::Cid => is_valid_cid(value), + StringFormat::Language => is_valid_language(value), + StringFormat::Tid => is_valid_tid(value), + StringFormat::RecordKey => is_valid_record_key(value), + StringFormat::AtIdentifier => is_valid_at_identifier(value), + StringFormat::Nsid => is_valid_nsid(value), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_dids() { + assert!(is_valid_did("did:plc:1234567890abcdefghijk")); + assert!(is_valid_did("did:web:example.com")); + assert!(!is_valid_did("")); + assert!(!is_valid_did("plc:123")); + assert!(!is_valid_did("did:")); + assert!(!is_valid_did("did:plc:")); + } + + #[test] + fn test_valid_handles() { + assert!(is_valid_handle("user.bsky.social")); + assert!(is_valid_handle("example.com")); + assert!(!is_valid_handle("noperiod")); + assert!(!is_valid_handle("")); + } + + #[test] + fn test_valid_at_uris() { + assert!(is_valid_at_uri("at://did:plc:abc/app.bsky.feed.post/123")); + assert!(is_valid_at_uri( + "at://user.bsky.social/app.bsky.feed.post/123" + )); + assert!(!is_valid_at_uri("https://example.com")); + assert!(!is_valid_at_uri("at://")); + assert!(!is_valid_at_uri("at://not valid")); + } + + #[test] + fn test_valid_datetimes() { + assert!(is_valid_datetime("2024-01-01T00:00:00.000Z")); + assert!(is_valid_datetime("2024-01-01T00:00:00Z")); + assert!(!is_valid_datetime("not-a-date")); + assert!(!is_valid_datetime("2024-13-01T00:00:00Z")); + } + + #[test] + fn test_valid_uris() { + assert!(is_valid_uri("https://example.com")); + assert!(is_valid_uri("http://localhost")); + assert!(is_valid_uri("ftp://files.example.com/path")); + assert!(!is_valid_uri("://x")); + assert!(!is_valid_uri("not a uri")); + assert!(!is_valid_uri("123://bad")); + assert!(!is_valid_uri("https://")); + } + + #[test] + fn test_valid_cids() { + assert!(is_valid_cid("bafyreiabcdef123456")); + assert!(is_valid_cid( + "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + )); + assert!(is_valid_cid("zQmSomeMultibase")); + assert!(!is_valid_cid("abc")); + assert!(!is_valid_cid("")); + assert!(!is_valid_cid("xyzinvalidprefix1234")); + } + + #[test] + fn test_valid_tids() { + assert!(is_valid_tid("3k2n5j2abcdef")); + assert!(!is_valid_tid("short")); + assert!(!is_valid_tid("3K2N5J2ABCDEF")); + } + + #[test] + fn test_valid_record_keys() { + assert!(is_valid_record_key("valid-key_123")); + assert!(is_valid_record_key("self")); + assert!(!is_valid_record_key("")); + assert!(!is_valid_record_key(".")); + assert!(!is_valid_record_key("..")); + } + + #[test] + fn test_valid_nsids() { + assert!(is_valid_nsid("app.bsky.feed.post")); + assert!(is_valid_nsid("com.atproto.repo.strongRef")); + assert!(!is_valid_nsid("too.short")); + assert!(!is_valid_nsid("")); + } + + #[test] + fn test_did_method_with_digits() { + assert!(is_valid_did( + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + )); + assert!(is_valid_did("did:3:abc123")); + assert!(is_valid_did("did:a1b2:test")); + assert!(!is_valid_did("did:UPPER:test")); + assert!(!is_valid_did("did::test")); + } + + #[test] + fn test_record_key_with_colon() { + assert!(is_valid_record_key("self")); + assert!(is_valid_record_key("key:with:colons")); + assert!(is_valid_record_key("at:something")); + } + + #[test] + fn test_valid_languages() { + assert!(is_valid_language("en")); + assert!(is_valid_language("en-US")); + assert!(is_valid_language("pt-BR")); + assert!(!is_valid_language("")); + } +} diff --git a/crates/tranquil-lexicon/src/lib.rs b/crates/tranquil-lexicon/src/lib.rs new file mode 100644 index 0000000..d135dd0 --- /dev/null +++ b/crates/tranquil-lexicon/src/lib.rs @@ -0,0 +1,27 @@ +mod formats; +mod registry; +mod schema; +mod validate; + +#[cfg(feature = "resolve")] +mod dynamic; +#[cfg(feature = "resolve")] +mod resolve; + +#[cfg(test)] +mod test_schemas; + +pub use formats::{ + is_valid_at_identifier, is_valid_at_uri, is_valid_cid, is_valid_datetime, is_valid_did, + is_valid_handle, is_valid_language, is_valid_nsid, is_valid_record_key, is_valid_tid, + is_valid_uri, +}; +pub use registry::LexiconRegistry; +pub use schema::{LexiconDoc, ParsedRef, parse_ref}; +pub use validate::{LexValidationError, validate_record}; + +#[cfg(feature = "resolve")] +pub use resolve::{ + ResolveError, fetch_schema_from_pds, resolve_did_from_dns, resolve_lexicon, + resolve_lexicon_from_did, resolve_lexicon_with_config, resolve_pds_endpoint, +}; diff --git a/crates/tranquil-lexicon/src/schema.rs b/crates/tranquil-lexicon/src/schema.rs new file mode 100644 index 0000000..f1a3d2b --- /dev/null +++ b/crates/tranquil-lexicon/src/schema.rs @@ -0,0 +1,208 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[derive(Debug, Deserialize)] +pub struct LexiconDoc { + pub lexicon: u32, + pub id: String, + #[serde(default)] + pub defs: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum LexDef { + #[serde(rename = "record")] + Record(LexRecord), + #[serde(rename = "object")] + Object(LexObject), + #[serde(rename = "token")] + Token {}, + #[serde(rename = "string")] + StringDef(LexStringDef), + #[serde(rename = "query")] + Query {}, + #[serde(rename = "procedure")] + Procedure {}, + #[serde(rename = "subscription")] + Subscription {}, + #[serde(rename = "params")] + Params {}, + #[serde(rename = "permission")] + Permission {}, + #[serde(rename = "permission-set")] + PermissionSet {}, +} + +#[derive(Debug, Deserialize)] +pub struct LexRecord { + #[serde(default)] + pub key: Option, + pub record: LexObject, +} + +#[derive(Debug, Deserialize)] +pub struct LexObject { + #[serde(default)] + pub required: Vec, + #[serde(default)] + pub nullable: Vec, + #[serde(default)] + pub properties: HashMap, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +pub enum LexProperty { + #[serde(rename = "string")] + String(LexString), + #[serde(rename = "integer")] + Integer(LexInteger), + #[serde(rename = "boolean")] + Boolean {}, + #[serde(rename = "bytes")] + Bytes(LexBytes), + #[serde(rename = "cid-link")] + CidLink {}, + #[serde(rename = "blob")] + Blob(LexBlob), + #[serde(rename = "unknown")] + Unknown {}, + #[serde(rename = "ref")] + Ref(LexRef), + #[serde(rename = "union")] + Union(LexUnion), + #[serde(rename = "array")] + Array(LexArray), + #[serde(rename = "object")] + Object(LexObject), +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LexString { + #[serde(default)] + pub max_length: Option, + #[serde(default)] + pub min_length: Option, + #[serde(default)] + pub max_graphemes: Option, + #[serde(default)] + pub min_graphemes: Option, + #[serde(default)] + pub format: Option, + #[serde(default)] + pub known_values: Option>, + #[serde(rename = "enum", default)] + pub enum_values: Option>, + #[serde(rename = "const", default)] + pub const_value: Option, + #[serde(default)] + pub default: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LexInteger { + #[serde(default)] + pub minimum: Option, + #[serde(default)] + pub maximum: Option, + #[serde(default)] + pub default: Option, + #[serde(rename = "enum", default)] + pub enum_values: Option>, + #[serde(rename = "const", default)] + pub const_value: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LexBytes { + #[serde(default)] + pub max_length: Option, + #[serde(default)] + pub min_length: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LexBlob { + #[serde(default)] + pub accept: Option>, + #[serde(default)] + pub max_size: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LexArray { + pub items: Box, + #[serde(default)] + pub min_length: Option, + #[serde(default)] + pub max_length: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LexUnion { + #[serde(default)] + pub refs: Vec, + #[serde(default)] + pub closed: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LexRef { + #[serde(rename = "ref")] + pub reference: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub enum StringFormat { + #[serde(rename = "did")] + Did, + #[serde(rename = "handle")] + Handle, + #[serde(rename = "at-uri")] + AtUri, + #[serde(rename = "datetime")] + Datetime, + #[serde(rename = "uri")] + Uri, + #[serde(rename = "cid")] + Cid, + #[serde(rename = "language")] + Language, + #[serde(rename = "tid")] + Tid, + #[serde(rename = "record-key")] + RecordKey, + #[serde(rename = "at-identifier")] + AtIdentifier, + #[serde(rename = "nsid")] + Nsid, +} + +pub enum ParsedRef<'a> { + Local(&'a str), + Qualified { nsid: &'a str, fragment: &'a str }, + Bare(&'a str), +} + +pub fn parse_ref(reference: &str) -> ParsedRef<'_> { + match reference.strip_prefix('#') { + Some(local) => ParsedRef::Local(local), + None => { + let stripped = reference.strip_prefix("lex:").unwrap_or(reference); + match stripped.split_once('#') { + Some((nsid, fragment)) => ParsedRef::Qualified { nsid, fragment }, + None => ParsedRef::Bare(stripped), + } + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LexStringDef {}