// ascii.mbt — ASCII / HTTP token helpers.
//
// HTTP/1.1 field names, methods, and structured-field tokens are restricted
// ASCII. These helpers centralize the small grammar checks required by
// RFC 9110 (HTTP Semantics) and RFC 9651 (Structured Field Values) so that
// every validation path uses identical rules.
//
// The `check_*` helpers are internal and raise `HsError`; public constructors
// wrap them and convert to `Result`.
///|
/// Returns `true` if the byte is an HTTP `tchar` (RFC 9110 §5.6.2):
/// `!#$%&'*+-.^_`|~` plus alphanumerics.
fn is_tchar_byte(b : Byte) -> Bool {
let i = b.to_int()
if i >= 0x30 && i <= 0x39 {
return true
}
if i >= 0x41 && i <= 0x5A {
return true
}
if i >= 0x61 && i <= 0x7A {
return true
}
match i {
0x21
| 0x23
| 0x24
| 0x25
| 0x26
| 0x27
| 0x2A
| 0x2B
| 0x2D
| 0x2E
| 0x5E
| 0x5F
| 0x60
| 0x7C
| 0x7E => true
_ => false
}
}
///|
/// Returns `true` if every byte of the string is an HTTP `tchar`.
pub fn is_tchar(s : String) -> Bool {
if s.is_empty() {
return false
}
for b in @utf8.encode(s) {
if !is_tchar_byte(b) {
return false
}
}
true
}
///|
/// Returns `true` if the string is a valid HTTP field name (RFC 9110 §5.1):
/// a non-empty sequence of `tchar` bytes. Field names are ASCII by definition,
/// so any non-ASCII byte fails the check.
pub fn is_valid_field_name(s : String) -> Bool {
if s.is_empty() {
return false
}
let bytes = @utf8.encode(s)
if bytes.length() != s.code_units().length() {
// A field name containing non-ASCII code units is invalid.
return false
}
for b in bytes {
if !is_tchar_byte(b) {
return false
}
}
true
}
///|
/// Returns `true` if the string contains a CR (`\r`) or LF (`\n`) byte,
/// which is forbidden in header values and in URI components.
pub fn contains_cr_or_lf(s : String) -> Bool {
s.contains("\r") || s.contains("\n")
}
///|
/// Returns the string with one optional leading and trailing OWS removed.
/// Per RFC 9110 field value semantics only leading/trailing OWS is trimmed;
/// interior whitespace is preserved byte-for-byte.
pub fn strip_ows(s : String) -> String {
s.trim(chars=" \t").to_owned()
}
///|
/// Validates a method token. Methods are case-sensitive `tchar` tokens.
/// An empty method or one containing whitespace is rejected.
fn check_method(method : String) -> Unit raise HsError {
if method.is_empty() {
raise hs_error(
MessageConstruction,
InvalidMethod,
"method must not be empty",
)
}
if method.contains(" ") || method.contains("\t") || contains_cr_or_lf(method) {
raise hs_error(
MessageConstruction,
InvalidMethod,
"method contains whitespace or control bytes",
)
}
if !is_tchar(method) {
raise hs_error(
MessageConstruction,
InvalidMethod,
"method contains non-tchar bytes",
)
}
}
///|
/// Validates an authority value: it must not contain CR/LF. Note: we
/// deliberately avoid re-parsing the authority into host/port here because
/// RFC 9421 requires the raw authority component to be used verbatim in
/// `@authority`.
fn check_authority(authority : String) -> Unit raise HsError {
if contains_cr_or_lf(authority) {
raise hs_error(
MessageConstruction,
InvalidAuthority,
"authority contains CR/LF",
)
}
}
///|
/// Validates a path component: no CR/LF. Percent-encoding is preserved
/// verbatim (RFC 9421 never re-encodes `@path`).
fn check_path(path : String) -> Unit raise HsError {
if contains_cr_or_lf(path) {
raise hs_error(MessageConstruction, InvalidPath, "path contains CR/LF")
}
}
///|
/// Validates a query component: no CR/LF. Ordering, repeated parameters,
/// empty parameters, and equals-less parameters are preserved verbatim.
fn check_query(query : String) -> Unit raise HsError {
if contains_cr_or_lf(query) {
raise hs_error(MessageConstruction, InvalidPath, "query contains CR/LF")
}
}
///|
/// Validates a scheme component: `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`
/// per RFC 3986 §3.1.
fn check_scheme(scheme : String) -> Unit raise HsError {
if scheme.is_empty() {
raise hs_error(
MessageConstruction,
InvalidScheme,
"scheme must not be empty",
)
}
let bytes = @utf8.encode(scheme)
if bytes.length() != scheme.code_units().length() {
raise hs_error(MessageConstruction, InvalidScheme, "scheme must be ASCII")
}
let first = bytes[0].to_int()
if !((first >= 0x41 && first <= 0x5A) || (first >= 0x61 && first <= 0x7A)) {
raise hs_error(
MessageConstruction,
InvalidScheme,
"scheme must start with an ASCII letter",
)
}
for i = 1; i < bytes.length(); i = i + 1 {
let c = bytes[i].to_int()
let ok = (c >= 0x41 && c <= 0x5A) ||
(c >= 0x61 && c <= 0x7A) ||
(c >= 0x30 && c <= 0x39) ||
c == 0x2B ||
c == 0x2D ||
c == 0x2E
if !ok {
raise hs_error(
MessageConstruction,
InvalidScheme,
"scheme contains invalid byte",
)
}
}
}
///|
/// Validates a status code is in the range 100..=999.
fn check_status(status : Int) -> Unit raise HsError {
if status < 100 || status > 999 {
raise hs_error(
MessageConstruction,
InvalidStatus,
"status must be 100..999",
)
}
}
///|
/// Validates that a header name is a legal HTTP field name, raising
/// `InvalidHeaderName` otherwise.
fn check_header_name(name : String) -> Unit raise HsError {
if !is_valid_field_name(name) {
raise hs_error(
MessageConstruction,
InvalidHeaderName,
"invalid header name: " + truncate_context(name),
)
}
}
///|
/// Validates that a header value contains no CR or LF bytes.
fn check_header_value(name : String, value : String) -> Unit raise HsError {
if contains_cr_or_lf(value) {
raise hs_error(
MessageConstruction,
InvalidHeaderValue,
"header value contains CR/LF: " + truncate_context(name),
)
}
}
///|
/// Cuts a string down to a safe diagnostic length.
fn truncate_context(s : String) -> String {
if s.length() > 64 {
s[:64].to_owned() + "..."
} else {
s
}
}