//! Small validators for the protocol identifiers accepted by `cpds`. use atrium_api::types::string::{Datetime, Did, Language, Nsid, RecordKey, Tid}; use crate::error::{Error, Result}; use std::{collections::HashSet, fmt, str::FromStr}; /// A canonical reference to an `ATProto` permissioned-data space. #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct SpaceRef { pub authority: String, pub space_type: String, pub skey: String, } impl fmt::Display for SpaceRef { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { write!( formatter, "at://{}/space/{}/{}", self.authority, self.space_type, self.skey ) } } impl FromStr for SpaceRef { type Err = Error; /// Parse and validate a canonical permissioned-data space reference. /// /// # Errors /// /// Returns `InvalidRequest` unless all four required segments are valid. fn from_str(value: &str) -> Result { let segments = value .strip_prefix("at://") .map(|rest| rest.split('/').collect::>()) .filter(|segments| segments.len() == 4 && segments[1] == "space") .ok_or_else(|| { Error::InvalidRequest(format!("`{value}` is not a valid space reference")) })?; did(segments[0])?; collection(segments[2])?; record_key(segments[3])?; Ok(Self { authority: segments[0].to_owned(), space_type: segments[2].to_owned(), skey: segments[3].to_owned(), }) } } /// Validate the ASCII DID shape used at protocol boundaries. /// /// # Errors /// /// Returns `InvalidRequest` when the value is not a bounded, path-safe DID. pub fn did(value: &str) -> Result<&str> { parse::(value, "DID").map(|_| value) } /// Validate an AT Protocol timestamp identifier. /// /// # Errors /// /// Returns `InvalidRequest` unless `value` is a canonical 13-character TID. pub fn tid(value: &str) -> Result<&str> { parse::(value, "TID").map(|_| value) } /// Parse an AT Protocol datetime without allowing an offset to normalize it /// before the minimum year-zero instant. /// /// # Errors /// /// Returns `InvalidRequest` when the value is not a valid Lexicon datetime. pub fn datetime(value: &str) -> Result { let datetime = parse::(value, "datetime")?; let minimum = "0000-01-01T00:00:00Z" .parse::() .map_err(|_| Error::Internal("AT Protocol datetime invariant is invalid".to_owned()))?; if datetime < minimum { return Err(Error::InvalidRequest(format!( "`{value}` normalizes before the minimum AT Protocol datetime" ))); } Ok(datetime) } /// Parse a well-formed AT Protocol BCP 47 language tag. /// /// `ATrium` supplies the tag parser. This adds RFC 5646's case-insensitive /// uniqueness rule, which its current `langtag` dependency does not enforce. /// /// # Errors /// /// Returns `InvalidRequest` when the tag syntax is invalid or a variant or /// extension singleton occurs more than once. pub fn language(value: &str) -> Result { let language = parse::(value, "language tag")?; let mut seen = HashSet::new(); let mut extension_started = false; for subtag in value.split('-').skip(1) { let kind = match subtag.as_bytes() { [b'x' | b'X'] => break, [_] => { extension_started = true; "extension" } [first, _, _, _] if !extension_started && first.is_ascii_digit() => "variant", [_, _, _, _, _, ..] if !extension_started && subtag.len() <= 8 => "variant", _ => continue, }; if !seen.insert(subtag.to_ascii_lowercase()) { return Err(Error::InvalidRequest(format!( "`{value}` contains a repeated language {kind}" ))); } } Ok(language) } /// Validate the subset of NSID syntax used for record collections. /// /// # Errors /// /// Returns `InvalidRequest` when `value` is not a normalized collection NSID. pub fn collection(value: &str) -> Result<&str> { parse::(value, "collection NSID").map(|_| value) } /// Validate an AT Protocol record key. /// /// # Errors /// /// Returns `InvalidRequest` when `value` is empty, reserved, too long, or /// contains a disallowed character. pub fn record_key(value: &str) -> Result<&str> { parse::(value, "record key").map(|_| value) } pub(crate) fn parse(value: &str, kind: &str) -> Result { value .parse() .map_err(|_| Error::InvalidRequest(format!("`{value}` is not a valid {kind}"))) } #[cfg(test)] mod tests { use atrium_api::types::string::{AtIdentifier, Handle}; use super::{SpaceRef, collection, datetime, did, language, record_key, tid}; fn fixture_cases(value: &str) -> impl Iterator { value.lines().filter(|line| { let trimmed = line.trim(); !trimmed.is_empty() && !trimmed.starts_with('#') }) } #[test] fn shared_native_parsing_preserves_borrowed_values_and_error_labels() { let fixtures = concat!( include_str!("../tests/fixtures/atproto-did-syntax-valid.txt"), include_str!("../tests/fixtures/atproto-did-syntax-invalid.txt"), include_str!("../tests/fixtures/atproto-tid-syntax-valid.txt"), include_str!("../tests/fixtures/atproto-tid-syntax-invalid.txt"), include_str!("../tests/fixtures/atproto-nsid-syntax-valid.txt"), include_str!("../tests/fixtures/atproto-nsid-syntax-invalid.txt"), include_str!("../tests/fixtures/atproto-record-key-syntax-valid.txt"), include_str!("../tests/fixtures/atproto-record-key-syntax-invalid.txt"), ); for value in fixture_cases(fixtures).chain(["", "a\0b", "café", "\"quoted\"", "line\nbreak"]) { for (actual, previous, kind) in [ ( did(value), super::Did::new(value.to_owned()).map(|_| ()), "DID", ), ( tid(value), super::Tid::new(value.to_owned()).map(|_| ()), "TID", ), ( collection(value), super::Nsid::new(value.to_owned()).map(|_| ()), "collection NSID", ), ( record_key(value), super::RecordKey::new(value.to_owned()).map(|_| ()), "record key", ), ] { let expected = previous.map(|()| value).map_err(|_| { super::Error::InvalidRequest(format!("`{value}` is not a valid {kind}")) }); assert_eq!( actual.as_ref().map_err(|error| format!("{error:?}")), expected.as_ref().map_err(|error| format!("{error:?}")), "{kind}: {value:?}" ); if let Ok(actual) = actual { assert!(std::ptr::eq(actual, value)); } } } } #[test] fn shared_datetime_parser_preserves_text_and_error_priority() { fn previous_datetime(value: &str) -> super::Result { let datetime = value.parse::().map_err(|_| { super::Error::InvalidRequest(format!("`{value}` is not a valid datetime")) })?; let minimum = "0000-01-01T00:00:00Z" .parse::() .map_err(|_| { super::Error::Internal("AT Protocol datetime invariant is invalid".to_owned()) })?; if datetime < minimum { return Err(super::Error::InvalidRequest(format!( "`{value}` normalizes before the minimum AT Protocol datetime" ))); } Ok(datetime) } for value in fixture_cases(concat!( include_str!("../tests/fixtures/atproto-datetime-syntax-valid.txt"), include_str!("../tests/fixtures/atproto-datetime-syntax-invalid.txt"), include_str!("../tests/fixtures/atproto-datetime-parse-invalid.txt"), )) .chain(["", "0000-01-01T00:00:00+01:00", "2026-09-05T12:34:56.1200Z"]) { let shape = |result: super::Result| { result .map(|datetime| serde_json::to_value(datetime).unwrap()) .map_err(|error| format!("{error:?}")) }; assert_eq!( shape(datetime(value)), shape(previous_datetime(value)), "{value}" ); } } #[test] fn shared_space_shape_gate_preserves_component_error_priority() { fn previous(value: &str) -> super::Result { let rest = value.strip_prefix("at://").ok_or_else(|| { super::Error::InvalidRequest(format!("`{value}` is not a valid space reference")) })?; let segments: Vec<_> = rest.split('/').collect(); if segments.len() != 4 || segments[1] != "space" { return Err(super::Error::InvalidRequest(format!( "`{value}` is not a valid space reference" ))); } did(segments[0])?; collection(segments[2])?; record_key(segments[3])?; Ok(SpaceRef { authority: segments[0].to_owned(), space_type: segments[2].to_owned(), skey: segments[3].to_owned(), }) } for prefix in ["at://", "AT://", "", "at:/", " at://"] { for authority in [ "did:example:alice", "did:web:example.com%3A8443", "invalid", "", ] { for marker in ["space", "SPACE", ""] { for kind in ["com.example.forum", "bad", ""] { for key in ["default", "..", "", "with/slash", "a:b"] { let value = format!("{prefix}{authority}/{marker}/{kind}/{key}"); assert_eq!( value .parse::() .map_err(|error| format!("{error:?}")), previous(&value).map_err(|error| format!("{error:?}")), "{value}" ); } } } } } for value in [ "", "at://", "at://did:example:alice", "at://did:example:alice/space", ] { assert_eq!( value .parse::() .map_err(|error| format!("{error:?}")), previous(value).map_err(|error| format!("{error:?}")), "{value}" ); } } fn previous_language(value: &str) -> super::Result { let language = value.parse::().map_err(|_| { super::Error::InvalidRequest(format!("`{value}` is not a valid language tag")) })?; let mut variants = std::collections::HashSet::new(); let mut extensions = std::collections::HashSet::new(); let mut extension_started = false; for subtag in value.split('-').skip(1) { if subtag.len() == 1 { if subtag.eq_ignore_ascii_case("x") { break; } extension_started = true; if !extensions.insert(subtag.to_ascii_lowercase()) { return Err(super::Error::InvalidRequest(format!( "`{value}` contains a repeated language extension" ))); } } else if !extension_started && ((4..=8).contains(&subtag.len()) && (subtag.len() != 4 || subtag.as_bytes().first().is_some_and(u8::is_ascii_digit))) && !variants.insert(subtag.to_ascii_lowercase()) { return Err(super::Error::InvalidRequest(format!( "`{value}` contains a repeated language variant" ))); } } Ok(language) } #[test] fn language_subtag_patterns_preserve_values_and_error_priority() { let values = [ "", "en", "en-US", "en-Latn-US", "sl-rozaj-biske-1994", "de-1901-1901", "sl-rozaj-ROZAJ", "en-a-foo-A-bar", "en-abcde-ABCDE-a-foo-a-bar", "en-abcde-ABCDE-a-foo-a-bar_", "en-a-foo-abcde-abcde", "en-a-foo-x-a-a", "en-X-abcde-ABCDE", "x-abcde-ABCDE", "X-abcde-ABCDE", "x-a-foo-A-bar", "x-x-abcde-ABCDE", "x-1abc-1ABC", "x-abcd-ABCD", "x-abcdefgh-ABCDEFGH", "x-abcdefghi-abcdefghi", "i-klingon", "en-GB-oed", "sgn-BE-FR", "en--US", "en-1234-1234", "en-abcde-abcde-", "en-ééé", ]; let outcome = |result: super::Result| { result .map(|language| serde_json::to_string(&language).unwrap()) .map_err(|error| format!("{error:?}")) }; for value in values.into_iter().chain(fixture_cases(concat!( include_str!("../tests/fixtures/atproto-language-syntax-valid.txt"), include_str!("../tests/fixtures/atproto-language-syntax-invalid.txt"), include_str!("../tests/fixtures/atproto-language-parse-invalid.txt") ))) { assert_eq!( outcome(language(value)), outcome(previous_language(value)), "{value}" ); } } #[test] fn accepts_protocol_shaped_identifiers() { assert!(collection("app.bsky.feed.post").is_ok()); assert!(record_key("3k2a6z:example").is_ok()); assert!(did("did:web:alice.test").is_ok()); assert!(tid("3jqfcqzm3fo2j").is_ok()); } #[test] fn atrium_tid_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-tid-syntax-valid.txt" )) { assert!(tid(value).is_ok(), "rejected valid TID `{value}`"); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-tid-syntax-invalid.txt" )) { assert!(tid(value).is_err(), "accepted invalid TID `{value}`"); } } #[test] fn atrium_did_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-did-syntax-valid.txt" )) { assert!(did(value).is_ok(), "rejected valid DID `{value}`"); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-did-syntax-invalid.txt" )) { assert!(did(value).is_err(), "accepted invalid DID `{value}`"); } } #[test] fn atrium_handle_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-handle-syntax-valid.txt" )) { assert!( Handle::new(value.to_owned()).is_ok(), "rejected valid handle `{value}`" ); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-handle-syntax-invalid.txt" )) { assert!( Handle::new(value.to_owned()).is_err(), "accepted invalid handle `{value}`" ); } } #[test] fn atrium_at_identifier_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-atidentifier-syntax-valid.txt" )) { assert!( value.parse::().is_ok(), "rejected valid AT identifier `{value}`" ); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-atidentifier-syntax-invalid.txt" )) { assert!( value.parse::().is_err(), "accepted invalid AT identifier `{value}`" ); } } #[test] fn atrium_datetime_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-datetime-syntax-valid.txt" )) { assert!(datetime(value).is_ok(), "rejected valid datetime `{value}`"); } for value in fixture_cases(concat!( include_str!("../tests/fixtures/atproto-datetime-syntax-invalid.txt"), include_str!("../tests/fixtures/atproto-datetime-parse-invalid.txt") )) { assert!( datetime(value).is_err(), "accepted invalid datetime `{value}`" ); } } #[test] fn atrium_language_validation_matches_current_protocol_and_official_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-language-syntax-valid.txt" )) { assert!( language(value).is_ok(), "rejected valid language tag `{value}`" ); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-language-syntax-invalid.txt" )) .filter(|value| !matches!(*value, "jaja" | "JA")) { assert!( language(value).is_err(), "accepted invalid language tag `{value}`" ); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-language-parse-invalid.txt" )) { assert!( language(value).is_err(), "accepted semantically invalid language tag `{value}`" ); } // The current specification requires well-formed, not registry-valid, // BCP 47 and the canonical atproto fixture now allows these cases. for value in ["jaja", "JA"] { assert!(language(value).is_ok()); } } #[test] fn atrium_nsid_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-nsid-syntax-valid.txt" )) { assert!(collection(value).is_ok(), "rejected valid NSID `{value}`"); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-nsid-syntax-invalid.txt" )) { assert!( collection(value).is_err(), "accepted invalid NSID `{value}`" ); } } #[test] fn atrium_record_key_validation_matches_official_syntax_fixtures() { for value in fixture_cases(include_str!( "../tests/fixtures/atproto-record-key-syntax-valid.txt" )) { assert!( record_key(value).is_ok(), "rejected valid record key `{value}`" ); } for value in fixture_cases(include_str!( "../tests/fixtures/atproto-record-key-syntax-invalid.txt" )) { assert!( record_key(value).is_err(), "accepted invalid record key `{value}`" ); } } #[test] fn rejects_paths_and_incomplete_names() { assert!(collection("posts").is_err()); assert!(collection("com.example.bad-").is_err()); assert!(did("did:12:example").is_err()); assert!(record_key("../../secret").is_err()); } #[test] fn parses_only_complete_canonical_space_references() { assert_eq!( "at://did:web:alice.test/space/com.example.forum/default" .parse::() .unwrap(), SpaceRef { authority: "did:web:alice.test".to_owned(), space_type: "com.example.forum".to_owned(), skey: "default".to_owned(), } ); assert!( "at://did:web:alice.test/com.example.forum/default" .parse::() .is_err() ); assert!( "at://did:web:alice.test/space/com.example.forum/default/extra" .parse::() .is_err() ); assert!( "at://did:web:alice.test/space/com.example.forum/../oops" .parse::() .is_err() ); } }