// Retrieval-context validation. RFC 9116 Section 3 fixes the security.txt location at
// /.well-known/security.txt over HTTPS; this module validates the caller-provided context
// (retrieval URI, content type) and compares it with the document's Canonical field. The
// library never fetches anything — the caller supplies the strings it observed.
///|
/// How a document was retrieved; both fields optional — absent values skip checks.
pub struct SecurityTxtContext {
retrieval_uri : String?
content_type : String?
} derive(Eq, Debug)
///|
/// An empty context: no retrieval information available.
pub fn empty_context() -> SecurityTxtContext {
{ retrieval_uri: None, content_type: None }
}
///|
/// Build a retrieval context.
pub fn security_txt_context(
retrieval_uri : String?,
content_type : String?,
) -> SecurityTxtContext {
{ retrieval_uri, content_type }
}
///|
/// The retrieval URI as provided by the caller.
pub fn SecurityTxtContext::retrieval_uri(self : SecurityTxtContext) -> String? {
self.retrieval_uri
}
///|
/// The Content-Type header value as provided by the caller.
pub fn SecurityTxtContext::content_type(self : SecurityTxtContext) -> String? {
self.content_type
}
///|
fn context_error(
kind : SecurityTxtErrorKind,
message : String,
) -> SecurityTxtError {
security_txt_error(Validation, kind, 0, 0, -1, message)
}
///|
fn check_retrieval_uri(uri : String) -> Result[Unit, SecurityTxtError] {
match uri_scheme(uri) {
None =>
return Err(context_error(InvalidContext, "retrieval URI has no scheme"))
Some(scheme) =>
if scheme != "https" {
return Err(
context_error(
InvalidContext,
"retrieval URI scheme is '\{scheme}'; RFC 9116 requires https",
),
)
}
}
// Strip query and fragment, then require the well-known path suffix.
let mut path_end = uri.length()
let mut i = 0
for c in uri {
if (c == '?' || c == '#') && i > 0 {
path_end = i
break
}
i += 1
}
let path = slice(uri, 0, path_end)
if !path.has_suffix("/.well-known/security.txt") {
return Err(
context_error(
InvalidContext,
"retrieval URI path must end with /.well-known/security.txt",
),
)
}
Ok(())
}
///|
fn check_content_type(content_type : String) -> Result[Unit, SecurityTxtError] {
let parts : Array[String] = []
for part in content_type.split(";") {
parts.push(part.trim().to_owned())
}
if parts.length() == 0 {
return Err(context_error(InvalidContext, "content type is empty"))
}
let media_type = ascii_lower(parts[0])
if media_type != "text/plain" {
return Err(
context_error(
InvalidContext,
"content type must be text/plain, found '\{media_type}'",
),
)
}
let mut i = 1
while i < parts.length() {
let param = parts[i]
if ascii_lower(param).has_prefix("charset=") {
let charset = slice(param, "charset=".length(), param.length())
if ascii_lower(charset) != "utf-8" {
return Err(
context_error(
InvalidContext,
"charset must be utf-8, found '\{charset}'",
),
)
}
}
i += 1
}
Ok(())
}
///|
/// Validate the retrieval context: HTTPS, well-known path, text/plain; charset=utf-8.
pub fn validate_context(
context : SecurityTxtContext,
) -> Result[Unit, SecurityTxtError] {
match context.retrieval_uri {
Some(uri) =>
match check_retrieval_uri(uri) {
Ok(_) => ()
Err(err) => return Err(err)
}
None => ()
}
match context.content_type {
Some(ct) =>
match check_content_type(ct) {
Ok(_) => ()
Err(err) => return Err(err)
}
None => ()
}
Ok(())
}
///|
/// Compare the retrieval URI with all Canonical fields: one must match exactly.
pub fn validate_retrieval_context(
document : SecurityTxt,
context : SecurityTxtContext,
) -> Result[Unit, SecurityTxtError] {
let canonicals = document.canonicals()
if canonicals.is_empty() {
return Ok(())
}
match context.retrieval_uri {
None =>
Err(
context_error(
ContextMismatch,
"document declares Canonical but no retrieval URI was provided",
),
)
Some(uri) => {
for canonical in canonicals {
if uri == canonical {
return Ok(())
}
}
Err(
context_error(
ContextMismatch,
"retrieval URI '\{uri}' is not listed by any Canonical field",
),
)
}
}
}