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()
}
/// Checks the scheme only, not the character set or structure of what
/// follows. The aim is to accept at least all valid URIs; we can always
/// tighten this later. It does not parse the authority, because at-uris
/// put colons in the authority (at://did:plc:abc123/collection/rkey) and
/// any 3986 authority parser reads that as a non-numeric port and rejects
/// it.
pub fn is_valid_uri(s: &str) -> bool {
let Some((scheme, rest)) = s.split_once(':') else {
return false;
};
let valid_scheme = !scheme.is_empty()
&& scheme.starts_with(|c: char| c.is_ascii_alphabetic())
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-' || c == '_');
if !valid_scheme {
return false;
}
match rest.strip_prefix("//") {
Some(authority_and_path) => !authority_and_path.is_empty(),
None => true,
}
}
pub fn is_valid_cid(s: &str) -> bool {
s.len() >= 8 && s.chars().all(|c| c.is_ascii_alphanumeric()) && s.starts_with(['b', 'z', '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_uris_without_authority() {
// RFC 3986 hier-part doesn't require "//": scheme ":" opaque-part is also a URI.
assert!(is_valid_uri("urn:isbn:9780141439518"));
assert!(is_valid_uri("mailto:user@example.com"));
assert!(is_valid_uri("mbid:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5")); //authority-less / path-rootless
assert!(is_valid_uri(
"has_an_underscore:70766a5a-3f95-4b19-96c8-a2c9c4a5e6e5"
));
assert!(is_valid_uri("urn:"));
}
#[test]
fn test_invalid_uri_without_scheme() {
assert!(!is_valid_uri(":no-scheme"));
}
#[test]
fn test_valid_uris_dont_reject_at_uri_authority_colons() {
// at-uri authorities contain colons (did:plc:...); is_valid_uri must not
// reject them the way a strict RFC 3986 authority parser would.
assert!(is_valid_uri(
"at://did:plc:cwdkf4xxjpznceembuuspt3d/sh.tangled.repo.pull/3mtjn7zouwn22"
));
}
#[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(""));
}
}