// DID -- Decentralized Identifier. The stable identity of an atproto account;
// the thing a handle resolves to and the thing every record's authorship is
// expressed in.
//
// https://atproto.com/specs/did
// Ported from @atproto/syntax packages/syntax/src/did.ts.
//
// Three deliberate non-checks, all of them upstream's:
//
// - Percent-encoding is not validated. `%` is an allowed character and
// `did:method:val%BB` is accepted, but so is `did:method:%zz`; only a
// trailing `%` is rejected, because that one cannot begin an escape.
// - Method-specific content is not interpreted. `did:plc:` identifiers have a
// fixed shape and `did:web:` ones are hostnames, and neither is enforced
// here -- a DID this library has never heard of must still round-trip.
// - Query strings and fragments are not accepted. Those belong to "DID URIs",
// which are a different grammar; a DID is only the identifier.
///|
/// The upstream cap. The spec's prose says 8 KB, but every implementation --
/// TypeScript, Rust -- enforces 2048, and interoperating with them beats
/// interoperating with the prose.
const DID_MAX_LENGTH : Int = 2048
///|
/// A syntactically valid DID.
///
/// Opaque: the only ways in are `parse`, which checks, and `unchecked`, which
/// says in its name that it does not.
pub struct Did(String) derive(Eq, Debug)
///|
pub impl Show for Did with fn output(self, logger) {
logger.write_string(self.0)
}
///|
pub fn Did::to_string(self : Self) -> String {
self.0
}
///|
/// Trusts the caller. For values that have already been validated -- read back
/// out of a database this library wrote, or decoded from a response that was
/// checked at the boundary -- and for tests.
pub fn Did::unchecked(did : String) -> Did {
Did(did)
}
///|
pub fn Did::is_valid(did : String) -> Bool {
try {
Did::parse(did) |> ignore
true
} catch {
_ => false
}
}
///|
/// The method: `plc` in `did:plc:7iza6de2dwap2sbkpav7c6c6`.
///
/// Spelled `method_name` because `method` is a reserved word.
///
/// Total, because a `Did` cannot exist without one.
pub fn Did::method_name(self : Self) -> String {
let rest = self.0[4:].to_owned()
match rest.split_once(":") {
Some((name, _)) => name.to_owned()
// Unreachable: `parse` rejects anything with fewer than three segments.
None => rest
}
}
///|
/// The order of these checks is upstream's, and it is observable: a caller sees
/// the message for the FIRST rule an input breaks, so reordering them changes
/// the reported reason for inputs that break several. `DID:method:val` must
/// report a missing prefix, not an uppercase method.
pub fn Did::parse(did : String) -> Did raise SyntaxError {
fn bad(reason : String) -> SyntaxError {
SyntaxError(kind=Did, input=did, reason~)
}
guard did.has_prefix("did:") else {
raise bad("DID requires \"did:\" prefix")
}
guard did.length() <= DID_MAX_LENGTH else {
raise bad("DID is too long (\{DID_MAX_LENGTH} chars max)")
}
guard !did.has_suffix(":") && !did.has_suffix("%") else {
raise bad("DID can not end with \":\" or \"%\"")
}
guard all_chars(did, is_did_char) else {
raise bad(
"Disallowed characters in DID (ASCII letters, digits, and a couple other characters only)",
)
}
let segments = did.split(":").collect()
guard segments.length() >= 3 else {
raise bad("DID requires prefix, method, and method-specific content")
}
// Spelled `method_name` because `method` is a reserved word.
let method_name = segments[1].to_owned()
// `all_chars` on the empty string is vacuously true, so the length check is
// not redundant: `did::val` has an empty method and must be rejected.
guard method_name.length() > 0 && all_chars(method_name, is_ascii_lower) else {
raise bad("DID method must be lower-case letters")
}
Did(did)
}
///|
/// `[a-zA-Z0-9._:%-]`.
fn is_did_char(c : Char) -> Bool {
is_ascii_alnum(c) || c == '.' || c == '_' || c == ':' || c == '%' || c == '-'
}