use std::fmt;
use zeroize::Zeroizing;
pub const MAX_VALUE_LEN: usize = 64 * 1024;
pub const MAX_TAG_LEN: usize = 64;
pub const MAX_FIELDS: usize = 256;
pub const MAX_RECORDS: usize = 100_010;
pub const MAX_TOTAL_FIELDS: usize = 20 * MAX_RECORDS;
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Field {
pub(crate) name: String,
pub(crate) kind: Option<String>,
pub(crate) value: Zeroizing<Vec<u8>>,
}
impl fmt::Debug for Field {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Field")
.field("name", &self.name)
.field("kind", &self.kind)
.field("value_len", &self.value.len())
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Record {
pub(crate) fields: Vec<Field>,
}
impl Record {
pub fn get(&self, name: &str) -> Option<&[u8]> {
self.fields
.iter()
.find(|f| f.name.eq_ignore_ascii_case(name))
.map(|f| f.value.as_slice())
}
pub fn text(&self, name: &str) -> Option<&str> {
self.get(name).and_then(|v| std::str::from_utf8(v).ok())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RecordError {
BadField {
offset: usize,
},
Truncated {
offset: usize,
},
TooLong {
offset: usize,
},
TooManyFields {
offset: usize,
},
TooManyRecords {
offset: usize,
},
Unterminated,
}
impl fmt::Display for RecordError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BadField { offset } => write!(f, "a malformed field at byte {offset}"),
Self::Truncated { offset } => write!(f, "a field at byte {offset} runs past the end"),
Self::TooLong { offset } => write!(f, "a field at byte {offset} is too long"),
Self::TooManyRecords { offset } => write!(f, "too many records, at byte {offset}"),
Self::TooManyFields { offset } => {
write!(f, "a record ending at byte {offset} has too many fields")
}
Self::Unterminated => write!(f, "the last record has no <eor>"),
}
}
}
impl std::error::Error for RecordError {}
pub fn parse(input: &[u8]) -> Result<Vec<Record>, RecordError> {
let mut records = Vec::new();
let mut current = Record::default();
let mut total_fields = 0;
let mut at = 0;
while let Some(lt) = input[at..].iter().position(|&b| b == b'<') {
let start = at + lt;
let gt = input[start..]
.iter()
.take(MAX_TAG_LEN + 2)
.position(|&b| b == b'>')
.map(|p| start + p)
.ok_or(RecordError::BadField { offset: start })?;
let tag = std::str::from_utf8(&input[start + 1..gt])
.map_err(|_| RecordError::BadField { offset: start })?;
if tag.eq_ignore_ascii_case("eor") {
if records.len() == MAX_RECORDS {
return Err(RecordError::TooManyRecords { offset: start });
}
records.push(std::mem::take(&mut current));
at = gt + 1;
continue;
}
if tag.eq_ignore_ascii_case("eoh") {
current = Record::default();
at = gt + 1;
continue;
}
let mut parts = tag.split(':');
let name = parts.next().unwrap_or("");
let len = parts.next();
let kind = parts.next();
let plain_name = |s: &str| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.')
};
let plain = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric());
let len: usize = match (len, parts.next()) {
(Some(l), None)
if plain_name(name) && !l.is_empty() && l.bytes().all(|b| b.is_ascii_digit()) =>
{
l.parse()
.map_err(|_| RecordError::TooLong { offset: start })?
}
_ => return Err(RecordError::BadField { offset: start }),
};
if kind.is_some_and(|k| k.len() != 1 || !plain(k)) {
return Err(RecordError::BadField { offset: start });
}
if len > MAX_VALUE_LEN {
return Err(RecordError::TooLong { offset: start });
}
let value_start = gt + 1;
let value_end = value_start
.checked_add(len)
.filter(|&end| end <= input.len())
.ok_or(RecordError::Truncated { offset: start })?;
if current.fields.len() == MAX_FIELDS || total_fields == MAX_TOTAL_FIELDS {
return Err(RecordError::TooManyFields { offset: start });
}
total_fields += 1;
current.fields.push(Field {
name: name.to_string(),
kind: kind.map(str::to_string),
value: Zeroizing::new(input[value_start..value_end].to_vec()),
});
at = value_end;
}
if current.fields.is_empty() {
Ok(records)
} else {
Err(RecordError::Unterminated)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_records_and_types_are_read_exactly() {
let input =
b"header text\n<eoh>\n<CALL:7>QG0TEST\n<SIGN_LOTW_V2.0:4:6>ab\ncd<eor>\n<A:0><eor>";
let records = parse(input).expect("records");
assert_eq!(records.len(), 2);
assert_eq!(records[0].text("call"), Some("QG0TEST"));
let sig = &records[0].fields[1];
assert_eq!(sig.name, "SIGN_LOTW_V2.0");
assert_eq!(sig.kind.as_deref(), Some("6"));
assert_eq!(sig.value.as_slice(), &b"ab\ncd"[..4]);
assert_eq!(records[1].get("A"), Some(&b""[..]));
}
#[test]
fn a_value_may_hold_anything_its_length_covers() {
let records = parse(b"<K:5><eor><eor>").expect("records");
assert_eq!(records.len(), 1);
assert_eq!(records[0].get("K"), Some(&b"<eor>"[..]));
}
#[test]
fn malformed_input_is_refused_and_says_where() {
assert_eq!(
parse(b"<CALL:99>QG0TEST<eor>"),
Err(RecordError::Truncated { offset: 0 })
);
assert_eq!(
parse(b"<CALL:9>QG0TEST<eor>"),
Err(RecordError::Unterminated)
);
assert_eq!(
parse(b"x<CALL>QG0TEST<eor>"),
Err(RecordError::BadField { offset: 1 })
);
assert_eq!(
parse(b"<CALL:-1>x<eor>"),
Err(RecordError::BadField { offset: 0 })
);
assert_eq!(
parse(b"<CA LL:1>x<eor>"),
Err(RecordError::BadField { offset: 0 })
);
assert_eq!(
parse(b"<CALL:1:6:7>x<eor>"),
Err(RecordError::BadField { offset: 0 })
);
assert_eq!(parse(b"<CALL:1>x"), Err(RecordError::Unterminated));
assert_eq!(parse(b"<CALL:1"), Err(RecordError::BadField { offset: 0 }));
let huge = format!("<K:{}>", MAX_VALUE_LEN + 1);
assert_eq!(
parse(huge.as_bytes()),
Err(RecordError::TooLong { offset: 0 })
);
assert_eq!(
parse(b"<K:99999999999999999999999>"),
Err(RecordError::TooLong { offset: 0 })
);
}
#[test]
fn many_tiny_records_cannot_grow_memory_without_bound() {
let records = b"<A:0><eor>".repeat(MAX_RECORDS + 1);
assert!(matches!(
parse(&records),
Err(RecordError::TooManyRecords { .. })
));
let fields = b"<A:0>"
.repeat(MAX_FIELDS - 1)
.iter()
.chain(b"<eor>")
.copied()
.collect::<Vec<u8>>();
let many = fields.repeat(MAX_TOTAL_FIELDS / (MAX_FIELDS - 1) + 1);
assert!(matches!(
parse(&many),
Err(RecordError::TooManyFields { .. })
));
}
#[test]
fn a_long_name_or_type_is_refused_before_it_is_kept() {
let long = [b"<".as_slice(), &[b'A'; MAX_TAG_LEN + 1], b":0><eor>"].concat();
assert_eq!(parse(&long), Err(RecordError::BadField { offset: 0 }));
let longest = [b"<".as_slice(), &[b'A'; MAX_TAG_LEN - 2], b":0><eor>"].concat();
assert!(parse(&longest).is_ok());
assert_eq!(
parse(b"<A:1:66>x<eor>"),
Err(RecordError::BadField { offset: 0 })
);
}
#[test]
fn a_record_with_too_many_fields_is_refused() {
let mut input = Vec::new();
for _ in 0..=MAX_FIELDS {
input.extend_from_slice(b"<K:1>x");
}
input.extend_from_slice(b"<eor>");
assert!(matches!(
parse(&input),
Err(RecordError::TooManyFields { .. })
));
}
#[test]
fn every_value_is_wiped_when_dropped() {
fn wiped<T: zeroize::ZeroizeOnDrop>(_: &T) {}
let records = parse(b"<PRIVATE_KEY:6>secret<eor>").expect("records");
wiped(&records[0].fields[0].value);
}
#[test]
fn a_field_value_is_never_printed() {
let records = parse(b"<PRIVATE_KEY:6>secret<eor>").expect("records");
let shown = format!("{records:?}");
assert!(!shown.contains("secret"), "{shown}");
assert!(shown.contains("value_len: 6"), "{shown}");
}
}