// Defensive RFC 9116 security.txt parser. Syntax only: byte decoding, line structure,
// comments, field name/value separation, resource limits. RFC field constraints (URI
// schemes, date formats, cardinality) belong to the validator; advisory observations
// belong to the audit layer.
///|
/// Parse a security.txt document with default limits.
pub fn parse_security_txt(
input : String,
) -> Result[SecurityTxt, SecurityTxtError] {
parse_security_txt_with_limits(input, Limits::default())
}
///|
/// Parse a security.txt document with explicit limits.
pub fn parse_security_txt_with_limits(
input : String,
limits : Limits,
) -> Result[SecurityTxt, SecurityTxtError] {
parse_security_txt_bytes(@utf8.encode(input), limits)
}
///|
/// Parse raw bytes with limits; invalid UTF-8 reports InvalidUtf8 with byte offsets.
pub fn parse_security_txt_bytes(
input : Bytes,
limits : Limits,
) -> Result[SecurityTxt, SecurityTxtError] {
Ok(parse_inner(input, limits)) catch {
e => Err(unwrap_security_txt_error(e))
}
}
///|
/// Split text into LF lines, stripping one CR, with byte offsets; no trailing line.
pub fn split_lines_text(input : String) -> (Array[String], Array[Int]) {
let lines : Array[String] = []
let offsets : Array[Int] = []
let mut start = 0
let mut i = 0
while i < input.length() {
let c = char_at_index(input, i)
if c == '\n' ||
(
c == '\r' &&
i + 1 < input.length() &&
char_at_index(input, i + 1) == '\n'
) {
lines.push(slice(input, start, i))
offsets.push(start)
start = i + (if c == '\n' { 1 } else { 2 })
i += if c == '\n' { 1 } else { 2 }
} else {
i += 1
}
}
if start < input.length() {
lines.push(slice(input, start, input.length()))
offsets.push(start)
}
(lines, offsets)
}
///|
fn char_at_index(s : String, at : Int) -> Char {
let mut i = 0
for c in s {
if i == at {
return c
}
i += 1
}
'?'
}
///|
/// Parse the raw bytes. Every failure raises SecurityTxtError.
fn parse_inner(input : Bytes, limits : Limits) -> SecurityTxt raise {
let byte_count = input.length()
if byte_count > limits.max_input_bytes {
raise security_txt_error(
Limit,
LimitExceeded,
1,
1,
limits.max_input_bytes,
"input size \{byte_count} exceeds max_input_bytes \{limits.max_input_bytes}",
)
}
let raw = input.to_fixedarray()
// Split into line byte slices on LF; strip one trailing CR; reject stray CR.
let line_starts : Array[Int] = []
let line_ends : Array[Int] = []
let mut line_start = 0
let mut i = 0
let mut current_line = 0
while i < byte_count {
let b = raw[i].to_int()
if b == 0x0A || b == 0x0D {
if b == 0x0D {
// CR is only legal immediately before LF.
if i + 1 >= byte_count || raw[i + 1].to_int() != 0x0A {
raise security_txt_error(
Line,
InvalidLine,
current_line + 1,
i - line_start + 1,
i,
"stray carriage return (line endings must be LF or CRLF)",
)
}
}
line_starts.push(line_start)
line_ends.push(i)
line_start = i + (if b == 0x0D { 2 } else { 1 })
current_line += 1
if b == 0x0D {
i += 1
}
}
i += 1
}
if line_start < byte_count {
line_starts.push(line_start)
line_ends.push(byte_count)
current_line += 1
}
let line_count = current_line
if line_count > limits.max_lines {
raise security_txt_error(
Limit,
LimitExceeded,
1,
1,
0,
"line count \{line_count} exceeds max_lines \{limits.max_lines}",
)
}
// Strip a UTF-8 BOM from the very start of the input.
let bom = byte_count >= 3 &&
raw[0].to_int() == 0xEF &&
raw[1].to_int() == 0xBB &&
raw[2].to_int() == 0xBF
// Decode each line, validating UTF-8 with byte-accurate offsets.
let lines : Array[String] = []
let offsets : Array[Int] = []
for j = 0; j < line_starts.length(); j = j + 1 {
let mut s = line_starts[j]
if bom && j == 0 && s < 3 {
s = 3
}
let line_bytes = line_ends[j] - s
if line_bytes > limits.max_line_bytes {
raise security_txt_error(
Limit,
LimitExceeded,
j + 1,
1,
line_starts[j],
"line \{j + 1} exceeds max_line_bytes \{limits.max_line_bytes}",
)
}
let decoded = decode_utf8_strict(
raw,
s,
line_ends[j],
j + 1,
line_starts[j],
)
lines.push(decoded)
offsets.push(line_starts[j])
}
// Detect a signed envelope on the first line.
let mut signature = SignatureState::Unsigned
let mut armor_headers : Array[String] = []
let mut field_lines : Array[String] = []
let mut field_offsets : Array[Int] = []
match split_signed_envelope(lines, offsets) {
Ok(None) => {
field_lines = lines
field_offsets = offsets
}
Ok(Some(payload)) => {
signature = SignedUnverified
armor_headers = payload.armor_headers()
field_lines = payload.cleartext_lines()
field_offsets = payload.cleartext_offsets()
}
Err(err) => raise err
}
// Process field lines.
let entries : Array[SecurityFieldEntry] = []
let comments : Array[String] = []
let mut field_count = 0
for j = 0; j < field_lines.length(); j = j + 1 {
let line = field_lines[j]
// RFC 9116 permits blank lines whose EOL is preceded by WSP.
if line.trim().length() == 0 {
continue
}
if line.has_prefix("#") {
comments.push(slice(line, 1, line.length()))
continue
}
field_count += 1
if field_count > limits.max_fields {
raise security_txt_error(
Limit,
LimitExceeded,
j + 1,
1,
field_offsets[j],
"field count exceeds max_fields \{limits.max_fields}",
)
}
let (name, value) = split_field_line(line, j + 1, field_offsets[j])
if utf8_byte_len(value) > limits.max_field_value_bytes {
raise security_txt_error(
Limit,
LimitExceeded,
j + 1,
1,
field_offsets[j],
"field value exceeds max_field_value_bytes \{limits.max_field_value_bytes}",
)
}
let field = match parse_standard_field(name, value) {
Some(f) => f
None => Extension(name, value)
}
entries.push(security_field_entry(field, j + 1, field_offsets[j]))
}
security_txt(
entries, comments, signature, armor_headers, line_count, byte_count,
)
}
///|
fn is_ws(c : Char) -> Bool {
c == ' ' || c == '\t'
}
///|
/// Split a field line into (name, value) at the first colon.
fn split_field_line(
line : String,
line_no : Int,
byte_offset : Int,
) -> (String, String) raise {
let mut colon = -1
let mut i = 0
for c in line {
if c == ':' {
colon = i
break
}
i += 1
}
if colon < 0 {
raise security_txt_error(
Line,
MissingColon,
line_no,
line.length() + 1,
byte_offset,
"field line has no ':' separator",
)
}
let name = slice(line, 0, colon)
if name.length() == 0 {
raise security_txt_error(
FieldName,
EmptyFieldName,
line_no,
1,
byte_offset,
"field name is empty",
)
}
for c in name {
if !c.is_ascii_alphabetic() && !c.is_ascii_digit() && c != '-' {
raise security_txt_error(
FieldName,
InvalidLine,
line_no,
1,
byte_offset,
"field name contains characters outside ALPHA / DIGIT / '-'",
)
}
}
// RFC 9116 field syntax is `Name: value`; whitespace after the colon is syntax, not
// part of the value. Surrounding SP / HTAB is stripped from the stored value.
let raw_value = slice(line, colon + 1, line.length())
if raw_value.length() == 0 {
raise security_txt_error(
FieldValue,
EmptyFieldValue,
line_no,
colon + 2,
byte_offset + colon + 1,
"field value is empty",
)
}
if char_at_index(raw_value, 0) != ' ' {
raise security_txt_error(
Line,
InvalidLine,
line_no,
colon + 2,
byte_offset + colon + 1,
"field separator must be followed by SP",
)
}
let mut value_start = 0
while value_start < raw_value.length() &&
is_ws(char_at_index(raw_value, value_start)) {
value_start += 1
}
let mut value_end = raw_value.length()
while value_end > value_start &&
is_ws(char_at_index(raw_value, value_end - 1)) {
value_end -= 1
}
let value = slice(raw_value, value_start, value_end)
if value.length() == 0 {
raise security_txt_error(
FieldValue,
EmptyFieldValue,
line_no,
colon + 2,
byte_offset + colon + 1,
"field value is empty",
)
}
for c in value {
if c.to_int() < 0x20 || c.to_int() == 0x7F {
raise security_txt_error(
FieldValue,
InvalidLine,
line_no,
colon + 2 + value_start,
byte_offset + colon + 1 + value_start,
"field value contains a control character",
)
}
}
(name, value)
}
///|
pub fn utf8_byte_len(s : String) -> Int {
let mut n = 0
for c in s {
n += char_utf8_len(c)
}
n
}
///|
pub fn char_utf8_len(c : Char) -> Int {
let n = c.to_int()
if n < 0x80 {
1
} else if n < 0x800 {
2
} else if n < 0x10000 {
3
} else {
4
}
}
///|
fn is_cont(b : Int) -> Bool {
b >= 0x80 && b <= 0xBF
}
///|
/// Strict UTF-8 decoder with byte-accurate diagnostics.
fn decode_utf8_strict(
raw : FixedArray[Byte],
start : Int,
end : Int,
line : Int,
base_offset : Int,
) -> String raise {
let sb = StringBuilder::new(size_hint=end - start)
let mut i = start
let mut chars = 0
while i < end {
let b0 = raw[i].to_int()
if b0 < 0x80 {
sb.write_char(b0.unsafe_to_char())
i += 1
} else if b0 <= 0xBF {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"unexpected continuation byte",
)
} else if b0 >= 0xC2 && b0 <= 0xDF {
if i + 1 >= end {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"truncated 2-byte sequence",
)
}
let b1 = raw[i + 1].to_int()
if !is_cont(b1) {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"invalid continuation byte",
)
}
let cp = ((b0 & 0x1F) << 6) | (b1 & 0x3F)
sb.write_char(cp.unsafe_to_char())
i += 2
} else if b0 <= 0xEF {
if i + 2 >= end {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"truncated 3-byte sequence",
)
}
let b1 = raw[i + 1].to_int()
let b2 = raw[i + 2].to_int()
let ok = is_cont(b1) &&
is_cont(b2) &&
(if b0 == 0xE0 {
b1 >= 0xA0
} else if b0 == 0xED {
b1 <= 0x9F
} else {
true
})
if !ok {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"invalid 3-byte sequence (overlong, surrogate or bad continuation)",
)
}
let cp = ((b0 & 0x0F) << 12) | ((b1 & 0x3F) << 6) | (b2 & 0x3F)
sb.write_char(cp.unsafe_to_char())
i += 3
} else if b0 <= 0xF4 {
if i + 3 >= end {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"truncated 4-byte sequence",
)
}
let b1 = raw[i + 1].to_int()
let b2 = raw[i + 2].to_int()
let b3 = raw[i + 3].to_int()
let ok = is_cont(b1) &&
is_cont(b2) &&
is_cont(b3) &&
(if b0 == 0xF0 {
b1 >= 0x90 && b1 <= 0x9F
} else if b0 == 0xF4 {
b1 <= 0x8F
} else {
true
})
if !ok {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"invalid 4-byte sequence (overlong, above U+10FFFF or bad continuation)",
)
}
let cp = ((b0 & 0x07) << 18) |
((b1 & 0x3F) << 12) |
((b2 & 0x3F) << 6) |
(b3 & 0x3F)
sb.write_char(cp.unsafe_to_char())
i += 4
} else {
raise invalid_utf8(
line,
chars + 1,
base_offset + i - start,
"byte is not valid UTF-8",
)
}
chars += 1
}
sb.to_string()
}
///|
fn invalid_utf8(
line : Int,
column : Int,
offset : Int,
message : String,
) -> SecurityTxtError {
security_txt_error(Input, InvalidUtf8, line, column, offset, message)
}