Skip to main content

truefix_core/
framing.rs

1//! Extract complete FIX messages from a byte stream.
2
3use crate::error::DecodeError;
4use crate::tags::SOH;
5
6/// BUG-13/FR-024 (feature 006): a sane maximum declared `BodyLength` (tag 9). No legitimate FIX
7/// message (including large repeating-group or `RawData`-bearing ones) approaches this; a
8/// connection declaring more is closed instead of growing an unbounded read buffer while waiting
9/// for that many bytes to arrive — a straightforward memory-exhaustion DoS vector otherwise
10/// reachable pre- or post-Logon on any open acceptor port.
11pub const MAX_BODY_LEN: usize = 16 * 1024 * 1024;
12
13/// If `buf` begins with at least one complete FIX message, return `Ok(Some(total_len))` (the byte
14/// length of that message). Return `Ok(None)` if more bytes are needed, or `Err` if the start of
15/// the buffer cannot be framed (including a declared `BodyLength` beyond [`MAX_BODY_LEN`]).
16///
17/// Uses BodyLength (tag 9) to locate the body, then accounts for the fixed 7-byte
18/// `10=XXX<SOH>` CheckSum trailer.
19pub fn frame_length(buf: &[u8]) -> Result<Option<usize>, DecodeError> {
20    let Some(soh1) = find(buf, SOH) else {
21        return Ok(None);
22    };
23    if buf.get(0..2) != Some(b"8=") {
24        return Err(DecodeError::MissingBeginString);
25    }
26    // BUG-79/FR-048 (feature 007): beyond the leading `8=`, the BeginString *value* itself
27    // (between `8=` and the first SOH) must match the `FIX.\d.\d`/`FIXT.\d.\d` shape QFJ
28    // requires — previously any bytes at all following `8=` were accepted as a plausible frame
29    // start, so non-FIX data (or a garbled `BeginString`) would still be framed.
30    let begin_string_value = buf.get(2..soh1).unwrap_or(&[]);
31    if !looks_like_fix_begin_string(begin_string_value) {
32        return Err(DecodeError::MissingBeginString);
33    }
34
35    let after8 = buf.get(soh1 + 1..).unwrap_or(&[]);
36    let Some(soh2_rel) = find(after8, SOH) else {
37        return Ok(None);
38    };
39    let bl_field = after8.get(..soh2_rel).unwrap_or(&[]);
40    if bl_field.get(0..2) != Some(b"9=") {
41        return Err(DecodeError::InvalidBodyLength);
42    }
43    let bl_val = bl_field.get(2..).unwrap_or(&[]);
44    let body_len: usize = core::str::from_utf8(bl_val)
45        .ok()
46        .and_then(|s| s.parse().ok())
47        .ok_or(DecodeError::InvalidBodyLength)?;
48    // BUG-100/FR-014 (feature 007): a declared BodyLength of 0 is malformed -- every real FIX
49    // message has a non-empty body (at minimum MsgType), matching QuickFIX/J (QFJ-903) and
50    // QuickFIX/Go, which both reject it rather than framing an empty-body message.
51    if body_len == 0 {
52        return Err(DecodeError::ZeroBodyLength);
53    }
54    if body_len > MAX_BODY_LEN {
55        return Err(DecodeError::BodyLengthTooLarge {
56            declared: body_len,
57            max: MAX_BODY_LEN,
58        });
59    }
60
61    // BUG-23/FR-032 (feature 007): `checked_add` throughout, not bare `+` — `body_len` is already
62    // capped by `MAX_BODY_LEN` above, and `soh1`/`soh2_rel` are bounded by `buf.len()`, so this
63    // can't overflow in any reachable scenario today; but a bare `+` would silently wrap (in
64    // release builds) if that ever stopped being true, drained the wrong number of bytes, and
65    // desynchronized the stream for a remote, unauthenticated peer — defense in depth, not just a
66    // theoretical concern, per the original audit finding.
67    let overflow = || DecodeError::BodyLengthTooLarge {
68        declared: body_len,
69        max: MAX_BODY_LEN,
70    };
71    let body_start = soh1
72        .checked_add(1)
73        .and_then(|v| v.checked_add(soh2_rel))
74        .and_then(|v| v.checked_add(1))
75        .ok_or_else(overflow)?;
76    let total = body_start
77        .checked_add(body_len)
78        .and_then(|v| v.checked_add(7))
79        .ok_or_else(overflow)?;
80    if buf.len() >= total {
81        // BUG-46/FR-047 (feature 007): confirm the bytes at the computed checksum position
82        // actually look like `10=` before trusting `total` — a wrong `BodyLength` (still numeric,
83        // still within `MAX_BODY_LEN`, but not matching the message's real length) would otherwise
84        // make `total` point at the wrong offset entirely; the caller would drain the wrong number
85        // of bytes and desynchronize the stream for good, rather than failing this one frame and
86        // resynchronizing (the existing malformed-frame recovery this `Err` return already
87        // triggers one layer up, in `truefix-transport`'s `classify_buffered`) — matching QFJ's
88        // `FIXMessageDecoder`, which verifies `10=???<SOH>` at the expected position.
89        if buf.get(total - 7..total - 4) != Some(b"10=") {
90            return Err(DecodeError::InvalidBodyLength);
91        }
92        // NEW-107 (audit 006): also confirm the byte immediately after the three checksum digits
93        // is SOH, completing the `10=\d{3}\x01` trailer shape QFJ's `FIXMessageDecoder` verifies
94        // in full -- the `10=` prefix check above alone would still accept a frame whose
95        // `BodyLength` and checksum digits happen to be numeric/well-positioned but whose trailing
96        // byte isn't SOH, which `tokenize_validated` would then misparse as the start of the next
97        // field, desynchronizing the stream.
98        if buf.get(total - 1) != Some(&SOH) {
99            return Err(DecodeError::InvalidBodyLength);
100        }
101        Ok(Some(total))
102    } else {
103        Ok(None)
104    }
105}
106
107/// T177/T178 (feature 009, NEW-35/36): a SIMD-accelerated search, replacing a hand-rolled
108/// `.iter().position()` byte-by-byte scan in this decode/framing hot path.
109fn find(haystack: &[u8], needle: u8) -> Option<usize> {
110    memchr::memchr(needle, haystack)
111}
112
113/// Whether `value` (the BeginString field's value, e.g. `b"FIX.4.4"`) matches the
114/// `FIX.<digit>.<digit>` / `FIXT.<digit>.<digit>` shape (BUG-79/FR-048, feature 007). Only the
115/// first 3 characters after the `FIX.`/`FIXT.` prefix are checked (matching QFJ's own pattern);
116/// any trailing suffix (e.g. `SP2`) is accepted as-is.
117fn looks_like_fix_begin_string(value: &[u8]) -> bool {
118    let Ok(s) = core::str::from_utf8(value) else {
119        return false;
120    };
121    let Some(rest) = s.strip_prefix("FIXT.").or_else(|| s.strip_prefix("FIX.")) else {
122        return false;
123    };
124    let bytes = rest.as_bytes();
125    matches!(bytes.get(0..3), Some([d1, b'.', d2]) if d1.is_ascii_digit() && d2.is_ascii_digit())
126}