Skip to main content

lace_sans_io/iltp/
framing.rs

1//! Strict ILTP item framing, record collection, and fact-line codecs.
2
3use std::fmt;
4
5use std::sync::Arc;
6
7use crate::core::listing_symbols::ListingSymbol;
8use crate::records::{Record, RecordError, RecordHash, b64a_decode, b64a_encode};
9
10use super::control::{ListingForm, ListingRow, RecordLabel};
11
12use super::stream_encoding::{StreamEncoding, StreamEncodingError, StreamOffers};
13
14pub(super) const KNOT_PREFIX: &[u8] = "🪢: ".as_bytes();
15/// Hard cap on one session preface, including its terminating empty line.
16pub const SESSION_PREFACE_MAX_BYTES: usize = 512;
17/// The knot line opening every preface: the version marker.
18pub(super) const KNOT_LINE: &str = "🪢: iltp/0.13\n";
19/// The longest port: a `Port` field line at 010's 128-byte bound.
20pub const PORT_MAX_BYTES: usize =
21    crate::records::MAX_FIELD_LINE_BYTES_EXCLUDING_LF - "Port: ".len();
22const OFFER_TOKEN_MAX_BYTES: usize = 16;
23/// Hard cap on one complete ILTP item.
24pub const ITEM_MAX_BYTES: usize = 64 * 1024 * 1024;
25/// Canonical B64A text for one nonzero 32-byte logical-session nonce.
26#[derive(Clone, PartialEq, Eq, Hash)]
27pub struct SessionNonceText(String);
28
29impl SessionNonceText {
30    pub fn from_bytes(bytes: [u8; 32]) -> Result<Self, IltpError> {
31        if bytes.iter().all(|byte| *byte == 0) {
32            return Err(malformed("session nonce must not be all-zero"));
33        }
34        Ok(Self(b64a_encode(&bytes)))
35    }
36
37    pub fn parse(text: &str) -> Result<Self, IltpError> {
38        if text.len() != 43 || !text.is_ascii() {
39            return Err(malformed(
40                "session nonce must be exactly 43 canonical B64A characters",
41            ));
42        }
43        let decoded =
44            b64a_decode(text).map_err(|_| malformed("session nonce is not canonical B64A"))?;
45        let bytes: [u8; 32] = decoded
46            .try_into()
47            .map_err(|_| malformed("session nonce must encode exactly 32 bytes"))?;
48        if bytes.iter().all(|byte| *byte == 0) || b64a_encode(&bytes) != text {
49            return Err(malformed(
50                "session nonce must encode canonical nonzero 32-byte data",
51            ));
52        }
53        Ok(Self(text.to_owned()))
54    }
55
56    pub fn as_str(&self) -> &str {
57        &self.0
58    }
59    pub fn bytes(&self) -> [u8; 32] {
60        b64a_decode(&self.0)
61            .expect("validated nonce text")
62            .try_into()
63            .expect("validated nonce width")
64    }
65}
66
67impl fmt::Debug for SessionNonceText {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.debug_tuple("SessionNonceText").field(&self.0).finish()
70    }
71}
72
73/// What a preface's sender wants to talk to: `SEGMENT(/SEGMENT)*`, each
74/// segment one or more printable ASCII bytes other than `/`, at most
75/// [`PORT_MAX_BYTES`] in total.
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub struct PortText(String);
78
79impl PortText {
80    pub fn parse(text: &str) -> Result<Self, IltpError> {
81        let valid = (1..=PORT_MAX_BYTES).contains(&text.len())
82            && text.split('/').all(|segment| {
83                !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_graphic())
84            });
85        if !valid {
86            return Err(malformed(
87                "port must be nonempty printable-ASCII segments separated by '/', at most 122 bytes",
88            ));
89        }
90        Ok(Self(text.to_owned()))
91    }
92
93    pub fn as_str(&self) -> &str {
94        &self.0
95    }
96}
97
98impl fmt::Display for PortText {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(&self.0)
101    }
102}
103
104/// One raw session preface: its sender's nonce, stream-encoding offers, and
105/// optional port.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct SessionPreface {
108    pub nonce: SessionNonceText,
109    pub offers: StreamOffers,
110    pub port: Option<PortText>,
111}
112
113/// One framing input or output from a Lace ILTP byte stream: a raw preface
114/// or a labelled item.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum IltpItem {
117    SessionPreface(SessionPreface),
118    /// `::as unknown`: no proof presented.
119    NoProof,
120    /// `::policy HASH`: the sender's operand.
121    Policy(RecordHash),
122    /// `::policy-known`: the sender holds the operand the peer announced.
123    PolicyKnown,
124    /// `::policy-missing`: the sender lacks the operand the peer announced.
125    PolicyMissing,
126    /// A listing label with its hash-ordered rows.
127    Listing {
128        form: ListingForm,
129        rows: Vec<ListingRow>,
130    },
131    /// `::symbols`: one 061 batch, nonempty.
132    Symbols(Vec<ListingSymbol>),
133    /// `::want` with strictly increasing hashes.
134    Want(Arc<[RecordHash]>),
135    /// `::round-end`.
136    RoundEnd,
137    /// `::as`, `::policy-body` or `::response`, its record and the LF
138    /// after it.
139    Record(RecordLabel, Record),
140}
141
142/// Error returned by the ILTP codec.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum IltpError {
145    Record(RecordError),
146    Truncated(IltpTruncation),
147    ItemTooLarge,
148    Malformed(String),
149    StreamEncoding(StreamEncodingError),
150    /// Local encoder or output-ownership failure: `Integration`.
151    Local(&'static str),
152}
153
154impl IltpError {
155    /// Whether this is a local failure (`Integration`) rather than a property
156    /// of peer bytes.
157    pub fn is_local(&self) -> bool {
158        matches!(
159            self,
160            Self::StreamEncoding(StreamEncodingError::Local(_)) | Self::Local(_)
161        )
162    }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum IltpTruncation {
167    Preface,
168    Control,
169    Record,
170    StreamItem,
171    /// EOF before a compressed encoding's end mark.
172    StreamEncoding,
173}
174
175impl fmt::Display for IltpError {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        match self {
178            Self::Record(err) => write!(f, "record validation failed: {err}"),
179            Self::Truncated(kind) => write!(f, "truncated ILTP {} at end of stream", kind.label()),
180            Self::ItemTooLarge => f.write_str("ILTP item exceeds 64 MiB hard cap"),
181            Self::Malformed(msg) => f.write_str(msg),
182            Self::StreamEncoding(err) => err.fmt(f),
183            Self::Local(message) => f.write_str(message),
184        }
185    }
186}
187
188impl IltpTruncation {
189    fn label(self) -> &'static str {
190        match self {
191            Self::Preface => "preface",
192            Self::Control => "control item",
193            Self::Record => "record",
194            Self::StreamItem => "stream item",
195            Self::StreamEncoding => "stream encoding before its end mark",
196        }
197    }
198}
199
200impl std::error::Error for IltpError {}
201
202impl From<RecordError> for IltpError {
203    fn from(value: RecordError) -> Self {
204        Self::Record(value)
205    }
206}
207
208impl From<StreamEncodingError> for IltpError {
209    fn from(value: StreamEncodingError) -> Self {
210        Self::StreamEncoding(value)
211    }
212}
213
214mod incremental;
215pub(super) use incremental::{Framed, ItemFramer, PrefaceRule};
216
217/// Append the plaintext ILTP bytes of one item: test support that scripts
218/// peer bytes.
219#[cfg(any(test, feature = "internal"))]
220#[doc(hidden)]
221pub fn encode_item(item: &IltpItem, out: &mut Vec<u8>) -> Result<(), IltpError> {
222    if let IltpItem::SessionPreface(preface) = item {
223        write_preface(preface, out);
224        return Ok(());
225    }
226    let mut cursor = super::control::LineCursor::default();
227    let mut line = Vec::new();
228    while super::control::next_line(item, &mut cursor, &mut line) {
229        out.extend_from_slice(&line);
230    }
231    if let IltpItem::Record(_, record) = item {
232        out.extend_from_slice(
233            &record
234                .to_vec(crate::records::MAX_H3_RECORD_BYTES)
235                .map_err(|_| IltpError::Local("record bytes could not be read"))?,
236        );
237        out.push(b'\n');
238    }
239    Ok(())
240}
241
242/// Canonical byte length of one preface, as [`write_preface`] renders it.
243pub(super) fn preface_len(preface: &SessionPreface) -> usize {
244    let mut len = 0;
245    preface_lines(preface, |line| {
246        len += line.iter().map(|part| part.len()).sum::<usize>()
247    });
248    len
249}
250
251pub(super) fn write_preface(preface: &SessionPreface, out: &mut Vec<u8>) {
252    preface_lines(preface, |line| {
253        for part in line {
254            out.extend_from_slice(part.as_bytes());
255        }
256    });
257}
258
259/// Hand each preface line, as parts, to `line`: the knot line, `Nonce`,
260/// nonempty `Decode` and `Encode`, an optional `Port`, and the empty line.
261fn preface_lines(preface: &SessionPreface, mut line: impl FnMut(&[&str])) {
262    fn list(line: &mut impl FnMut(&[&str]), name: &'static str, encodings: &[StreamEncoding]) {
263        for (index, encoding) in encodings.iter().enumerate() {
264            let lead = if index == 0 { name } else { "," };
265            let end = if index + 1 == encodings.len() {
266                "\n"
267            } else {
268                ""
269            };
270            line(&[lead, encoding.token(), end]);
271        }
272    }
273    line(&[KNOT_LINE]);
274    line(&["Nonce: ", preface.nonce.as_str(), "\n"]);
275    list(&mut line, "Decode: ", preface.offers.decode());
276    list(&mut line, "Encode: ", preface.offers.encode());
277    if let Some(port) = &preface.port {
278        line(&["Port: ", port.as_str(), "\n"]);
279    }
280    line(&["\n"]);
281}
282
283/// The fields of a preface being decoded, one field line at a time.
284#[derive(Debug, Default)]
285pub(super) struct PrefaceFields {
286    names: Vec<String>,
287    nonce: Option<SessionNonceText>,
288    decode: Vec<StreamEncoding>,
289    encode: Vec<StreamEncoding>,
290    port: Option<PortText>,
291}
292
293impl PrefaceFields {
294    /// Apply one field line, with its LF. Unknown names are ignored once
295    /// checked against 010's line rules and for repetition.
296    pub(super) fn push_line(&mut self, line: &[u8]) -> Result<(), IltpError> {
297        let text = std::str::from_utf8(&line[..line.len() - 1])
298            .map_err(|_| malformed("preface field line is not UTF-8"))?;
299        let (name, value) = crate::util::validate_field_line(text)
300            .map_err(|error| malformed(format!("invalid preface field line: {error}")))?;
301        if self.names.iter().any(|seen| seen == name) {
302            return Err(malformed(format!("preface field {name} repeats")));
303        }
304        self.names.push(name.to_owned());
305        match name {
306            "Nonce" => self.nonce = Some(SessionNonceText::parse(value)?),
307            "Decode" => {
308                let tokens = offer_tokens(value)?;
309                if tokens.windows(2).any(|pair| pair[0] >= pair[1]) {
310                    return Err(malformed(
311                        "preface Decode tokens must be in strictly ascending order",
312                    ));
313                }
314                self.decode = StreamOffers::known(tokens);
315            }
316            "Encode" => {
317                let tokens = offer_tokens(value)?;
318                if tokens
319                    .iter()
320                    .enumerate()
321                    .any(|(index, token)| tokens[..index].contains(token))
322                {
323                    return Err(malformed("preface Encode tokens must not repeat"));
324                }
325                self.encode = StreamOffers::known(tokens);
326            }
327            "Port" => self.port = Some(PortText::parse(value)?),
328            _ => {}
329        }
330        Ok(())
331    }
332
333    pub(super) fn finish(self) -> Result<SessionPreface, IltpError> {
334        Ok(SessionPreface {
335            nonce: self
336                .nonce
337                .ok_or_else(|| malformed("session preface has no Nonce field"))?,
338            offers: StreamOffers::from_lists(self.decode, self.encode),
339            port: self.port,
340        })
341    }
342}
343
344fn offer_tokens(list: &str) -> Result<Vec<&str>, IltpError> {
345    list.split(',')
346        .map(|token| {
347            let valid = (1..=OFFER_TOKEN_MAX_BYTES).contains(&token.len())
348                && token
349                    .bytes()
350                    .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit());
351            if !valid {
352                Err(malformed("preface offer token must match [a-z0-9]{1,16}"))
353            } else if token == StreamEncoding::Identity.token() {
354                Err(malformed("preface offers must not list identity"))
355            } else {
356                Ok(token)
357            }
358        })
359        .collect()
360}
361
362pub(super) fn malformed(message: impl Into<String>) -> IltpError {
363    IltpError::Malformed(message.into())
364}