///|
/// Decoded assertion bytes or opaque BER hex form. No directory schema or
/// distinguishedNameMatch is applied by this syntax parser.
pub(all) enum NameValue {
Text(Bytes)
Ber(Bytes)
} derive(Eq)
///|
pub(all) struct NameAssertion {
attribute : String
value : NameValue
byte_offset : Int
} derive(Eq)
///|
pub(all) struct RelativeName {
assertions : Array[NameAssertion]
} derive(Eq)
///|
pub(all) struct DistinguishedName {
rdns : Array[RelativeName]
} derive(Eq)
///|
pub(all) struct NameError {
code : String
byte_offset : Int
reason : String
} derive(Eq, ToJson)
///|
fn hex_digit(b : Byte) -> Int? {
let n = b.to_int()
if n >= 48 && n <= 57 {
Some(n - 48)
} else if n >= 65 && n <= 70 {
Some(n - 55)
} else if n >= 97 && n <= 102 {
Some(n - 87)
} else {
None
}
}
///|
fn name_error(code : String, byte_offset : Int, reason : String) -> NameError {
{ code, byte_offset, reason, }
}
///|
fn name_attribute(s : String) -> Bool {
if s == "" {
return false
}
let chars = s.iter().to_array()
if is_alpha(chars[0]) {
return chars.all(c => is_alpha(c) || is_digit(c) || c == '-')
}
let parts = s.split(".").to_array()
parts.length() >= 2 &&
parts.all(p => {
!p.is_empty() &&
p.iter().all(is_digit) &&
(p.length() == 1 || !p.has_prefix("0"))
})
}
///|
/// RFC 4514 string grammar. Empty DN is the root; values are never trimmed.
pub fn parse_dn(text : String) -> Result[DistinguishedName, NameError] {
parse_name(text, false)
}
///|
fn parse_name(
text : String,
legacy_spaces : Bool,
) -> Result[DistinguishedName, NameError] {
let b = @utf8.encode(text[:])
if b.length() > 16384 {
return Err(
name_error("name-limit", 16384, "DN exceeds this profile's 16 KiB limit."),
)
}
let rdns : Array[RelativeName] = []
if b.is_empty() {
return Ok({ rdns, })
}
let mut assertions : Array[NameAssertion] = []
let mut i = 0
while i < b.length() {
// Compatibility only for separator padding. Never trim assertion values.
if legacy_spaces && i > 0 {
while i < b.length() && b[i] == 32 {
i = i + 1
}
if i == b.length() {
return Err(
name_error(
"name-empty-component", i, "Expected an assertion after separator padding.",
),
)
}
}
let begin = i
while i < b.length() && b[i] != 61 && b[i] != 43 && b[i] != 44 {
i = i + 1
}
if i == b.length() || b[i] != 61 {
return Err(
name_error(
"name-missing-equals", begin, "Expected an attribute type followed by '='.",
),
)
}
let attribute = @utf8.decode(b[begin:i]) catch {
_ =>
return Err(
name_error(
"name-invalid-attribute", begin, "Attribute type must be an ASCII descriptor or numeric OID.",
),
)
}
if !name_attribute(attribute) {
return Err(
name_error(
"name-invalid-attribute", begin, "Expected an ASCII descriptor or OID without options, whitespace or leading-zero arcs.",
),
)
}
i = i + 1
let value_start = i
let value : NameValue = if i < b.length() && b[i] == 35 {
i = i + 1
let bytes : Array[Byte] = []
while i < b.length() && b[i] != 43 && b[i] != 44 {
if i + 1 >= b.length() {
return Err(
name_error(
"name-invalid-hex", i, "BER hex form requires pairs of hexadecimal digits.",
),
)
}
match (hex_digit(b[i]), hex_digit(b[i + 1])) {
(Some(high), Some(low)) => bytes.push((high * 16 + low).to_byte())
_ =>
return Err(
name_error(
"name-invalid-hex", i, "BER hex form requires pairs of hexadecimal digits.",
),
)
}
i = i + 2
}
if bytes.is_empty() {
return Err(
name_error(
"name-empty-hex", value_start, "BER hex form requires at least one byte after '#'.",
),
)
}
Ber(Bytes::from_array(bytes))
} else {
let bytes : Array[Byte] = []
let mut trailing_space = false
while i < b.length() && b[i] != 43 && b[i] != 44 {
let c = b[i]
if c == 92 {
if i + 1 == b.length() {
return Err(
name_error(
"name-incomplete-escape", i, "A backslash must be followed by an allowed escaped character or two hex digits.",
),
)
}
let next = b[i + 1]
match hex_digit(next) {
Some(high) => {
if i + 2 >= b.length() {
return Err(
name_error(
"name-incomplete-escape", i, "A hexadecimal escape requires two digits.",
),
)
}
let low = match hex_digit(b[i + 2]) {
Some(n) => n
None =>
return Err(
name_error(
"name-invalid-escape", i, "A hexadecimal escape requires two digits.",
),
)
}
bytes.push((high * 16 + low).to_byte())
i = i + 3
}
None => {
if ![32, 34, 35, 43, 44, 59, 60, 61, 62, 92].contains(
next.to_int(),
) {
return Err(
name_error(
"name-invalid-escape", i, "This character cannot be escaped with a single backslash; use its hex byte representation.",
),
)
}
bytes.push(next)
i = i + 2
}
}
trailing_space = false
} else {
if (i == value_start && c == 32) ||
c == 0 ||
c == 34 ||
c == 59 ||
c == 60 ||
c == 62 {
return Err(
name_error(
"name-unescaped-character", i, "Escape this leading space or special character in the DN value.",
),
)
}
bytes.push(c)
trailing_space = c == 32
i = i + 1
}
}
if trailing_space {
return Err(
name_error(
"name-trailing-space",
i - 1,
"A trailing DN value space must be escaped; it is never silently trimmed.",
),
)
}
Text(Bytes::from_array(bytes))
}
assertions.push({ attribute, value, byte_offset: begin, })
if i == b.length() || b[i] == 44 {
rdns.push({ assertions, })
assertions = []
if rdns.length() > 256 {
return Err(
name_error(
"name-rdn-limit", i, "DN exceeds this profile's 256 RDN limit.",
),
)
}
}
if i < b.length() {
i = i + 1
if i == b.length() {
return Err(
name_error(
"name-empty-component", i, "A trailing comma or plus must be followed by an attribute assertion.",
),
)
}
}
}
Ok({ rdns, })
}
///|
pub fn parse_rdn(text : String) -> Result[RelativeName, NameError] {
match parse_dn(text) {
Err(e) => Err(e)
Ok(name) =>
if name.rdns.length() == 1 {
Ok(name.rdns[0])
} else {
Err(
name_error(
"name-expected-rdn", 0, "Expected exactly one nonempty RDN; use '+' for multiple assertions, not ','.",
),
)
}
}
}
///|
fn report_name_error(
ds : Array[Diagnostic],
e : NameError,
field : String,
span : Span,
) -> Unit {
diagnose(
ds,
e.code,
"error",
span,
field +
": decoded value byte " +
e.byte_offset.to_string() +
": " +
e.reason,
)
}
///|
fn check_name_field(
text : String,
field : String,
span : Span,
ds : Array[Diagnostic],
legacy_spaces : Bool,
) -> Unit {
let strict = if field == "newrdn" {
parse_rdn(text).map(_ => ())
} else {
parse_dn(text).map(_ => ())
}
match strict {
Ok(_) => ()
Err(e) => {
let legacy_valid = legacy_spaces &&
(match parse_name(text, true) {
Ok(name) => field != "newrdn" || name.rdns.length() == 1
Err(_) => false
})
if legacy_valid {
diagnose(
ds,
"legacy-name-separator-spaces",
"warning",
span,
field +
": accepted ASCII spaces after DN/RDN separators by explicit legacy mode. The original value is preserved; this is not strict RFC 4514 syntax.",
)
} else {
report_name_error(ds, e, field, span)
}
}
}
}
///|
/// Structural parsing plus DN/newrdn/newsuperior string-syntax checks.
/// Diagnostics locate original physical fields even with Base64 or folding.
pub fn check(
data : Bytes,
options? : Options = Options::default(),
legacy_dn_spaces? : Bool = false,
risk_policy? : RiskPolicy = RiskPolicy::default(),
) -> Report {
let report = parse(data, options~)
let ds = report.diagnostics.copy()
let lines = logical_lines(data, [])
let mut cursor = 0
for r in report.document.records {
while cursor < lines.length() && lines[cursor].span.line < r.span.line {
cursor = cursor + 1
}
let mut dn_span = single_line(r.span.line)
let mut rdn_span = r.span
let mut superior_span = r.span
while cursor < lines.length() && lines[cursor].span.line <= r.span.end_line {
let line = lines[cursor]
if line.span.line == r.span.line {
dn_span = line.span
}
if line_name(line) == "newrdn" {
rdn_span = line.span
}
if line_name(line) == "newsuperior" {
superior_span = line.span
}
cursor = cursor + 1
}
check_name_field(r.dn, "dn", dn_span, ds, legacy_dn_spaces)
match r.body {
Rename(rdn, _, superior) => {
check_name_field(rdn, "newrdn", rdn_span, ds, legacy_dn_spaces)
match superior {
Some(s) =>
check_name_field(
s, "newsuperior", superior_span, ds, legacy_dn_spaces,
)
None => ()
}
}
_ => ()
}
}
check_risks(report.document, ds, risk_policy)
{ document: report.document, diagnostics: ds, names_checked: true, }
}
///|
pub fn check_text(
text : String,
options? : Options = Options::default(),
legacy_dn_spaces? : Bool = false,
risk_policy? : RiskPolicy = RiskPolicy::default(),
) -> Report {
check(@utf8.encode(text[:]), options~, legacy_dn_spaces~, risk_policy~)
}