Skip to main content

wowlab_parsers/parsers/parcel/
codec.rs

1//! LibParcel-1.0 envelope codec; raw DEFLATE body, CRC-32 of the raw payload.
2
3use std::{
4    fmt::Write as _,
5    io::{Read, Write},
6};
7
8use flate2::{Compression, read::DeflateDecoder, write::DeflateEncoder};
9use serde::{Deserialize, Serialize};
10
11use super::{
12    base64::{self, is_base64_alphabet},
13    crc32::{crc32_hex8, is_checksum_token},
14    errors::{CodecBuildError, ParcelError},
15};
16
17/// Default cap on the total length of the encoded string.
18pub const DEFAULT_MAX_ENCODED_LEN: usize = 100_000;
19/// Default cap on the size of the base64-decoded body.
20pub const DEFAULT_MAX_DECODED_BODY: usize = 65_536;
21/// Default cap on the size of the raw payload after inflate.
22pub const DEFAULT_MAX_PAYLOAD_BYTES: usize = 262_144;
23
24const FIELD_SEPARATOR: char = '.';
25const FIELD_SEPARATOR_LEN: usize = 1;
26const CHECKSUM_LEN: usize = 8;
27const READ_CHUNK: usize = 8 * 1024;
28
29/// Builder-style configuration for [`Codec`]; optional fields fall back to SPEC defaults.
30#[derive(Clone, Debug)]
31pub struct CodecConfig {
32    pub prefix: String,
33    pub content_types: Vec<String>,
34    pub default_content_type: Option<String>,
35    pub max_encoded_len: Option<usize>,
36    pub max_decoded_body: Option<usize>,
37    pub max_payload_bytes: Option<usize>,
38}
39
40impl CodecConfig {
41    /// Construct a config with `prefix` set and content types/limits defaulted.
42    pub fn new(prefix: impl Into<String>) -> Self {
43        Self {
44            prefix: prefix.into(),
45            content_types: Vec::new(),
46            default_content_type: None,
47            max_encoded_len: None,
48            max_decoded_body: None,
49            max_payload_bytes: None,
50        }
51    }
52}
53
54/// Validated parcel codec; construct with [`Codec::new`] and reuse across calls.
55#[derive(Clone, Debug)]
56pub struct Codec {
57    prefix: String,
58    content_types: Vec<Box<str>>,
59    default_ct: String,
60    max_encoded_len: usize,
61    max_decoded_body: usize,
62    max_payload_bytes: usize,
63}
64
65/// Metadata returned alongside the decoded payload.
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67pub struct DecodeMeta {
68    pub prefix: String,
69    pub content_type: String,
70    pub checksum: String,
71    pub encoded_bytes: usize,
72    pub decoded_body_bytes: usize,
73    pub payload_bytes: usize,
74}
75
76/// Decoded parcel: the recovered payload bytes plus envelope metadata.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct DecodedParcel {
79    pub payload: Vec<u8>,
80    pub meta: DecodeMeta,
81}
82
83impl Codec {
84    /// Validate `config` and produce a ready-to-use [`Codec`].
85    ///
86    /// # Errors
87    ///
88    /// Returns [`CodecBuildError`] when the prefix or content-type configuration is invalid.
89    pub fn new(config: CodecConfig) -> Result<Self, CodecBuildError> {
90        if config.prefix.is_empty() {
91            return Err(CodecBuildError::empty_prefix());
92        }
93
94        if config.prefix.contains(FIELD_SEPARATOR) {
95            return Err(CodecBuildError::prefix_contains_dot());
96        }
97
98        let mut content_types = config.content_types;
99
100        if content_types.is_empty() {
101            content_types.push("bin".to_owned());
102        }
103
104        if let Some(bad) = content_types.iter().find(|ct| !is_valid_content_type(ct)) {
105            return Err(CodecBuildError::invalid_content_type(bad.clone()));
106        }
107
108        let default_ct = match config.default_content_type {
109            Some(ct) => {
110                if !content_types.iter().any(|c| c == &ct) {
111                    return Err(CodecBuildError::invalid_default_content_type(ct));
112                }
113
114                ct
115            }
116            None => content_types
117                .first()
118                .cloned()
119                .ok_or_else(CodecBuildError::empty_content_types)?,
120        };
121
122        let content_types = content_types
123            .into_iter()
124            .map(String::into_boxed_str)
125            .collect();
126
127        Ok(Self {
128            prefix: config.prefix,
129            content_types,
130            default_ct,
131            max_encoded_len: config.max_encoded_len.unwrap_or(DEFAULT_MAX_ENCODED_LEN),
132            max_decoded_body: config.max_decoded_body.unwrap_or(DEFAULT_MAX_DECODED_BODY),
133            max_payload_bytes: config
134                .max_payload_bytes
135                .unwrap_or(DEFAULT_MAX_PAYLOAD_BYTES),
136        })
137    }
138
139    /// The configured envelope prefix.
140    #[must_use]
141    pub fn prefix(&self) -> &str {
142        &self.prefix
143    }
144
145    /// The default content type used when [`Codec::encode`] is called without an override.
146    #[must_use]
147    pub fn default_content_type(&self) -> &str {
148        &self.default_ct
149    }
150
151    /// Encode `payload` into a parcel envelope (`None` content type uses the default).
152    ///
153    /// # Errors
154    ///
155    /// Returns [`ParcelError`] for invalid input, compression failure, or an oversized envelope.
156    pub fn encode(
157        &self,
158        payload: &[u8],
159        content_type: Option<&str>,
160    ) -> Result<String, ParcelError> {
161        let content_type = content_type.unwrap_or(&self.default_ct);
162
163        if !self
164            .content_types
165            .iter()
166            .any(|candidate| candidate.as_ref() == content_type)
167        {
168            return Err(ParcelError::ContentType);
169        }
170
171        if payload.len() > self.max_payload_bytes {
172            return Err(ParcelError::PayloadTooLarge);
173        }
174
175        let body_bytes = compress_raw_deflate(payload)?;
176
177        if body_bytes.is_empty() {
178            return Err(ParcelError::Compress);
179        }
180
181        if body_bytes.len() > self.max_decoded_body {
182            return Err(ParcelError::BodyTooLarge);
183        }
184
185        let checksum = crc32_hex8(payload);
186        let body_b64 = base64::encode(&body_bytes);
187
188        let mut encoded = String::with_capacity(
189            self.prefix.len()
190                + content_type.len()
191                + FIELD_SEPARATOR_LEN
192                + CHECKSUM_LEN
193                + FIELD_SEPARATOR_LEN
194                + body_b64.len(),
195        );
196
197        encoded.push_str(&self.prefix);
198        encoded.push_str(content_type);
199        encoded.push(FIELD_SEPARATOR);
200        encoded.push_str(&checksum);
201        encoded.push(FIELD_SEPARATOR);
202        encoded.push_str(&body_b64);
203
204        if encoded.len() > self.max_encoded_len {
205            return Err(ParcelError::EncodedTooLarge);
206        }
207
208        Ok(encoded)
209    }
210
211    /// Decode a parcel envelope into the recovered payload bytes plus metadata.
212    ///
213    /// # Errors
214    ///
215    /// Returns [`ParcelError`] for an invalid envelope, checksum, body, or configured limit.
216    pub fn decode(&self, encoded: &str) -> Result<DecodedParcel, ParcelError> {
217        let (content_type, checksum_hex, body) = self.parse(encoded)?;
218
219        let body_bytes = base64::decode(body)?;
220
221        if body_bytes.len() > self.max_decoded_body {
222            return Err(ParcelError::BodyTooLarge);
223        }
224
225        let payload = decompress_raw_deflate(&body_bytes, self.max_payload_bytes)?;
226
227        if crc32_hex8(&payload) != checksum_hex {
228            return Err(ParcelError::ChecksumMismatch);
229        }
230
231        Ok(DecodedParcel {
232            meta: DecodeMeta {
233                prefix: self.prefix.clone(),
234                content_type: content_type.to_owned(),
235                checksum: checksum_hex.to_owned(),
236                encoded_bytes: encoded.len(),
237                decoded_body_bytes: body_bytes.len(),
238                payload_bytes: payload.len(),
239            },
240            payload,
241        })
242    }
243
244    /// Decode `encoded` and render a multi-line human-readable summary (not a transport format).
245    #[must_use]
246    pub fn inspect(&self, encoded: &str) -> String {
247        match self.decode(encoded) {
248            Err(e) => format!("LibParcel inspect: {}", e.code()),
249            Ok(decoded) => format_inspect(&decoded),
250        }
251    }
252
253    fn parse<'a>(&self, encoded: &'a str) -> Result<(&'a str, &'a str, &'a str), ParcelError> {
254        if encoded.len() > self.max_encoded_len {
255            return Err(ParcelError::EncodedTooLarge);
256        }
257
258        let rest = encoded
259            .strip_prefix(self.prefix.as_str())
260            .ok_or(ParcelError::Prefix)?;
261
262        let mut parts = rest.split(FIELD_SEPARATOR);
263        let content_type = parts.next().ok_or(ParcelError::Fields)?;
264        let checksum_hex = parts.next().ok_or(ParcelError::Fields)?;
265        let body = parts.next().ok_or(ParcelError::Fields)?;
266
267        if parts.next().is_some() {
268            return Err(ParcelError::Fields);
269        }
270
271        if !self
272            .content_types
273            .iter()
274            .any(|candidate| candidate.as_ref() == content_type)
275        {
276            return Err(ParcelError::ContentType);
277        }
278
279        if !is_checksum_token(checksum_hex) {
280            return Err(ParcelError::ChecksumToken);
281        }
282
283        if body.is_empty() || !is_base64_alphabet(body) {
284            return Err(ParcelError::Base64Token);
285        }
286
287        Ok((content_type, checksum_hex, body))
288    }
289}
290
291fn is_valid_content_type(ct: &str) -> bool {
292    let mut bytes = ct.bytes();
293    let Some(first) = bytes.next() else {
294        return false;
295    };
296
297    if !first.is_ascii_lowercase() {
298        return false;
299    }
300
301    bytes.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
302}
303
304// #t(fn: rust_missing_error_context) ParcelError variants are unit per SPEC ยง7; flate2 error has nowhere to go.
305fn compress_raw_deflate(payload: &[u8]) -> Result<Vec<u8>, ParcelError> {
306    let mut encoder =
307        DeflateEncoder::new(Vec::with_capacity(payload.len()), Compression::default());
308
309    encoder
310        .write_all(payload)
311        .map_err(|_write_error| ParcelError::Compress)?;
312
313    encoder
314        .finish()
315        .map_err(|_finish_error| ParcelError::Compress)
316}
317
318// Aborts once output exceeds `max_payload_bytes` (decompression-bomb guard).
319fn decompress_raw_deflate(body: &[u8], max_payload_bytes: usize) -> Result<Vec<u8>, ParcelError> {
320    let mut decoder = DeflateDecoder::new(body);
321    let mut output = Vec::new();
322    let mut buf = [0u8; READ_CHUNK];
323
324    loop {
325        match decoder.read(&mut buf) {
326            Ok(0) => return Ok(output),
327            Ok(n) => {
328                let new_len = output.len().saturating_add(n);
329
330                if new_len > max_payload_bytes {
331                    return Err(ParcelError::PayloadTooLarge);
332                }
333                // BOUNDS: `n` is the byte count read; `buf` has READ_CHUNK capacity.
334
335                output.extend_from_slice(&buf[..n]);
336            }
337            Err(_) => return Err(ParcelError::Decompress),
338        }
339    }
340}
341
342const PREVIEW_BYTES: usize = 32;
343const LABEL_WIDTH: usize = 19;
344
345fn format_inspect(decoded: &DecodedParcel) -> String {
346    const HIGH_NIBBLE_SHIFT: u32 = 4;
347    const LOW_NIBBLE_MASK: u8 = 0x0F;
348
349    let m = &decoded.meta;
350    let mut out = String::from("LibParcel inspect:\n");
351    let _ = writeln!(out, "  {:<LABEL_WIDTH$} = {}", "prefix", m.prefix);
352    let _ = writeln!(
353        out,
354        "  {:<LABEL_WIDTH$} = {}",
355        "content_type", m.content_type
356    );
357    let _ = writeln!(out, "  {:<LABEL_WIDTH$} = {}", "checksum", m.checksum);
358    let _ = writeln!(
359        out,
360        "  {:<LABEL_WIDTH$} = {}",
361        "encoded_bytes", m.encoded_bytes
362    );
363    let _ = writeln!(
364        out,
365        "  {:<LABEL_WIDTH$} = {}",
366        "decoded_body_bytes", m.decoded_body_bytes
367    );
368    let _ = write!(
369        out,
370        "  {:<LABEL_WIDTH$} = {}",
371        "payload_bytes", m.payload_bytes
372    );
373
374    if !decoded.payload.is_empty() {
375        const HEX_BYTES_PER_INPUT: usize = 3;
376        let n = decoded.payload.len().min(PREVIEW_BYTES);
377        let mut hex = String::with_capacity(n * HEX_BYTES_PER_INPUT);
378        let mut ascii = String::with_capacity(n);
379
380        for (i, &b) in decoded.payload.iter().take(n).enumerate() {
381            if i > 0 {
382                hex.push(' ');
383            }
384
385            hex.push(hex_nibble(b >> HIGH_NIBBLE_SHIFT));
386            hex.push(hex_nibble(b & LOW_NIBBLE_MASK));
387            let printable = b.is_ascii_graphic() || b == b' ';
388
389            ascii.push(if printable { b as char } else { '.' });
390        }
391
392        out.push('\n');
393        let _ = writeln!(out, "  {:<LABEL_WIDTH$} = {hex}", "payload_hex_preview");
394        let _ = write!(out, "  {:<LABEL_WIDTH$} = {ascii}", "payload_ascii");
395    }
396
397    out
398}
399
400fn hex_nibble(n: u8) -> char {
401    const HEX_DIGIT_MAX: u8 = 9;
402
403    if n <= HEX_DIGIT_MAX {
404        (b'0' + n) as char
405    } else {
406        const HEX_LETTER_BASE: u8 = 10;
407
408        (b'a' + (n - HEX_LETTER_BASE)) as char
409    }
410}