Skip to main content

lace_sans_io/iltp/
control.rs

1//! The labelled items of ILTP 0.13: their values, canonical sizes,
2//! line-at-a-time rendering, and line-at-a-time parsing.
3//!
4//! Every item after a preface starts with a `::` label line. A line item is
5//! that line alone. A record item's label is followed by one canonical record
6//! and one framing LF. A list item continues with rows of its label's kind and
7//! ends with an empty line, the only empty line between items.
8
9use std::sync::Arc;
10
11use crate::core::listing_symbols::ListingSymbol;
12use crate::core::{AdvertisementField, AdvertisementRecord};
13use crate::records::{
14    RECORD_HASH_TEXT_LEN, RecordHash, RecordKind, validate_projected_field_name_value,
15};
16
17use super::framing::{IltpError, IltpItem, malformed};
18
19/// Longest control line, excluding its LF: an HT plus a 128-byte 010 field
20/// line.
21pub(super) const CONTROL_LINE_MAX_EXCLUDING_LF: usize =
22    1 + crate::records::MAX_FIELD_LINE_BYTES_EXCLUDING_LF;
23/// A bare `HASH` offer line with its LF.
24pub const OFFER_LINE_BYTES: u64 = RECORD_HASH_TEXT_LEN as u64 + 1;
25/// A `+ HASH` or `- HASH` change line with its LF.
26pub const CHANGE_LINE_BYTES: u64 = 2 + OFFER_LINE_BYTES;
27/// The empty listing rendered as an `::offers` item.
28pub const EMPTY_LISTING_BYTES: u64 = (COMPLETE_LABEL.len() + 1) as u64;
29/// The framing of one `::response` item around its record: the label line and
30/// the LF after the record.
31pub const RESPONSE_FRAMING_BYTES: u64 = RESPONSE_LABEL.len() as u64 + 1;
32/// One `::round-end` item.
33pub const ROUND_END_BYTES: u64 = ROUND_END_LABEL.len() as u64;
34
35const COMPLETE_LABEL: &str = "::offers\n";
36const NO_PROOF_LABEL: &str = "::as unknown\n";
37const POLICY_KNOWN_LABEL: &str = "::policy-known\n";
38const POLICY_MISSING_LABEL: &str = "::policy-missing\n";
39const ROUND_END_LABEL: &str = "::round-end\n";
40const PROOF_LABEL: &str = "::as\n";
41const RESPONSE_LABEL: &str = "::response\n";
42const POLICY_BODY_LABEL: &str = "::policy-body\n";
43const WANT_LABEL: &str = "::want\n";
44const SYMBOLS_LABEL: &str = "::symbols\n";
45const POLICY_PREFIX: &str = "::policy ";
46
47/// The base a listing item applies to (060/061), named by its label.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ListingForm {
50    /// `::offers`: the empty base, a complete listing.
51    Full,
52    /// `::offers-unshared`: the intersection after a successful 061 decode.
53    Difference,
54    /// `::offers-changed`: the sender's previous listing, which it may also
55    /// withdraw from.
56    Delta,
57}
58
59impl ListingForm {
60    /// The label line, with its LF.
61    pub fn label(self) -> &'static str {
62        match self {
63            Self::Full => COMPLETE_LABEL,
64            Self::Difference => "::offers-unshared\n",
65            Self::Delta => "::offers-changed\n",
66        }
67    }
68
69    /// Whether rows are `+`/`-` changes rather than bare offers.
70    pub fn is_change(self) -> bool {
71        self == Self::Delta
72    }
73}
74
75/// One listing row: a withdrawal or a complete added entry.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum ListingRow {
78    Withdrawn(RecordHash),
79    Added(Arc<AdvertisementRecord>),
80}
81
82impl ListingRow {
83    pub fn hash(&self) -> RecordHash {
84        match self {
85            Self::Withdrawn(hash) => *hash,
86            Self::Added(entry) => entry.record_hash(),
87        }
88    }
89
90    /// Rendered bytes in a `form` item, each line with its LF.
91    pub fn rendered_bytes(&self, form: ListingForm) -> u64 {
92        match self {
93            Self::Withdrawn(_) => CHANGE_LINE_BYTES,
94            Self::Added(entry) => entry.canonical_bytes() + if form.is_change() { 2 } else { 0 },
95        }
96    }
97}
98
99/// What a record item carries.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum RecordLabel {
102    Proof,
103    PolicyBody,
104    Response,
105}
106
107impl RecordLabel {
108    fn label(self) -> &'static str {
109        match self {
110            Self::Proof => PROOF_LABEL,
111            Self::PolicyBody => POLICY_BODY_LABEL,
112            Self::Response => RESPONSE_LABEL,
113        }
114    }
115
116    /// Bytes of the whole item: its label line, a record of `canonical_len`
117    /// bytes and the LF after it.
118    pub fn item_len(self, canonical_len: usize) -> usize {
119        self.label().len() + canonical_len + 1
120    }
121}
122
123/// Bytes of one offer in an `::offers` item: its hash line and one
124/// HT-prefixed line per field occurrence.
125pub fn entry_len(fields: &[AdvertisementField]) -> u64 {
126    fields.iter().fold(OFFER_LINE_BYTES, |len, field| {
127        len + (1 + field.name.len() + 2 + field.value.len() + 1) as u64
128    })
129}
130
131/// Rendered bytes of one item, without rendering it.
132pub(super) fn item_len(item: &IltpItem) -> usize {
133    let hash_line = RECORD_HASH_TEXT_LEN + 1;
134    match item {
135        IltpItem::SessionPreface(preface) => super::framing::preface_len(preface),
136        IltpItem::NoProof => NO_PROOF_LABEL.len(),
137        IltpItem::Policy(_) => POLICY_PREFIX.len() + hash_line,
138        IltpItem::PolicyKnown => POLICY_KNOWN_LABEL.len(),
139        IltpItem::PolicyMissing => POLICY_MISSING_LABEL.len(),
140        IltpItem::Listing { form, rows } => {
141            form.label().len()
142                + rows
143                    .iter()
144                    .map(|row| row.rendered_bytes(*form) as usize)
145                    .sum::<usize>()
146                + 1
147        }
148        IltpItem::Symbols(symbols) => {
149            SYMBOLS_LABEL.len()
150                + symbols
151                    .iter()
152                    .map(|symbol| symbol.row_len() + 1)
153                    .sum::<usize>()
154                + 1
155        }
156        IltpItem::Want(hashes) => WANT_LABEL.len() + hashes.len() * hash_line + 1,
157        IltpItem::RoundEnd => ROUND_END_LABEL.len(),
158        IltpItem::Record(label, record) => label.item_len(record.canonical_len()),
159    }
160}
161
162/// Where rendering of one item's lines stands.
163#[derive(Debug, Default, Clone, Copy)]
164pub(super) struct LineCursor {
165    stage: Stage,
166    row: usize,
167    /// Within an added entry: 0 for its hash line, then one per field.
168    field: usize,
169}
170
171#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
172enum Stage {
173    #[default]
174    Label,
175    Rows,
176    Done,
177}
178
179/// Render the next line of an item, or of a record item's label, into `out`;
180/// `false` once the item's lines are exhausted. A record item renders only
181/// its label; its record and the LF after it follow.
182pub(super) fn next_line(item: &IltpItem, cursor: &mut LineCursor, out: &mut Vec<u8>) -> bool {
183    out.clear();
184    match cursor.stage {
185        Stage::Label => {
186            let list = write_label(item, out);
187            cursor.stage = if list { Stage::Rows } else { Stage::Done };
188            true
189        }
190        Stage::Rows => {
191            if !write_row(item, cursor, out) {
192                out.push(b'\n');
193                cursor.stage = Stage::Done;
194            }
195            true
196        }
197        Stage::Done => false,
198    }
199}
200
201/// Write the label line; whether rows and an empty line follow it.
202fn write_label(item: &IltpItem, out: &mut Vec<u8>) -> bool {
203    let mut push = |text: &str| out.extend_from_slice(text.as_bytes());
204    match item {
205        IltpItem::SessionPreface(_) => unreachable!("prefaces render through framing"),
206        IltpItem::NoProof => push(NO_PROOF_LABEL),
207        IltpItem::Policy(operand) => {
208            push(POLICY_PREFIX);
209            push(operand.as_str());
210            push("\n");
211        }
212        IltpItem::PolicyKnown => push(POLICY_KNOWN_LABEL),
213        IltpItem::PolicyMissing => push(POLICY_MISSING_LABEL),
214        IltpItem::RoundEnd => push(ROUND_END_LABEL),
215        IltpItem::Listing { form, .. } => {
216            push(form.label());
217            return true;
218        }
219        IltpItem::Symbols(_) => {
220            push(SYMBOLS_LABEL);
221            return true;
222        }
223        IltpItem::Want(_) => {
224            push(WANT_LABEL);
225            return true;
226        }
227        IltpItem::Record(label, _) => push(label.label()),
228    }
229    false
230}
231
232fn write_hash_line(lead: &str, hash: &RecordHash, out: &mut Vec<u8>) {
233    out.extend_from_slice(lead.as_bytes());
234    out.extend_from_slice(hash.as_str().as_bytes());
235    out.push(b'\n');
236}
237
238/// Render row `cursor.row` of a list item, advancing the cursor; `false`
239/// past the last row.
240fn write_row(item: &IltpItem, cursor: &mut LineCursor, out: &mut Vec<u8>) -> bool {
241    let row = cursor.row;
242    match item {
243        IltpItem::Listing { form, rows } => match rows.get(row) {
244            None => return false,
245            Some(ListingRow::Withdrawn(hash)) => {
246                debug_assert!(form.is_change(), "only ::offers-changed withdraws");
247                write_hash_line("- ", hash, out);
248            }
249            Some(ListingRow::Added(entry)) => {
250                match cursor.field.checked_sub(1) {
251                    None => {
252                        let lead = if form.is_change() { "+ " } else { "" };
253                        write_hash_line(lead, &entry.record_hash(), out);
254                    }
255                    Some(index) => {
256                        let field = &entry.fields()[index];
257                        out.push(b'\t');
258                        out.extend_from_slice(field.name.as_bytes());
259                        out.extend_from_slice(b": ");
260                        out.extend_from_slice(field.value.as_bytes());
261                        out.push(b'\n');
262                    }
263                }
264                cursor.field += 1;
265                if cursor.field > entry.fields().len() {
266                    cursor.field = 0;
267                    cursor.row += 1;
268                }
269                return true;
270            }
271        },
272        IltpItem::Symbols(symbols) => match symbols.get(row) {
273            None => return false,
274            Some(symbol) => {
275                symbol.write_row(out);
276                out.push(b'\n');
277            }
278        },
279        IltpItem::Want(hashes) => match hashes.get(row) {
280            None => return false,
281            Some(hash) => write_hash_line("", hash, out),
282        },
283        _ => return false,
284    }
285    cursor.row += 1;
286    true
287}
288
289/// What an item's label line opens.
290pub(super) enum Opened {
291    /// A line item, complete with its label.
292    Item(IltpItem),
293    /// A list item, parsed row by row until its empty line.
294    List(List),
295    /// A record item whose record and framing LF must follow.
296    Record(RecordLabel),
297}
298
299/// Parse one complete label line, without its LF.
300pub(super) fn open(line: &[u8]) -> Result<Opened, IltpError> {
301    let text = std::str::from_utf8(line).map_err(|_| malformed("ILTP label is not UTF-8"))?;
302    let item = match text {
303        "::as unknown" => IltpItem::NoProof,
304        "::policy-known" => IltpItem::PolicyKnown,
305        "::policy-missing" => IltpItem::PolicyMissing,
306        "::round-end" => IltpItem::RoundEnd,
307        "::as" => return Ok(Opened::Record(RecordLabel::Proof)),
308        "::policy-body" => return Ok(Opened::Record(RecordLabel::PolicyBody)),
309        "::response" => return Ok(Opened::Record(RecordLabel::Response)),
310        "::want" => return Ok(Opened::List(List::Want(Vec::new()))),
311        "::symbols" => return Ok(Opened::List(List::Symbols(Vec::new()))),
312        "::offers" => return Ok(listing(ListingForm::Full)),
313        "::offers-changed" => return Ok(listing(ListingForm::Delta)),
314        "::offers-unshared" => return Ok(listing(ListingForm::Difference)),
315        _ => {
316            let Some(rest) = text.strip_prefix(POLICY_PREFIX) else {
317                return Err(malformed("unknown ILTP label"));
318            };
319            let operand = parse_hash(rest)?;
320            if operand.kind() != RecordKind::Blob {
321                return Err(malformed("operand hash is not a Blob hash"));
322            }
323            IltpItem::Policy(operand)
324        }
325    };
326    Ok(Opened::Item(item))
327}
328
329fn listing(form: ListingForm) -> Opened {
330    Opened::List(List::Listing(ListingList {
331        form,
332        rows: Vec::new(),
333        entry: None,
334    }))
335}
336
337fn parse_hash(text: &str) -> Result<RecordHash, IltpError> {
338    RecordHash::parse(text).map_err(|error| malformed(format!("invalid record hash: {error}")))
339}
340
341/// A list item being parsed.
342pub(super) enum List {
343    Listing(ListingList),
344    Symbols(Vec<ListingSymbol>),
345    Want(Vec<RecordHash>),
346}
347
348pub(super) struct ListingList {
349    form: ListingForm,
350    rows: Vec<ListingRow>,
351    /// The added entry whose field lines are being read.
352    entry: Option<(RecordHash, Vec<AdvertisementField>)>,
353}
354
355impl List {
356    /// Take one nonempty row line, without its LF.
357    pub(super) fn push_line(&mut self, line: &[u8]) -> Result<(), IltpError> {
358        match self {
359            Self::Listing(list) => list.push_line(line)?,
360            Self::Symbols(symbols) => symbols.push(
361                ListingSymbol::parse_row(ascii(line)?)
362                    .map_err(|error| malformed(error.to_string()))?,
363            ),
364            Self::Want(hashes) => {
365                let hash = parse_hash(ascii(line)?)?;
366                if hashes.last().is_some_and(|last| *last >= hash) {
367                    return Err(malformed(
368                        "requested hashes are not in strictly increasing order",
369                    ));
370                }
371                hashes.push(hash);
372            }
373        }
374        Ok(())
375    }
376
377    /// The item its empty line completes.
378    pub(super) fn finish(self) -> Result<IltpItem, IltpError> {
379        Ok(match self {
380            Self::Listing(list) => list.finish(),
381            Self::Symbols(symbols) => {
382                if symbols.is_empty() {
383                    return Err(malformed("::symbols has no rows"));
384                }
385                IltpItem::Symbols(symbols)
386            }
387            Self::Want(hashes) => IltpItem::Want(hashes.into()),
388        })
389    }
390}
391
392impl ListingList {
393    fn push_line(&mut self, line: &[u8]) -> Result<(), IltpError> {
394        if let Some(field) = line.strip_prefix(b"\t") {
395            let Some((_, fields)) = &mut self.entry else {
396                return Err(malformed("field line does not follow an offer"));
397            };
398            let text =
399                std::str::from_utf8(field).map_err(|_| malformed("field line is not UTF-8"))?;
400            let (name, value) = text
401                .split_once(": ")
402                .ok_or_else(|| malformed("field line has no ': ' separator"))?;
403            validate_projected_field_name_value(name, value)
404                .map_err(|error| malformed(format!("invalid advertised field: {error}")))?;
405            let index = match fields.last() {
406                Some(last) if last.name.as_bytes() > name.as_bytes() => {
407                    return Err(malformed("advertised field names decrease"));
408                }
409                Some(last) if last.name == name => last.index.parse::<usize>().unwrap_or(0) + 1,
410                _ => 0,
411            };
412            fields.push(AdvertisementField::new(name, index.to_string(), value));
413            return Ok(());
414        }
415        self.finish_entry();
416        let (added, hash) = match (self.form.is_change(), line) {
417            (true, [b'+', b' ', hash @ ..]) => (true, hash),
418            (true, [b'-', b' ', hash @ ..]) => (false, hash),
419            (true, _) => return Err(malformed("::offers-changed rows start with '+ ' or '- '")),
420            (false, [b'+' | b'-', b' ', ..]) => {
421                return Err(malformed("a complete listing holds bare offer hashes"));
422            }
423            (false, hash) => (true, hash),
424        };
425        let hash = parse_hash(ascii(hash)?)?;
426        if self.rows.last().is_some_and(|last| last.hash() >= hash) {
427            return Err(malformed(
428                "listing rows are not in strictly increasing hash order",
429            ));
430        }
431        if added {
432            self.entry = Some((hash, Vec::new()));
433        } else {
434            self.rows.push(ListingRow::Withdrawn(hash));
435        }
436        Ok(())
437    }
438
439    fn finish_entry(&mut self) {
440        if let Some((hash, fields)) = self.entry.take() {
441            self.rows
442                .push(ListingRow::Added(Arc::new(AdvertisementRecord::new(
443                    hash, fields,
444                ))));
445        }
446    }
447
448    fn finish(mut self) -> IltpItem {
449        self.finish_entry();
450        IltpItem::Listing {
451            form: self.form,
452            rows: self.rows,
453        }
454    }
455}
456
457fn ascii(line: &[u8]) -> Result<&str, IltpError> {
458    if !line.is_ascii() {
459        return Err(malformed("ILTP row is not ASCII"));
460    }
461    Ok(std::str::from_utf8(line).expect("ASCII is UTF-8"))
462}