///|
/// Minimal `acct` URI support (RFC 7565), limited to what WebFinger
/// actually needs.
///
/// RFC 7565 Section 7:
///
/// acctURI = "acct" ":" userpart "@" host
/// userpart = unreserved / sub-delims
/// 0*( unreserved / pct-encoded / sub-delims )
///
/// This module provides `parse_acct_uri`, `is_acct_uri` and
/// `acct_resource`. It deliberately does NOT implement a full email
/// validator, an SMTP address parser or an IDNA framework: the host is
/// checked with the same conservative domain-name heuristic as
/// `check_hostname`, and internationalized localparts/hosts must be
/// percent-encoded / A-labels (see `docs/limitations.md`).
///|
/// A parsed `acct:` URI. `localpart` is the (possibly percent-encoded)
/// userpart exactly as it appeared in the URI; `host` is the domain as
/// it appeared.
pub struct AcctUri {
localpart : String
host : String
}
///|
/// The canonical reconstructed URI `acct:@`.
pub fn AcctUri::to_uri_string(self : AcctUri) -> String {
"acct:\{self.localpart}@\{self.host}"
}
///|
/// Internal: whether a raw code unit may appear unescaped in the userpart
/// (unreserved or sub-delims).
fn is_userpart_char_u16(u : UInt16) -> Bool {
is_unreserved_u16(u) || is_sub_delim_u16(u)
}
///|
/// Internal: whether a localpart string is valid as-is: every raw
/// character is a userpart character and every `%` begins a valid
/// `%XX` escape.
fn is_valid_localpart(localpart : String) -> Bool {
if localpart.length() == 0 {
return false
}
let mut i = 0
while i < localpart.length() {
let u = localpart[i]
if u == 37 {
if i + 2 >= localpart.length() {
return false
}
match (hex_value_u16(localpart[i + 1]), hex_value_u16(localpart[i + 2])) {
(Some(_), Some(_)) => i = i + 3
_ => return false
}
} else if !is_userpart_char_u16(u) {
return false
}
i = i + 1
}
true
}
///|
/// Quick, syntactic-only test: does this string use the `acct` scheme
/// (case-insensitively) and contain the `userpart@host` shape? This does
/// not validate the localpart or the host; use `parse_acct_uri` for a
/// strict parse.
pub fn is_acct_uri(s : String) -> Bool {
scheme_is(s, "acct") && s.contains("@")
}
///|
/// Strictly parse an `acct` URI per RFC 7565. Returns the localpart and
/// host, or a structured error (`InvalidAcctUri` / `InvalidUri` /
/// `InvalidPercentEncoding`).
pub fn parse_acct_uri(s : String) -> Result[AcctUri, WebFingerError] {
Ok(parse_acct_uri_inner(s)) catch {
e => Err(unwrap_webfinger_error(e))
}
}
///|
/// Internal raise-based version of `parse_acct_uri`.
fn parse_acct_uri_inner(s : String) -> AcctUri raise {
if !scheme_is(s, "acct") {
raise WebFingerError(Uri, InvalidAcctUri, None, "URI scheme is not acct")
}
check_absolute_uri_inner(s)
let body = s[5:]
match body.split_once("@") {
Some((localpart, host)) => {
if localpart.length() == 0 {
raise WebFingerError(
Uri,
InvalidAcctUri,
None,
"acct URI localpart is empty",
)
}
if host.contains("@") {
raise WebFingerError(
Uri,
InvalidAcctUri,
None,
"raw '@' is not allowed in the acct localpart",
)
}
if !is_valid_localpart(localpart.to_owned()) {
raise WebFingerError(
Uri,
InvalidAcctUri,
None,
"invalid acct localpart (allowed: unreserved, sub-delims, percent-encoded)",
)
}
match check_hostname(host.to_owned()) {
Ok(_) => {
let acct : AcctUri = {
localpart: localpart.to_owned(),
host: host.to_owned(),
}
acct
}
Err(_) =>
raise WebFingerError(
Uri,
InvalidAcctUri,
None,
"invalid acct host: must be a DNS domain name",
)
}
}
None =>
raise WebFingerError(
Uri,
InvalidAcctUri,
None,
"acct URI must contain exactly one '@'",
)
}
}
///|
/// Build an `acct` URI from a raw localpart and host. The localpart is
/// percent-encoded as needed (unreserved characters and sub-delims pass
/// through; note that a literal `%` is treated as data and encoded, so
/// pass the raw, unencoded localpart). The host must pass
/// `check_hostname`.
pub fn acct_resource(
localpart : String,
host : String,
) -> Result[String, WebFingerError] {
match check_hostname(host) {
Err(e) => Err(e)
Ok(_) => {
let encoded = percent_encode_acct_localpart(localpart)
Ok("acct:\{encoded}@\{host}")
}
}
}