Skip to main content

truefix_core/codec/
decode.rs

1//! Decode wire bytes into a [`Message`], verifying BodyLength (tag 9) and CheckSum (tag 10).
2//!
3//! Decoding produces flat ordered fields (no dictionary-driven group parsing at this layer).
4//! Binary length-prefixed data fields (e.g. RawDataLength/RawData) are handled so embedded
5//! SOH bytes do not corrupt parsing. No path panics.
6
7use crate::error::DecodeError;
8use crate::field::Field;
9use crate::field_map::FieldMap;
10use crate::framing::MAX_BODY_LEN;
11use crate::group::{Group, GroupSpec};
12use crate::message::Message;
13use crate::tags::{
14    BEGIN_STRING, BODY_LENGTH, CHECK_SUM, MSG_TYPE, SOH, data_field_for_length, is_header,
15    is_trailer,
16};
17
18/// A tokenized field: tag, raw value bytes, and the byte offset where the field began.
19type Token = (u32, Vec<u8>, usize);
20
21/// Wire section a tag statically classifies into (header < body < trailer). Used only to detect
22/// out-of-order sectioning (`ValidateFieldsOutOfOrder`; FR-006) — it does not affect where the
23/// field actually lands (that's still governed by `is_header`/`is_trailer` at each call site).
24fn section_of(tag: u32) -> u8 {
25    if is_trailer(tag) {
26        2
27    } else if is_header(tag) {
28        0
29    } else {
30        1
31    }
32}
33
34/// Decode `input` into a [`Message`] with flat fields (no dictionary-driven group structure).
35pub fn decode(input: &[u8]) -> Result<Message, DecodeError> {
36    let fields = tokenize_validated(input)?;
37    let mut msg = Message::new();
38    let mut max_section_seen = 0u8;
39    // T177/T178 (feature 009, NEW-37/38): `fields` is consumed here and nowhere else, so taking
40    // ownership via `into_iter()` moves each value straight into its `Field` — previously
41    // `.iter()` + `.clone()` allocated a second copy of every field's value bytes for no reason.
42    for (i, (tag, value, _)) in fields.into_iter().enumerate() {
43        let section = section_of(tag);
44        if section < max_section_seen {
45            msg.fields_out_of_order = true;
46        } else {
47            max_section_seen = section;
48        }
49        if i == 2 && tag != MSG_TYPE {
50            msg.fields_out_of_order = true;
51        }
52        let field = Field::new(tag, value);
53        if is_trailer(tag) {
54            msg.trailer.add_field(field);
55        } else if is_header(tag) {
56            msg.header.add_field(field);
57        } else {
58            msg.body.add_field(field);
59        }
60    }
61    Ok(msg)
62}
63
64/// Decode `input` into a [`Message`] with repeating groups structured per `spec` (FR-004, and
65/// header/trailer groups per US9, feature 005, FR-026). Header, body, and trailer are each
66/// decoded through the same group-aware machinery (`decode_section_with_groups`) — a section with
67/// no declared groups (today, header/trailer always; body when `spec` has none for it either)
68/// decodes exactly as flat fields, since `spec.group_of(tag)` simply never matches. Structure is
69/// best-effort/greedy — count/order validation is a separate dictionary concern (see
70/// `truefix-dict`).
71pub fn decode_with_groups(input: &[u8], spec: &dyn GroupSpec) -> Result<Message, DecodeError> {
72    let fields = tokenize_validated(input)?;
73    let mut msg = Message::new();
74    let mut header: Vec<Token> = Vec::new();
75    let mut body: Vec<Token> = Vec::new();
76    let mut trailer: Vec<Token> = Vec::new();
77    // GAP-26/FR-032 (feature 006): mirror `decode()`'s `fields_out_of_order`/
78    // `ValidateFieldsOutOfOrder` tracking, which this function never performed at all — a
79    // pre-existing gap in this otherwise-correct primitive, only surfaced once a production path
80    // actually started calling it (`crates/truefix-transport`'s `classify_buffered`).
81    let mut max_section_seen = 0u8;
82    for (i, tok) in fields.iter().enumerate() {
83        let tag = tok.0;
84        let section = section_of(tag);
85        if section < max_section_seen {
86            msg.fields_out_of_order = true;
87        } else {
88            max_section_seen = section;
89        }
90        if i == 2 && tag != MSG_TYPE {
91            msg.fields_out_of_order = true;
92        }
93    }
94    for tok in fields {
95        let tag = tok.0;
96        if is_trailer(tag) {
97            trailer.push(tok);
98        } else if is_header(tag) {
99            header.push(tok);
100        } else {
101            body.push(tok);
102        }
103    }
104    decode_section_with_groups(&header, spec, &mut msg.header)?;
105    decode_section_with_groups(&body, spec, &mut msg.body)?;
106    decode_section_with_groups(&trailer, spec, &mut msg.trailer)?;
107    Ok(msg)
108}
109
110/// Re-group an already-decoded, flat [`FieldMap`] against `spec` — the in-memory counterpart to
111/// [`decode_with_groups`]'s wire-level grouping (feature 011, FR-008). Needed because a FIXT 1.1
112/// application dictionary is only resolvable *after* this crate's decode step already ran (its
113/// selection depends on session state — the negotiated `ApplVerID` — that this crate has no
114/// visibility into; see `truefix-session`'s post-decode restructuring call site): `map` is first
115/// flattened back to a plain token stream (recursively, so a `Member::Group` entry from some
116/// *other* already-applied `spec` is flattened too, not left half-structured) and then re-grouped
117/// via [`decode_section_with_groups`] — the exact same boundary-detection algorithm
118/// `decode_with_groups` itself uses, so the two paths can never diverge (Constitution Principle
119/// IV). A tag `spec` doesn't recognize as a group ends up a plain field, exactly like
120/// `decode_with_groups`. Calling this on an already-correctly-structured map (relative to `spec`)
121/// is a safe, idempotent no-op (flattening then immediately re-grouping the same content the same
122/// way).
123pub fn restructure_groups(map: &mut FieldMap, spec: &dyn GroupSpec) -> Result<(), DecodeError> {
124    let mut tokens: Vec<Token> = Vec::new();
125    flatten_to_tokens(map, &mut tokens);
126    let mut rebuilt = FieldMap::new();
127    decode_section_with_groups(&tokens, spec, &mut rebuilt)?;
128    *map = rebuilt;
129    Ok(())
130}
131
132/// Recursively flatten `map`'s members back into wire-order tokens — the inverse of
133/// [`decode_section_with_groups`]/`build_group`, used by [`restructure_groups`] to get a uniform
134/// starting point regardless of whether `map` is currently fully flat or already partially
135/// structured by some other [`GroupSpec`].
136fn flatten_to_tokens(map: &FieldMap, out: &mut Vec<Token>) {
137    for member in map.members() {
138        match member {
139            crate::field_map::MemberRef::Field(f) => {
140                out.push((f.tag(), f.value_bytes().to_vec(), 0));
141            }
142            crate::field_map::MemberRef::Group {
143                count_tag,
144                entries,
145                declared_count,
146            } => {
147                // Mirrors `encode.rs`'s `group_count_to_emit`: the wire-declared count when one
148                // was recorded (even if it didn't match `entries.len()`), else the real entry
149                // count.
150                let count =
151                    declared_count.map_or_else(|| entries.len().to_string(), |n| n.to_string());
152                out.push((count_tag, count.into_bytes(), 0));
153                for entry in entries {
154                    flatten_to_tokens(entry, out);
155                }
156            }
157        }
158    }
159}
160
161/// Decode one wire section's (header/body/trailer) tokens into `out`, consuming a delimiter-led
162/// repeating group wherever `spec.group_of` matches a token's tag (nested groups recurse via
163/// `build_group`); every other token becomes a plain field.
164fn decode_section_with_groups(
165    tokens: &[Token],
166    spec: &dyn GroupSpec,
167    out: &mut FieldMap,
168) -> Result<(), DecodeError> {
169    let mut pos = 0usize;
170    while let Some(tok) = tokens.get(pos) {
171        let tag = tok.0;
172        if let Some((delimiter, members)) = spec.group_of(tag) {
173            let group = build_group(tokens, &mut pos, spec, tag, delimiter, members, 0)?;
174            out.add_group(group);
175        } else {
176            out.add_field(Field::new(tag, tok.1.clone()));
177            pos += 1;
178        }
179    }
180    Ok(())
181}
182
183const MAX_GROUP_NESTING_DEPTH: usize = 32;
184
185/// Consume a repeating group starting at the count token, returning the structured [`Group`].
186fn build_group(
187    tokens: &[Token],
188    pos: &mut usize,
189    spec: &dyn GroupSpec,
190    count_tag: u32,
191    delimiter: u32,
192    members: &[u32],
193    depth: usize,
194) -> Result<Group, DecodeError> {
195    if depth >= MAX_GROUP_NESTING_DEPTH {
196        return Err(DecodeError::GroupNestingTooDeep {
197            max: MAX_GROUP_NESTING_DEPTH,
198        });
199    }
200    // NEW-22 (feature 009): capture the wire-declared count before consuming its token, so a
201    // re-encode preserves it verbatim even when it doesn't match the actual entry count found
202    // below (previously silently "corrected" to `entries.len()` on encode, discarding the wire's
203    // own — possibly malformed — declaration).
204    let declared: Option<i64> = tokens
205        .get(*pos)
206        .and_then(|tok| core::str::from_utf8(&tok.1).ok())
207        .and_then(|s| s.parse().ok());
208    *pos += 1; // consume the NoXxx count field
209    let mut group = Group::new(count_tag);
210    while let Some(tok) = tokens.get(*pos) {
211        if tok.0 != delimiter {
212            break; // no more entries
213        }
214        let mut entry = FieldMap::new();
215        entry.add_field(Field::new(delimiter, tok.1.clone()));
216        *pos += 1;
217        while let Some(t) = tokens.get(*pos) {
218            let tag = t.0;
219            if tag == delimiter || !members.contains(&tag) {
220                break;
221            }
222            if let Some((d2, m2)) = spec.group_of(tag) {
223                let sub = build_group(tokens, pos, spec, tag, d2, m2, depth + 1)?;
224                entry.add_group(sub);
225            } else {
226                entry.add_field(Field::new(tag, t.1.clone()));
227                *pos += 1;
228            }
229        }
230        group.add_entry(entry);
231    }
232    if let Some(n) = declared {
233        group.set_declared_count(n);
234    }
235    Ok(group)
236}
237
238/// Tokenize `input` and verify BeginString/BodyLength/CheckSum.
239fn tokenize_validated(input: &[u8]) -> Result<Vec<Token>, DecodeError> {
240    if input.is_empty() {
241        return Err(DecodeError::Empty);
242    }
243
244    let fields = tokenize(input)?;
245
246    let first = fields.first().ok_or(DecodeError::Empty)?;
247    if first.0 != BEGIN_STRING {
248        return Err(DecodeError::MissingBeginString);
249    }
250    let second = fields.get(1).ok_or(DecodeError::InvalidBodyLength)?;
251    if second.0 != BODY_LENGTH {
252        return Err(DecodeError::InvalidBodyLength);
253    }
254    let declared_bl = parse_usize(&second.1).ok_or(DecodeError::InvalidBodyLength)?;
255    // T168/T169 (feature 009, NEW-04): `frame_length` (the normal transport read-loop path)
256    // rejects a declared BodyLength beyond `MAX_BODY_LEN` before ever buffering that many bytes —
257    // but `decode`/`Message::decode` is also a public API callable directly on arbitrary bytes,
258    // bypassing `frame_length` entirely. Without this check here too, a caller using `decode`
259    // directly (not through the transport read loop) could have to hold a many-times-larger
260    // buffer in memory than the transport path would ever have allowed, once actual_bl below is
261    // checked against it — the exact same resource-exhaustion shape `MAX_BODY_LEN` exists to
262    // prevent, just reachable through a different entry point.
263    if declared_bl > MAX_BODY_LEN {
264        return Err(DecodeError::BodyLengthTooLarge {
265            declared: declared_bl,
266            max: MAX_BODY_LEN,
267        });
268    }
269
270    // BUG-80/FR-049 (feature 007): MsgType (tag 35) must be present somewhere in the message --
271    // previously unchecked entirely, so a message missing it (e.g. a near-empty frame containing
272    // only BeginString/BodyLength/CheckSum) was accepted. Checked by presence, not position: a
273    // MsgType present but out of its normal third-field position is a separate, already-handled
274    // concern (`fields_out_of_order`/`ValidateFieldsOutOfOrder`, FR-006) -- not a basis for
275    // rejecting outright here.
276    if !fields.iter().any(|f| f.0 == MSG_TYPE) {
277        return Err(DecodeError::MissingMsgType);
278    }
279
280    let last = fields.last().ok_or(DecodeError::MissingChecksum)?;
281    if last.0 != CHECK_SUM {
282        return Err(DecodeError::MissingChecksum);
283    }
284    // NEW-155 (audit 006): require exactly three ASCII digits (the canonical `10=007` shape every
285    // real FIX encoder emits), not just "parses as a non-negative integer" -- `parse_u32` alone
286    // would accept e.g. `10=7`, which the stream-framing path (`frame_length`, assuming a fixed
287    // seven-byte `10=XXX<SOH>` trailer) can never actually produce, leaving `Message::decode`'s
288    // direct public entry point stricter-in-theory but not in practice.
289    if last.1.len() != 3 || !last.1.iter().all(u8::is_ascii_digit) {
290        return Err(DecodeError::MissingChecksum);
291    }
292    let declared_cs = parse_u32(&last.1).ok_or(DecodeError::MissingChecksum)?;
293
294    // Body starts at the third field (just after `9=..<SOH>`) and ends just before `10=`.
295    let cs_offset = last.2;
296    let body_start = fields.get(2).map_or(cs_offset, |f| f.2);
297    let actual_bl = cs_offset
298        .checked_sub(body_start)
299        .ok_or(DecodeError::InvalidBodyLength)?;
300    if actual_bl != declared_bl {
301        return Err(DecodeError::BodyLengthMismatch {
302            declared: declared_bl,
303            actual: actual_bl,
304        });
305    }
306
307    let pre = input.get(..cs_offset).ok_or(DecodeError::MissingChecksum)?;
308    // BUG-24/FR-032 (feature 007): same `u64`-accumulator fix as `encode.rs`'s mirror-image
309    // checksum sum — see its comment for the overflow/panic rationale. `frame_length`'s
310    // `MAX_BODY_LEN` cap makes this unreachable via the normal transport path, but `Message::decode`
311    // is also a public API callable directly with arbitrary bytes, bypassing that cap entirely.
312    let computed: u32 = (pre.iter().map(|&b| u64::from(b)).sum::<u64>() & 0xFF) as u32;
313    if computed != declared_cs {
314        return Err(DecodeError::ChecksumMismatch {
315            declared: declared_cs,
316            computed,
317        });
318    }
319
320    Ok(fields)
321}
322
323/// Split `input` into `tag=value<SOH>` tokens, honoring length-prefixed binary data fields.
324fn tokenize(input: &[u8]) -> Result<Vec<Token>, DecodeError> {
325    let mut tokens = Vec::new();
326    let mut pos = 0usize;
327    // When the previous field was a length field (e.g. RawDataLength/95), this holds its declared
328    // byte length together with the one tag (e.g. RawData/96) it's actually allowed to apply to
329    // (BUG-38/FR-020, feature 007) — a length only ever governs its own documented data-tag
330    // partner, never whatever tag happens to appear next. Applying it unconditionally to any
331    // following tag let a phantom length silently swallow an embedded SOH byte and an entire
332    // subsequent field into the wrong tag's "value", with the swallowed tag vanishing from the
333    // decoded message and no error raised at all.
334    let mut pending_data: Option<(u32, usize)> = None;
335
336    while pos < input.len() {
337        let start = pos;
338        let rest = input
339            .get(pos..)
340            .ok_or(DecodeError::Truncated { offset: start })?;
341
342        let eq_rel = memchr(rest, b'=').ok_or(DecodeError::GarbledField {
343            offset: start,
344            reason: "missing '=' in field",
345        })?;
346        let tag_bytes = rest.get(..eq_rel).unwrap_or(&[]);
347        let tag = parse_u32(tag_bytes).ok_or(DecodeError::InvalidTag { offset: start })?;
348        // NEW-21 (feature 009): tag 0 is not a valid FIX tag number (tags are strictly positive) --
349        // checked here rather than inside `parse_u32` itself, since that helper is shared with
350        // CheckSum(10) parsing, where a computed checksum of exactly `0` legitimately encodes as
351        // the string `"000"`.
352        if tag == 0 {
353            return Err(DecodeError::InvalidTag { offset: start });
354        }
355        let val_start = pos + eq_rel + 1;
356
357        let data_len = match pending_data.take() {
358            Some((expected_tag, len)) if expected_tag == tag => Some(len),
359            Some(_) => {
360                // BUG-38/FR-020: the length field's documented partner never showed up -- the tag
361                // that actually followed is something else entirely, a malformed/adversarial
362                // message.
363                return Err(DecodeError::GarbledField {
364                    offset: start,
365                    reason: "data field does not match its declared length field's partner tag",
366                });
367            }
368            None => None,
369        };
370
371        let (value, next) = if let Some(len) = data_len {
372            let val_end = val_start
373                .checked_add(len)
374                .ok_or(DecodeError::GarbledField {
375                    offset: start,
376                    reason: "data length overflow",
377                })?;
378            let v = input
379                .get(val_start..val_end)
380                .ok_or(DecodeError::Truncated { offset: val_start })?;
381            match input.get(val_end) {
382                Some(&b) if b == SOH => (v.to_vec(), val_end + 1),
383                _ => {
384                    return Err(DecodeError::GarbledField {
385                        offset: val_end,
386                        reason: "data field not terminated by SOH",
387                    });
388                }
389            }
390        } else {
391            let after = input
392                .get(val_start..)
393                .ok_or(DecodeError::Truncated { offset: val_start })?;
394            let soh_rel = memchr(after, SOH).ok_or(DecodeError::GarbledField {
395                offset: val_start,
396                reason: "field not terminated by SOH",
397            })?;
398            let v = after.get(..soh_rel).unwrap_or(&[]);
399            (v.to_vec(), val_start + soh_rel + 1)
400        };
401
402        if let Some(expected_tag) = data_field_for_length(tag) {
403            // BUG-49/FR-020 (feature 007): a non-numeric declared length is itself malformed --
404            // silently leaving `pending_data` unset (as before) let the following data field be
405            // misparsed as an ordinary SOH-delimited string field with no error at all.
406            let len = parse_usize(&value).ok_or(DecodeError::GarbledField {
407                offset: start,
408                reason: "data-length field value is not a valid non-negative integer",
409            })?;
410            pending_data = Some((expected_tag, len));
411        }
412
413        tokens.push((tag, value, start));
414        pos = next;
415    }
416
417    Ok(tokens)
418}
419
420/// T177/T178 (feature 009, NEW-35/36): a SIMD-accelerated search, replacing a hand-rolled
421/// `.iter().position()` byte-by-byte scan in this decode hot path.
422fn memchr(haystack: &[u8], needle: u8) -> Option<usize> {
423    memchr::memchr(needle, haystack)
424}
425
426fn parse_u32(bytes: &[u8]) -> Option<u32> {
427    core::str::from_utf8(bytes).ok()?.parse().ok()
428}
429
430fn parse_usize(bytes: &[u8]) -> Option<usize> {
431    core::str::from_utf8(bytes).ok()?.parse().ok()
432}