///|
/// HTTPS context validation for WebFinger exchanges.
///
/// WebFinger is an HTTPS-only protocol (RFC 7033 Sections 4 and 9.1):
/// clients MUST query over HTTPS, MUST NOT fall back to plain HTTP, and
/// MUST re-validate certificates after any redirect (which itself MUST
/// only target an https URI). Because this library performs no network
/// access, the caller supplies the observed exchange facts — request
/// URL, final URL, HTTP status, Content-Type and body size — and
/// `validate_response_context` checks the protocol invariants that are
/// checkable without a connection:
///
/// * the final URL must be an absolute https URI;
/// * a supplied request URL must also be https;
/// * the status must be in the 2xx success range;
/// * the Content-Type must be `application/jrd+json` (RFC 7033 Section
/// 10.2; parameters are tolerated and the media type comparison is
/// case-insensitive).
///
/// Redirects to another https origin are permitted by RFC 7033, so an
/// origin change between request and final URL is not an error here; the
/// audit module reports it as an advisory finding instead.
///|
/// The caller-observed facts about a WebFinger request. `resource` and
/// `rels` are the values actually sent.
pub struct WebFingerRequestContext {
request_url : String
resource : String
rels : Array[String]
}
///|
/// Build a request context. No validation happens here; use
/// `validate_response_context` on the paired response context.
pub fn WebFingerRequestContext::new(
request_url : String,
resource : String,
rels : Array[String],
) -> WebFingerRequestContext {
{ request_url, resource, rels }
}
///|
/// The caller-observed facts about a WebFinger response.
pub struct WebFingerResponseContext {
request_url : String?
final_url : String
status : Int
content_type : String?
body_bytes : Int?
}
///|
/// Build a response context. `status` is the HTTP status code,
/// `content_type` the full Content-Type header value (parameters
/// allowed), `body_bytes` the response body size in bytes when known.
pub fn WebFingerResponseContext::new(
request_url : String?,
final_url : String,
status : Int,
content_type : String?,
body_bytes : Int?,
) -> WebFingerResponseContext {
{ request_url, final_url, status, content_type, body_bytes }
}
///|
/// The registered JRD media type.
pub const JRD_MEDIA_TYPE : String = "application/jrd+json"
///|
/// Whether a Content-Type header value denotes the JRD media type.
/// Parameters (after `;`) are ignored and the media type comparison is
/// ASCII case-insensitive. Malformed values simply return false.
pub fn is_jrd_content_type(content_type : String) -> Bool {
match content_type.split_once(";") {
Some((media_type, _params)) =>
media_type.trim().to_lower().to_owned() == JRD_MEDIA_TYPE
None => content_type.trim().to_lower().to_owned() == JRD_MEDIA_TYPE
}
}
///|
/// Internal: whether a status code is in the 2xx success range.
fn is_success_status(status : Int) -> Bool {
status >= 200 && status <= 299
}
///|
/// Validate a response context against the checkable RFC 7033 transport
/// invariants. Returns the first violation found as a structured error;
/// advisory observations (such as a same-scheme redirect) are reported
/// by `audit_response` instead.
pub fn validate_response_context(
ctx : WebFingerResponseContext,
) -> Result[Unit, WebFingerError] {
match check_absolute_uri(ctx.final_url) {
Ok(_) => ()
Err(e) =>
return Err(
WebFingerError(
Context,
NonHttpsFinalUrl,
None,
"final_url is not an absolute URI: \{e.context()}",
),
)
}
if !scheme_is(ctx.final_url, "https") {
return Err(
WebFingerError(
Context,
NonHttpsFinalUrl,
None,
"final_url must use the https scheme (RFC 7033 Section 4.2)",
),
)
}
match ctx.request_url {
Some(url) =>
match check_absolute_uri(url) {
Ok(_) =>
if !scheme_is(url, "https") {
return Err(
WebFingerError(
Context,
NonHttpsOrigin,
None,
"request_url must use the https scheme",
),
)
}
Err(e) =>
return Err(
WebFingerError(
Context,
InvalidOrigin,
None,
"request_url is not an absolute URI: \{e.context()}",
),
)
}
None => ()
}
if !is_success_status(ctx.status) {
return Err(
WebFingerError(
Context,
InvalidHttpStatus,
None,
"status \{ctx.status} is not in the 2xx success range",
),
)
}
match ctx.content_type {
None =>
return Err(
WebFingerError(
Context,
InvalidContentType,
None,
"content_type is missing; JRD responses must use \{JRD_MEDIA_TYPE}",
),
)
Some(ct) =>
if !is_jrd_content_type(ct) {
return Err(
WebFingerError(
Context,
InvalidContentType,
None,
"content_type \{ct} is not \{JRD_MEDIA_TYPE}",
),
)
}
}
Ok(())
}
///|
/// Internal: offset of the first `/` or `None`.
fn first_slash(s : String) -> Int? {
let mut i = 0
while i < s.length() {
if s[i] == 47 {
return Some(i)
}
i = i + 1
}
None
}
///|
/// The origin (scheme + host + port) of a URL-shaped string, lowercased
/// scheme and host, or `None` when it cannot be split. Syntactic only.
pub fn origin_of(url : String) -> String? {
match url.split_once("://") {
Some((scheme, rest)) =>
match first_slash(rest.to_owned()) {
Some(n) =>
Some("\{scheme.to_lower()}://\{rest[0:n].to_owned().to_lower()}")
None => Some("\{scheme.to_lower()}://\{rest.to_owned().to_lower()}")
}
None => None
}
}