///|
/// WebFinger request construction.
///
/// Per RFC 7033 Section 4:
///
/// * the path component MUST be `/.well-known/webfinger`;
/// * the query MUST contain a `resource` parameter exactly once, holding
/// the (absolute URI) query target;
/// * the query MAY contain one or more `rel` parameters holding link
/// relation types;
/// * every parameter value MUST be percent-encoded per RFC 3986 Section
/// 2.1 with `=` and `&` additionally encoded, and no spaces may be
/// inserted.
///
/// This module builds request *targets* only. It never derives a host
/// from the resource, never resolves DNS, and never performs network
/// access; `build_webfinger_url` requires the HTTPS origin to be passed
/// in explicitly by the caller.
///
/// RFC 7033 Section 4.3 permits repeating `rel`, so
/// `build_request_target_with_rels` preserves the caller's rel order and
/// duplicates. `canonicalize_request` is the one place that normalizes:
/// it sorts rels and drops duplicates, which is semantically neutral
/// because RFC 7033 Section 4.1 says parameter order does not matter.
///|
/// The well-known WebFinger path.
pub const WELL_KNOWN_PATH : String = "/.well-known/webfinger"
///|
/// Internal: RFC 7230 token characters (`tchar`), used to recognise
/// registered link relation types (RFC 5988 relation-type).
fn is_tchar_u16(u : UInt16) -> Bool {
is_alnum_u16(u) ||
u == 33 ||
u == 35 ||
u == 36 ||
u == 37 ||
u == 38 ||
u == 39 ||
u == 42 ||
u == 43 ||
u == 45 ||
u == 46 ||
u == 94 ||
u == 95 ||
u == 96 ||
u == 124 ||
u == 126
}
///|
/// Internal: whether `rel` is a token-shaped (registered) relation type.
fn is_registered_relation_type(rel : String) -> Bool {
if rel.length() == 0 {
return false
}
let mut i = 0
while i < rel.length() {
if !is_tchar_u16(rel[i]) {
return false
}
i = i + 1
}
true
}
///|
/// Check a link relation value: per RFC 7033 Section 4.4.4.1 a rel value
/// must be exactly one URI or one registered relation type (token). This
/// is a syntactic check — the library cannot consult the IANA registry.
pub fn check_rel_value(rel : String) -> Result[Unit, WebFingerError] {
if rel.length() == 0 {
return Err(
WebFingerError(Request, InvalidRelValue, None, "rel value is empty"),
)
}
if is_registered_relation_type(rel) {
return Ok(())
}
match check_absolute_uri(rel) {
Ok(_) => Ok(())
Err(_) =>
Err(
WebFingerError(
Request,
InvalidRelValue,
None,
"rel must be a URI or a registered relation type",
),
)
}
}
///|
/// Internal: validate the `resource` query target. RFC 7033 requires an
/// absolute URI (relative references are not used with WebFinger); an
/// unencoded `#` would make it a URI-reference with a fragment rather
/// than a URI, so it is rejected and must be percent-encoded.
fn check_resource(resource : String) -> Result[Unit, WebFingerError] {
if resource.length() == 0 {
return Err(
WebFingerError(Request, MissingResource, None, "resource is empty"),
)
}
if utf8_byte_length(resource) > Limits::default().max_subject_bytes {
return Err(
WebFingerError(
Limit,
LimitExceeded,
None,
"resource exceeds \{Limits::default().max_subject_bytes} bytes",
),
)
}
if resource.contains("#") {
return Err(
WebFingerError(
Request,
InvalidResource,
None,
"resource must not contain a fragment; percent-encode '#'",
),
)
}
match check_absolute_uri(resource) {
Ok(_) => Ok(())
Err(e) => Err(WebFingerError(Request, InvalidResource, None, e.context()))
}
}
///|
/// Internal: validate every rel and return a display context on failure.
fn check_rels(rels : Array[String]) -> Result[Unit, WebFingerError] {
for rel in rels {
match check_rel_value(rel) {
Ok(_) => continue
Err(e) =>
return Err(
WebFingerError(
Request,
InvalidRelValue,
None,
"\{e.context()} (rel: \{rel})",
),
)
}
}
Ok(())
}
///|
/// Internal: assemble the query component.
fn build_query(resource : String, rels : Array[String]) -> String {
let sb = StringBuilder::new(size_hint=64 + resource.length() * 3)
sb.write_string("resource=")
sb.write_string(percent_encode_component(resource))
for rel in rels {
sb.write_string("&rel=")
sb.write_string(percent_encode_component(rel))
}
sb.to_string()
}
///|
/// Build the WebFinger request target for a resource without rel
/// filters.
pub fn build_request_target(
resource : String,
) -> Result[String, WebFingerError] {
build_request_target_with_rels(resource, [])
}
///|
/// Build the WebFinger request target for a resource plus zero or more
/// rel filters. Rel order and duplicates are preserved, matching RFC
/// 7033 Section 4.3.
pub fn build_request_target_with_rels(
resource : String,
rels : Array[String],
) -> Result[String, WebFingerError] {
match check_resource(resource) {
Err(e) => Err(e)
Ok(_) =>
match check_rels(rels) {
Err(e) => Err(e)
Ok(_) => Ok("\{WELL_KNOWN_PATH}?\{build_query(resource, rels)}")
}
}
}
///|
/// Canonicalize a request target for the same resource and rels: the
/// `resource` parameter always comes first, rels are sorted and
/// deduplicated. Parameter order is not semantic (RFC 7033 Section 4.1),
/// so this normalization is safe, but it is provided as a separate
/// function because ordinary request building preserves caller order.
pub fn canonicalize_request(
resource : String,
rels : Array[String],
) -> Result[String, WebFingerError] {
match check_resource(resource) {
Err(e) => Err(e)
Ok(_) =>
match check_rels(rels) {
Err(e) => Err(e)
Ok(_) => {
let unique : Map[String, Bool] = Map([])
let sorted : Array[String] = []
for rel in rels {
if !unique.contains(rel) {
unique.set(rel, true)
sorted.push(rel)
}
}
sorted.sort()
Ok("\{WELL_KNOWN_PATH}?\{build_query(resource, sorted)}")
}
}
}
}
///|
/// Internal: validate an HTTPS origin (`https://host[:port]` with an
/// optional single trailing slash; no path, query, fragment or userinfo).
fn check_https_origin(origin : String) -> Result[Unit, WebFingerError] {
if !scheme_is(origin, "https") {
return Err(
WebFingerError(
Context,
NonHttpsOrigin,
None,
"origin must use the https scheme",
),
)
}
if origin.contains("?") || origin.contains("#") {
return Err(
WebFingerError(
Context,
InvalidOrigin,
None,
"origin must not contain a query or fragment",
),
)
}
let mut rest = origin[8:].to_owned()
if rest.has_suffix("/") {
rest = rest[0:rest.length() - 1].to_owned()
}
if rest.length() == 0 {
return Err(
WebFingerError(Context, InvalidOrigin, None, "origin has no host"),
)
}
if rest.contains("/") {
return Err(
WebFingerError(
Context,
InvalidOrigin,
None,
"origin must not contain a path",
),
)
}
if rest.contains("@") {
return Err(
WebFingerError(
Context,
InvalidOrigin,
None,
"origin must not contain userinfo",
),
)
}
// Optional port.
let host_part = match rest.split_once(":") {
Some((h, p)) => {
let port = p.to_owned()
if port.length() == 0 {
return Err(WebFingerError(Context, InvalidOrigin, None, "empty port"))
}
let mut i = 0
while i < port.length() {
if !is_ascii_digit_u16(port[i]) {
return Err(
WebFingerError(Context, InvalidOrigin, None, "port must be numeric"),
)
}
i = i + 1
}
h.to_owned()
}
None => rest
}
match check_hostname(host_part) {
Ok(_) => Ok(())
Err(_) =>
Err(
WebFingerError(
Context,
InvalidOrigin,
None,
"origin host must be a DNS domain name",
),
)
}
}
///|
/// Internal: strip one trailing slash for URL assembly.
fn origin_without_trailing_slash(origin : String) -> String {
if origin.has_suffix("/") && origin.length() > 1 {
origin[0:origin.length() - 1].to_owned()
} else {
origin
}
}
///|
/// Build a full WebFinger URL from a caller-supplied HTTPS origin plus
/// the resource and optional rel filters. The origin is validated
/// syntactically (https scheme, domain name host, optional numeric
/// port) but never contacted: no DNS, no TLS, no network.
pub fn build_webfinger_url(
origin : String,
resource : String,
rels : Array[String],
) -> Result[String, WebFingerError] {
match check_https_origin(origin) {
Err(e) => Err(e)
Ok(_) =>
match build_request_target_with_rels(resource, rels) {
Err(e) => Err(e)
Ok(target) => Ok("\{origin_without_trailing_slash(origin)}\{target}")
}
}
}