lace_sans_io/records/
util.rs1use 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
9pub const RECORD_GLYPH: &str = "🖧";
11
12pub const MAX_BLOB_SIZE: usize = 32 * 1024 * 1024;
14
15pub const MAX_PLEX_FIELDS: usize = 512;
17
18pub 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
33pub(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
43pub const MAX_BLOB_RECORD_BYTES: usize = blob_record_len(MAX_BLOB_SIZE);
45pub 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);
49pub const MAX_H3_RECORD_BYTES: usize = MAX_PLEX_RECORD_BYTES + SEAL_OVERHEAD;
51pub 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
72pub const TYPE_FIELD: &str = ":Type";
74pub const LENGTH_FIELD: &str = ":Length";
76pub const SYNTHETIC_FIELD_NAMES: [&str; 2] = [LENGTH_FIELD, TYPE_FIELD];
78
79pub fn is_synthetic_field_name(name: &str) -> bool {
81 SYNTHETIC_FIELD_NAMES.contains(&name)
82}
83
84pub 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
96pub 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
105pub(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
133pub 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
142pub 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}