// Field-name registry and per-field value checks.
// URI and language-tag handling here is minimal: just enough for RFC 9116
// security.txt fields, not a general browser-grade URL parser (see docs/limitations.md).
///|
/// ASCII lower-case a string; field names are ASCII, so no Unicode case folding needed.
fn ascii_lower(s : String) -> String {
let sb = StringBuilder::new(size_hint=s.length())
for c in s {
sb.write_char(c.to_ascii_lowercase())
}
sb.to_string()
}
///|
/// Look up a case-insensitive field name; returns the canonical name.
pub fn standard_field_name(name : String) -> String? {
match ascii_lower(name) {
"contact" => Some("Contact")
"expires" => Some("Expires")
"canonical" => Some("Canonical")
"encryption" => Some("Encryption")
"acknowledgments" => Some("Acknowledgments")
"policy" => Some("Policy")
"hiring" => Some("Hiring")
"preferred-languages" => Some("Preferred-Languages")
_ => None
}
}
///|
/// True when the name (any case) is one of the nine standard fields.
pub fn is_standard_field_name(name : String) -> Bool {
standard_field_name(name) is Some(_)
}
///|
/// Map a case-insensitive name to the matching standard field, or None.
pub fn parse_standard_field(name : String, value : String) -> SecurityField? {
match standard_field_name(name) {
Some("Contact") => Some(Contact(value))
Some("Expires") => Some(Expires(value))
Some("Canonical") => Some(Canonical(value))
Some("Encryption") => Some(Encryption(value))
Some("Acknowledgments") => Some(Acknowledgments(value))
Some("Policy") => Some(Policy(value))
Some("Hiring") => Some(Hiring(value))
Some("Preferred-Languages") => Some(PreferredLanguages(value))
_ => None
}
}
///|
/// Lower-case URI scheme of a value with a scheme-like prefix
/// (ALPHA *(ALPHA / DIGIT / "+" / "-" / ".") ":").
pub fn uri_scheme(value : String) -> String? {
let mut i = 0
for c in value {
if c == ':' && i > 0 {
return Some(ascii_lower(slice(value, 0, i)))
}
let ok = c.is_ascii_alphabetic() ||
(i > 0 && (c.is_ascii_digit() || c == '+' || c == '-' || c == '.'))
if !ok {
return None
}
i += 1
}
None
}
///|
/// True when the value contains control characters (C0 except HTAB, or DEL)
/// or literal spaces, which are not permitted in a URI.
pub fn contains_uri_illegal(value : String) -> Bool {
for c in value {
let n = c.to_int()
if (n < 0x20 && n != 0x09) || n == 0x7F || n == 0x20 {
return true
}
}
false
}
///|
/// Minimal absolute-URI syntax check: scheme present, non-empty remainder, no control
/// characters or spaces. Scheme policy (e.g. the `https` requirement on `Encryption`)
/// is checked separately via `check_uri_scheme`.
pub fn check_uri(value : String) -> Result[Unit, SecurityTxtError] {
if contains_uri_illegal(value) {
return Err(
security_txt_error(
Uri,
InvalidUri,
0,
1,
0,
"URI contains control characters or spaces",
),
)
}
let scheme = match uri_scheme(value) {
None =>
return Err(
security_txt_error(
Uri,
InvalidUri,
0,
1,
0,
"value is not an absolute URI (no valid scheme prefix)",
),
)
Some(s) => s
}
let len = scheme.length() + 1
if value.length() == len {
return Err(
security_txt_error(
Uri,
InvalidUri,
0,
len + 1,
len,
"URI has an empty scheme-specific part",
),
)
}
Ok(())
}
///|
/// Enforce a per-field scheme allow-list; `InvalidScheme` on violation.
pub fn check_uri_scheme(
value : String,
allowed : Array[String],
field_name : String,
line : Int,
byte_offset : Int,
) -> Result[Unit, SecurityTxtError] {
let scheme = match uri_scheme(value) {
None =>
return Err(
security_txt_error(
Uri,
InvalidScheme,
line,
1,
byte_offset + 1,
"\{field_name} value has no URI scheme",
),
)
Some(s) => s
}
for allowed_scheme in allowed {
if scheme == allowed_scheme {
return Ok(())
}
}
Err(
security_txt_error(
Uri,
InvalidScheme,
line,
1,
byte_offset + 1,
"\{field_name} scheme '\{scheme}' is not allowed here (allowed: \{allowed.join(", ")}",
),
)
}
///|
/// Common schemes demonstrated by RFC 9116 for `Encryption`.
/// This is informational: RFC 9116 also demonstrates `dns:` and does not define
/// a closed allow-list. Validation only forbids insecure `http:` web URIs.
pub fn encryption_allowed_schemes() -> Array[String] {
["https", "dns", "openpgp4fpr"]
}
///|
/// Validate one field value against its RFC 9116 constraints. `line` and `byte_offset`
/// position the field in the original input; they are 0 for generated documents.
pub fn check_field_value(
field : SecurityField,
line : Int,
byte_offset : Int,
) -> Result[Unit, SecurityTxtError] {
match field {
Contact(value) => check_uri_at(value, "Contact", line, byte_offset)
Canonical(value) => check_uri_at(value, "Canonical", line, byte_offset)
Encryption(value) => check_uri_at(value, "Encryption", line, byte_offset)
Acknowledgments(value) =>
check_uri_at(value, "Acknowledgments", line, byte_offset)
Policy(value) => check_uri_at(value, "Policy", line, byte_offset)
Hiring(value) => check_uri_at(value, "Hiring", line, byte_offset)
Expires(value) => check_expires_at(value, line, byte_offset)
PreferredLanguages(value) =>
check_language_tags_at(value, line, byte_offset)
Extension(_, _) => Ok(())
}
}
///|
/// URI check with error positions rewritten to the field location.
fn check_uri_at(
value : String,
field_name : String,
line : Int,
byte_offset : Int,
) -> Result[Unit, SecurityTxtError] {
match check_uri(value) {
Ok(_) =>
match uri_scheme(value) {
Some("http") =>
Err(
security_txt_error(
Uri,
InvalidScheme,
line,
1,
byte_offset + 1,
"\{field_name}: web URIs must use https",
),
)
_ => Ok(())
}
Err(err) =>
Err(relocate(err, line, byte_offset, "\{field_name}: \{err.message()}"))
}
}
///|
fn check_expires_at(
value : String,
line : Int,
byte_offset : Int,
) -> Result[Unit, SecurityTxtError] {
match parse_rfc3339(value) {
Ok(_) => Ok(())
Err(err) => Err(relocate(err, line, byte_offset, err.message()))
}
}
///|
/// Split a `Preferred-Languages` value into trimmed tags.
pub fn split_preferred_languages(value : String) -> Array[String] {
let out : Array[String] = []
for part in value.split(",") {
let trimmed = part.trim()
if trimmed.length() > 0 {
out.push(trimmed.to_owned())
}
}
out
}
///|
/// Validate a language tag against the project-defined RFC 5646 subset: a 2-8 alpha
/// primary subtag (or private-use `x`), optional `-`-separated 1-8 alphanumeric subtags,
/// at most 35 characters. No registry lookup is performed.
pub fn check_language_tag(tag : String) -> Result[Unit, SecurityTxtError] {
if tag.length() == 0 {
return Err(
security_txt_error(
Language,
InvalidLanguage,
0,
1,
0,
"language tag is empty",
),
)
}
if tag.length() > 35 {
return Err(
security_txt_error(
Language,
InvalidLanguage,
0,
1,
0,
"language tag exceeds 35 characters",
),
)
}
let mut first = true
for subtag in tag.split("-") {
if first {
let primary = subtag.to_owned()
if !((primary.length() >= 2 && primary.length() <= 8) || primary == "x") {
return Err(
security_txt_error(
Language,
InvalidLanguage,
0,
1,
0,
"primary language subtag must be 2-8 letters (or the private-use singleton 'x')",
),
)
}
for c in primary {
if !c.is_ascii_alphabetic() {
return Err(
security_txt_error(
Language,
InvalidLanguage,
0,
1,
0,
"primary language subtag must be alphabetic",
),
)
}
}
first = false
} else {
if subtag.length() < 1 || subtag.length() > 8 {
return Err(
security_txt_error(
Language,
InvalidLanguage,
0,
1,
0,
"language subtag must be 1-8 alphanumeric characters",
),
)
}
for c in subtag {
if !c.is_ascii_alphabetic() && !c.is_ascii_digit() {
return Err(
security_txt_error(
Language,
InvalidLanguage,
0,
1,
0,
"language subtag must be alphanumeric",
),
)
}
}
}
}
Ok(())
}
///|
/// Validate every tag of a `Preferred-Languages` value, positioned at the field.
fn check_language_tags_at(
value : String,
line : Int,
byte_offset : Int,
) -> Result[Unit, SecurityTxtError] {
let tags = split_preferred_languages(value)
if tags.length() == 0 {
return Err(
security_txt_error(
Language,
InvalidLanguage,
line,
1,
byte_offset + 1,
"Preferred-Languages lists no language tags",
),
)
}
for tag in tags {
match check_language_tag(tag) {
Ok(_) => ()
Err(err) =>
return Err(
relocate(err, line, byte_offset, "\{err.message()} (tag '\{tag}')"),
)
}
}
Ok(())
}
///|
fn relocate(
err : SecurityTxtError,
line : Int,
byte_offset : Int,
message : String,
) -> SecurityTxtError {
security_txt_error(
err.stage(),
err.kind(),
line,
err.column() + 1,
byte_offset + 1 + err.byte_offset(),
message,
)
}