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}