Skip to main content

lace_sans_io/records/
util.rs

1//! Shared H3 record constants and field validation.
2
3use b64a::b64a_encoded_len;
4use unicode_normalization::is_nfc;
5
6use super::hash::RECORD_HASH_TEXT_LEN;
7use super::typed_text::TYPED_H3_TEXT_LEN;
8
9/// The Lace record glyph character at the start of every record glyph line.
10pub const RECORD_GLYPH: &str = "🖧";
11
12/// Maximum Blob data size: 32 MiB.
13pub const MAX_BLOB_SIZE: usize = 32 * 1024 * 1024;
14
15/// Maximum number of application field occurrences in one Plex.
16pub const MAX_PLEX_FIELDS: usize = 512;
17
18/// Maximum complete H3 field-line size in UTF-8 bytes, excluding LF.
19pub const MAX_FIELD_LINE_BYTES_EXCLUDING_LF: usize = 128;
20
21pub(crate) const RECORD_GLYPH_LINE_PREFIX: &[u8] = "🖧: ".as_bytes();
22pub(crate) const RECORD_GLYPH_LINE_HASH_START: usize = RECORD_GLYPH_LINE_PREFIX.len();
23pub(crate) const RECORD_GLYPH_LINE_HASH_END: usize =
24    RECORD_GLYPH_LINE_HASH_START + RECORD_HASH_TEXT_LEN;
25pub(crate) const RECORD_GLYPH_LINE_LEN: usize = RECORD_GLYPH_LINE_HASH_END + 1;
26pub(crate) const DATA_LENGTH_PREFIX: &[u8] = b"Data-Length: ";
27pub(crate) const BY_LINE_PREFIX: &[u8] = b"By: ";
28pub(crate) const MARK_LINE_PREFIX: &[u8] = b"Mark: ";
29pub(crate) const BY_LINE_LEN: usize = BY_LINE_PREFIX.len() + TYPED_H3_TEXT_LEN + 1;
30pub(crate) const MARK_LINE_LEN: usize = MARK_LINE_PREFIX.len() + b64a_encoded_len(64) + 1;
31pub(crate) const SEAL_OVERHEAD: usize = RECORD_GLYPH_LINE_LEN + BY_LINE_LEN + MARK_LINE_LEN;
32
33/// Complete Blob size for a data length within the H3 bound.
34pub(crate) const fn blob_record_len(data_len: usize) -> usize {
35    let digits = if data_len == 0 {
36        1
37    } else {
38        data_len.ilog10() as usize + 1
39    };
40    RECORD_GLYPH_LINE_LEN + DATA_LENGTH_PREFIX.len() + digits + 2 + data_len
41}
42
43/// Maximum complete canonical H3 Blob byte length.
44pub const MAX_BLOB_RECORD_BYTES: usize = blob_record_len(MAX_BLOB_SIZE);
45/// Maximum complete canonical H3 Plex byte length.
46pub const MAX_PLEX_RECORD_BYTES: usize = MAX_BLOB_RECORD_BYTES
47    + RECORD_GLYPH_LINE_LEN
48    + MAX_PLEX_FIELDS * (MAX_FIELD_LINE_BYTES_EXCLUDING_LF + 1);
49/// Maximum complete canonical H3 record byte length (a Seal over a maximum Plex).
50pub const MAX_H3_RECORD_BYTES: usize = MAX_PLEX_RECORD_BYTES + SEAL_OVERHEAD;
51/// Maximum canonical non-payload prefix (record metadata) of an H3 record:
52/// every byte of a maximum Seal except its Blob data.
53pub const MAX_RECORD_METADATA_BYTES: usize = MAX_H3_RECORD_BYTES - MAX_BLOB_SIZE;
54
55pub(crate) fn with_usize_decimal_str<R>(mut value: usize, f: impl FnOnce(&str) -> R) -> R {
56    let mut buffer = [0u8; 20];
57    let mut position = buffer.len();
58    loop {
59        position -= 1;
60        buffer[position] = b'0' + (value % 10) as u8;
61        value /= 10;
62        if value == 0 {
63            break;
64        }
65    }
66    f(std::str::from_utf8(&buffer[position..]).expect("decimal digits are UTF-8"))
67}
68
69const RESERVED_APPLICATION_FIELD_NAMES: &[&str] =
70    &["Data-Length", "By", "Mark", RECORD_GLYPH, "⋯🖧"];
71
72/// The 020 synthetic record kind field: `B`, `P`, or `S`.
73pub const TYPE_FIELD: &str = ":Type";
74/// The 020 synthetic complete canonical record byte length field.
75pub const LENGTH_FIELD: &str = ":Length";
76/// Field names projected by 020 that never occur in record bytes.
77pub const SYNTHETIC_FIELD_NAMES: [&str; 2] = [LENGTH_FIELD, TYPE_FIELD];
78
79/// Whether a name is one of the 020 synthetic field names.
80pub fn is_synthetic_field_name(name: &str) -> bool {
81    SYNTHETIC_FIELD_NAMES.contains(&name)
82}
83
84/// Whether a name is reserved from Plex application use.
85pub fn is_reserved_application_field_name(name: &str) -> bool {
86    RESERVED_APPLICATION_FIELD_NAMES.contains(&name)
87}
88
89fn has_control(value: &str) -> bool {
90    value.chars().any(|character| {
91        let scalar = character as u32;
92        scalar <= 0x1f || scalar == 0x7f
93    })
94}
95
96/// Validate one split field name/value pair under the shared H3 line rules.
97pub fn validate_field_name_value(name: &str, value: &str) -> Result<(), String> {
98    validate_field_text(name, value)?;
99    if name.contains(':') || name.contains(' ') || name.contains('\t') {
100        return Err(format!("invalid field name: {name}"));
101    }
102    Ok(())
103}
104
105/// Validate one projected 020 field: a 010 field or a synthetic field.
106pub(crate) fn validate_projected_field_name_value(name: &str, value: &str) -> Result<(), String> {
107    if is_synthetic_field_name(name) {
108        validate_field_text(name, value)
109    } else {
110        validate_field_name_value(name, value)
111    }
112}
113
114fn validate_field_text(name: &str, value: &str) -> Result<(), String> {
115    if name.len() + 2 + value.len() > MAX_FIELD_LINE_BYTES_EXCLUDING_LF {
116        return Err("field line too long".into());
117    }
118    if name.is_empty() {
119        return Err("empty field name".into());
120    }
121    if value.is_empty() {
122        return Err(format!("empty field value for {name}"));
123    }
124    if !is_nfc(name) || !is_nfc(value) {
125        return Err("field text not NFC".into());
126    }
127    if has_control(name) || has_control(value) {
128        return Err("field contains control byte".into());
129    }
130    Ok(())
131}
132
133/// Validate one field line without its terminating LF.
134pub fn validate_field_line(line: &str) -> Result<(&str, &str), String> {
135    let (name, value) = line
136        .split_once(": ")
137        .ok_or_else(|| "invalid field syntax".to_string())?;
138    validate_field_name_value(name, value)?;
139    Ok((name, value))
140}
141
142/// Validate one Plex application field line.
143pub fn validate_application_field_line(line: &str) -> Result<(&str, &str), String> {
144    let (name, value) = validate_field_line(line)?;
145    if is_reserved_application_field_name(name) {
146        return Err(format!("reserved field name: {name}"));
147    }
148    Ok((name, value))
149}
150
151pub(crate) fn parse_record_link_text<'a>(name: &str, value: &'a str) -> Option<(&'a str, &'a str)> {
152    if name != "+Link" && !name.ends_with("+Link") {
153        return None;
154    }
155
156    let (data, target) = value.rsplit_once(' ')?;
157    if data.is_empty() || !is_typed_record_hash_text(target) {
158        return None;
159    }
160
161    Some((data, target))
162}
163
164fn is_typed_record_hash_text(value: &str) -> bool {
165    let mut parts = value.split('.');
166    let (Some(type_part), Some(payload_part), Some(format_part)) =
167        (parts.next(), parts.next(), parts.next())
168    else {
169        return false;
170    };
171    if parts.next().is_some() {
172        return false;
173    }
174
175    if format_part == "H3" {
176        return crate::hash::RecordHash::parse(value).is_ok();
177    }
178
179    matches!(type_part, "B" | "P" | "S")
180        && !payload_part.is_empty()
181        && !format_part.is_empty()
182        && !payload_part.chars().any(char::is_whitespace)
183        && !format_part.chars().any(char::is_whitespace)
184}