// AT-URI -- `at://authority/collection/rkey`, the way one record refers to
// another. The `uri` half of every strong ref, every reply parent, every embed.
//
// https://atproto.com/specs/at-uri-scheme
// Ported from @atproto/syntax packages/syntax/src/aturi_validation.ts, the
// strict "Restricted AT URI Syntax" that Lexicons use -- not the older, laxer
// `AtUri` class, which admits syntax a server will reject.
//
// This parses into COMPONENTS rather than keeping a path string, and the
// components are an enum rather than two independent options. Both choices are
// reactions to the same bug: rsky models an AT-URI as `{host, pathname}` and
// re-splits `pathname` in every accessor, and its `set_collection` writes
// segment 0 while `get_collection` reads segment 1 -- so setting a collection
// silently returns the record key afterwards. A shape that cannot represent
// "an rkey with no collection" cannot have that bug.
///|
/// 8 KB. Larger than anything real; the point is to bound work on hostile
/// input, not to express a protocol limit.
const AT_URI_MAX_LENGTH : Int = 8192
///|
/// What an AT-URI points at. A record key without a collection is not a state
/// this type can hold.
pub(all) enum AtUriPath {
/// `at://alice.bsky.social` -- the repository itself.
Repo
/// `at://alice.bsky.social/app.bsky.feed.post` -- one collection in it.
Collection(Nsid)
/// `at://alice.bsky.social/app.bsky.feed.post/3jzfcijpj2z2a` -- one record.
Record(collection~ : Nsid, rkey~ : RecordKey)
} derive(Eq, Debug)
///|
/// A syntactically valid AT-URI.
pub struct AtUri {
authority : AtIdentifier
path : AtUriPath
/// A JSON pointer into the record, including its leading slash. Rare; used by
/// Lexicon references and by moderation reports that cite one field.
fragment : String?
} derive(Eq, Debug)
///|
pub impl Show for AtUri with fn output(self, logger) {
logger.write_string(self.to_string())
}
///|
pub fn AtUri::to_string(self : Self) -> String {
let b = StringBuilder::new()
b.write_string("at://")
b.write_string(self.authority.to_string())
match self.path {
Repo => ()
Collection(nsid) => {
b.write_char('/')
b.write_string(nsid.to_string())
}
Record(collection~, rkey~) => {
b.write_char('/')
b.write_string(collection.to_string())
b.write_char('/')
b.write_string(rkey.to_string())
}
}
if self.fragment is Some(f) {
b.write_char('#')
b.write_string(f)
}
b.to_string()
}
///|
pub fn AtUri::authority(self : Self) -> AtIdentifier {
self.authority
}
///|
pub fn AtUri::path(self : Self) -> AtUriPath {
self.path
}
///|
pub fn AtUri::fragment(self : Self) -> String? {
self.fragment
}
///|
pub fn AtUri::collection(self : Self) -> Nsid? {
match self.path {
Repo => None
Collection(nsid) => Some(nsid)
Record(collection~, ..) => Some(collection)
}
}
///|
pub fn AtUri::rkey(self : Self) -> RecordKey? {
match self.path {
Record(rkey~, ..) => Some(rkey)
_ => None
}
}
///|
/// Builds an AT-URI from parts that are already valid, so it cannot fail. This
/// is the constructor to reach for when you have a DID and a record key in hand
/// rather than a string to interpret.
pub fn AtUri::make(
authority : AtIdentifier,
path : AtUriPath,
fragment? : String,
) -> AtUri {
{ authority, path, fragment }
}
///|
pub fn AtUri::is_valid(uri : String) -> Bool {
try {
AtUri::parse(uri) |> ignore
true
} catch {
_ => false
}
}
///|
/// Note what this does NOT accept, all of which the older `AtUri` class did: a
/// missing `at://` prefix, a trailing slash, a query string, and a record key
/// that is not a valid record key. Every one of those is in the protocol's
/// interop corpus as invalid.
pub fn AtUri::parse(uri : String) -> AtUri raise SyntaxError {
fn bad(reason : String) -> SyntaxError {
SyntaxError(kind=AtUri, input=uri, reason~)
}
guard uri.length() <= AT_URI_MAX_LENGTH else {
raise bad("ATURI exceeds maximum length")
}
// This also rejects every whitespace character, which is why nothing below
// has to think about spaces.
guard all_chars(uri, is_at_uri_char) else {
raise bad("Disallowed characters in ATURI (ASCII)")
}
guard uri.has_prefix("at://") else {
raise bad("ATURI must start with \"at://\"")
}
// Fragment first, then query: a `?` after a `#` is part of the fragment, not
// a query string.
let (before_hash, fragment) = match uri.split_once("#") {
Some((head, tail)) => (head.to_owned(), Some(tail.to_owned()))
None => (uri, None)
}
guard before_hash.split_once("?") is None else {
raise bad("ATURI query part is not allowed")
}
let rest = before_hash[5:].to_owned()
guard !rest.has_suffix("/") else {
raise bad("ATURI can not have a trailing slash")
}
let segments = rest.split("/").collect()
guard segments.length() <= 3 else {
raise bad("ATURI can not have more than two path segments")
}
for segment in segments {
guard segment.length() > 0 else {
raise bad("ATURI can not have empty path segments")
}
}
let authority = AtIdentifier::parse(segments[0].to_owned()) catch {
_ => raise bad("ATURI has invalid authority")
}
let path = if segments.length() == 1 {
AtUriPath::Repo
} else {
let collection = Nsid::parse(segments[1].to_owned()) catch {
_ => raise bad("ATURI has invalid collection")
}
if segments.length() == 2 {
AtUriPath::Collection(collection)
} else {
let rkey = RecordKey::parse(segments[2].to_owned()) catch {
_ => raise bad("ATURI has invalid record key")
}
AtUriPath::Record(collection~, rkey~)
}
}
if fragment is Some(f) {
guard is_valid_fragment(f) else { raise bad("ATURI has invalid fragment") }
}
{ authority, path, fragment }
}
///|
/// `[a-zA-Z0-9._~:@!$&'()*+,;=%/\[\]#?-]` -- RFC 3986's unreserved and
/// sub-delims, plus the structural characters. Deliberately wider than any
/// component allows, so a bad character inside a collection is reported as a
/// bad collection rather than as a bad URI.
fn is_at_uri_char(c : Char) -> Bool {
is_ascii_alnum(c) ||
c == '.' ||
c == '_' ||
c == '~' ||
c == ':' ||
c == '@' ||
c == '!' ||
c == '$' ||
c == '&' ||
c == '\'' ||
c == '(' ||
c == ')' ||
c == '*' ||
c == '+' ||
c == ',' ||
c == ';' ||
c == '=' ||
c == '%' ||
c == '/' ||
c == '\\' ||
c == '[' ||
c == ']' ||
c == '#' ||
c == '?' ||
c == '-'
}
///|
/// A fragment is a JSON pointer: it must be non-empty, start with `/`, and use
/// only the pointer character set -- which notably excludes `#`, so `#a#b` is
/// rejected rather than read as a fragment containing a hash.
///
/// Percent escapes must be well-formed. Upstream gets this by calling
/// `decodeURIComponent` and catching; the check here is the same rule without
/// building the decoded string, since the decoded value is not wanted.
fn is_valid_fragment(fragment : String) -> Bool {
guard fragment.length() > 0 && fragment.has_prefix("/") else { return false }
let mut i = 0
while i < fragment.length() {
let unit = fragment[i]
if code_unit_is(unit, '%') {
guard i + 2 < fragment.length() else { return false }
guard is_hex_digit(fragment[i + 1]) && is_hex_digit(fragment[i + 2]) else {
return false
}
i = i + 3
} else {
guard char_at_is_fragment_char(fragment, i) else { return false }
i = i + 1
}
}
true
}
///|
fn is_hex_digit(unit : UInt16) -> Bool {
let i = unit.to_int()
code_unit_is_ascii_digit(unit) ||
(i >= 'a'.to_int() && i <= 'f'.to_int()) ||
(i >= 'A'.to_int() && i <= 'F'.to_int())
}
///|
/// `[a-zA-Z0-9._~:@!$&'()*+,;=/\[\]-]`, i.e. the URI set without `%`, `#` and
/// `?`. `%` is handled by the escape branch above.
fn char_at_is_fragment_char(s : String, i : Int) -> Bool {
let unit = s[i]
if code_unit_is(unit, '#') ||
code_unit_is(unit, '?') ||
code_unit_is(unit, '%') {
return false
}
code_unit_is_ascii_alpha(unit) ||
code_unit_is_ascii_digit(unit) ||
code_unit_is(unit, '.') ||
code_unit_is(unit, '_') ||
code_unit_is(unit, '~') ||
code_unit_is(unit, ':') ||
code_unit_is(unit, '@') ||
code_unit_is(unit, '!') ||
code_unit_is(unit, '$') ||
code_unit_is(unit, '&') ||
code_unit_is(unit, '\'') ||
code_unit_is(unit, '(') ||
code_unit_is(unit, ')') ||
code_unit_is(unit, '*') ||
code_unit_is(unit, '+') ||
code_unit_is(unit, ',') ||
code_unit_is(unit, ';') ||
code_unit_is(unit, '=') ||
code_unit_is(unit, '/') ||
code_unit_is(unit, '\\') ||
code_unit_is(unit, '[') ||
code_unit_is(unit, ']') ||
code_unit_is(unit, '-')
}