Skip to main content

ironfix_engine/
outbound.rs

1/******************************************************************************
2   Author: Joaquín Béjar García
3   Email: jb@taunais.com
4   Date: 14/7/26
5******************************************************************************/
6
7//! Outbound message builder and the rules an outbound body must satisfy.
8//!
9//! [`OutboundMessage`] is the **pre-encoding form of every message the engine
10//! sends** — application messages handed to
11//! [`Connection::send`](crate::Connection::send) and the administrative
12//! messages the session layer builds for itself. It is what
13//! [`Application::to_app`](crate::Application::to_app) and
14//! [`Application::to_admin`](crate::Application::to_admin) receive, and it is
15//! what the engine encodes, so a mutation made in a callback reaches the wire.
16//!
17//! # What a body may not carry
18//!
19//! The engine stamps the standard header and trailer itself. A body field that
20//! repeats one of those tags produces a frame with two occurrences of it, which
21//! a conforming counterparty rejects or misparses, so [`RESERVED_TAGS`] are
22//! refused at the public boundary rather than duplicated. Administrative
23//! MsgTypes are refused there too: Logon, Logout, SequenceReset and the rest
24//! belong to the session state machine, and one emitted behind its back leaves
25//! the engine's phase tracking describing a session that no longer exists.
26
27use crate::error::EngineError;
28use ironfix_core::message::MsgType;
29use ironfix_tagvalue::SOH;
30
31/// A single body field of an [`OutboundMessage`], in the form the encoder
32/// needs to stamp it.
33///
34/// Most fields are [`OutboundField::Raw`] and are written verbatim. A FIX
35/// `DATA` field (`RawData`/96, `Signature`/89, the `Encoded*` family) legally
36/// contains the SOH delimiter and `=`, so it is decodable only alongside its
37/// paired `LENGTH` field; those fields are [`OutboundField::Data`] and are
38/// emitted as a counted pair so the payload's SOH bytes are never read as field
39/// terminators.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum OutboundField {
42    /// An ordinary field, written as `tag=value<SOH>`.
43    Raw {
44        /// The field tag number.
45        tag: u32,
46        /// The field value bytes.
47        value: Vec<u8>,
48    },
49    /// A counted `LENGTH`/`DATA` pair, written as
50    /// `length_tag=<value.len()><SOH>data_tag=<value><SOH>`.
51    Data {
52        /// The `LENGTH` field tag (e.g., 95 `RawDataLength`).
53        length_tag: u32,
54        /// The paired `DATA` field tag (e.g., 96 `RawData`).
55        data_tag: u32,
56        /// The raw payload, which may carry SOH and `=`.
57        value: Vec<u8>,
58    },
59}
60
61/// Tags the engine stamps into the standard header or trailer itself.
62///
63/// In order: `BeginString` (8), `BodyLength` (9), `CheckSum` (10),
64/// `MsgSeqNum` (34), `MsgType` (35), `PossDupFlag` (43), `SenderCompID` (49),
65/// `SenderSubID` (50), `SendingTime` (52), `TargetCompID` (56),
66/// `TargetSubID` (57), `OrigSendingTime` (122) and `ApplVerID` (1128).
67///
68/// A body carrying any of them is refused with [`EngineError::ReservedTag`].
69pub const RESERVED_TAGS: [u32; 13] = [8, 9, 10, 34, 35, 43, 49, 50, 52, 56, 57, 122, 1128];
70
71/// Tags whose values may carry a credential and must never appear in a log.
72///
73/// In order: `RawData` (96), `Password` (554) and `NewPassword` (925). The
74/// [`Debug`] impl of [`OutboundMessage`] replaces their values with a redaction
75/// marker so that logging the message — the object a `to_admin` callback stamps
76/// a password onto — cannot leak a secret.
77const SENSITIVE_TAGS: [u32; 3] = [96, 554, 925];
78
79/// Body tags an administrative MsgType may not go out without.
80///
81/// The engine's [`MessageFactory`](crate::wire::MessageFactory) always builds
82/// these in, but `to_admin` receives the message by `&mut` and can
83/// [`remove`](OutboundMessage::remove) them. A Logon stripped of `HeartBtInt`
84/// (108) or a TestRequest stripped of `TestReqID` (112) is malformed, and every
85/// conforming counterparty rejects it — so the drop is caught here rather than
86/// emitted. Header and trailer tags are covered separately by [`RESERVED_TAGS`].
87///
88/// The lookup is by wire code so a [`MsgType::Custom`] holding an administrative
89/// code is protected the same way. `Password` (554) and the rest are optional
90/// on the wire and are not listed. Returns an empty slice for every application
91/// MsgType and for administrative types with no required body field (Heartbeat,
92/// Logout).
93fn admin_required_tags(msg_type: &MsgType) -> &'static [u32] {
94    match msg_type.as_str() {
95        "A" => &[98, 108], // Logon: EncryptMethod, HeartBtInt.
96        "1" => &[112],     // TestRequest: TestReqID.
97        "2" => &[7, 16],   // ResendRequest: BeginSeqNo, EndSeqNo.
98        "4" => &[36],      // SequenceReset: NewSeqNo.
99        _ => &[],
100    }
101}
102
103/// Checks that a message may be sent on the application path.
104///
105/// Enforces the two rules the public boundary owns: the MsgType must not be
106/// administrative, and the body must not repeat a tag the engine stamps.
107///
108/// # Errors
109/// [`EngineError::ReservedMsgType`] for an administrative MsgType, otherwise
110/// whatever [`check_body`] reports.
111pub(crate) fn check_sendable(message: &OutboundMessage) -> Result<(), EngineError> {
112    if message.msg_type().is_admin() {
113        return Err(EngineError::ReservedMsgType {
114            msg_type: message.msg_type().as_str().to_string(),
115        });
116    }
117    check_body(message)
118}
119
120/// Checks that every body field has a legal wire form, is not one the engine
121/// stamps itself, and — for an administrative MsgType — that no field the
122/// message cannot go out without has been dropped.
123///
124/// Run again after `to_admin` / `to_app`, because a callback can append fields
125/// the caller never wrote and can [`remove`](OutboundMessage::remove) fields the
126/// session layer built in.
127///
128/// # Errors
129/// [`EngineError::ReservedTag`] for a tag in [`RESERVED_TAGS`],
130/// [`EngineError::InvalidField`] for tag `0`, an empty value, or a value
131/// carrying the SOH delimiter — which would terminate its own field early and
132/// let the remainder inject further fields into the frame — and
133/// [`EngineError::MissingRequiredField`] when an administrative message no
134/// longer carries a tag in [`admin_required_tags`].
135///
136/// The reported reason never quotes the value: an outbound Logon body carries
137/// `Password` (554) and `NewPassword` (925).
138pub(crate) fn check_body(message: &OutboundMessage) -> Result<(), EngineError> {
139    for field in message.fields() {
140        match field {
141            OutboundField::Raw { tag, value } => {
142                check_body_tag(*tag)?;
143                if value.is_empty() {
144                    return Err(EngineError::InvalidField {
145                        tag: *tag,
146                        reason: "value is empty; a FIX field carries at least one byte".to_string(),
147                    });
148                }
149                if value.contains(&SOH) {
150                    return Err(EngineError::InvalidField {
151                        tag: *tag,
152                        reason:
153                            "value contains the SOH delimiter, which would terminate the field \
154                                 early and inject the remainder as further fields"
155                                .to_string(),
156                    });
157                }
158            }
159            // A DATA field legally carries the SOH delimiter and `=`: it is
160            // framed as a counted LENGTH/DATA pair, so the payload's bytes are
161            // never read as field terminators and are not checked for SOH here.
162            // Both tags are still the engine's to refuse if reserved or zero.
163            OutboundField::Data {
164                length_tag,
165                data_tag,
166                ..
167            } => {
168                check_body_tag(*length_tag)?;
169                check_body_tag(*data_tag)?;
170            }
171        }
172    }
173    // An administrative message that lost a required body field — a `to_admin`
174    // that removed HeartBtInt (108) from a Logon, say — must not reach the wire:
175    // the counterparty rejects it and the session's own handshake stalls.
176    for &required in admin_required_tags(message.msg_type()) {
177        if message.get(required).is_none() {
178            return Err(EngineError::MissingRequiredField {
179                msg_type: message.msg_type().as_str().to_string(),
180                tag: required,
181            });
182        }
183    }
184    Ok(())
185}
186
187/// Refuses a body tag that is `0` (no such FIX tag) or one the engine stamps
188/// into the standard header or trailer itself.
189///
190/// # Errors
191/// [`EngineError::InvalidField`] for tag `0`, [`EngineError::ReservedTag`] for a
192/// tag in [`RESERVED_TAGS`].
193fn check_body_tag(tag: u32) -> Result<(), EngineError> {
194    if tag == 0 {
195        return Err(EngineError::InvalidField {
196            tag,
197            reason: "0 is not a legal FIX field tag: tags are positive integers starting at 1"
198                .to_string(),
199        });
200    }
201    if RESERVED_TAGS.contains(&tag) {
202        return Err(EngineError::ReservedTag { tag });
203    }
204    Ok(())
205}
206
207/// An outbound message: a MsgType plus ordered body fields.
208///
209/// The engine stamps the standard header (BeginString, BodyLength, MsgType,
210/// SenderCompID, TargetCompID, MsgSeqNum, SendingTime) and the trailer when
211/// the message is sent, so the builder only carries body fields. Fields are
212/// encoded in insertion order.
213///
214/// # Mutating one in a callback
215///
216/// [`Application::to_admin`](crate::Application::to_admin) and
217/// [`Application::to_app`](crate::Application::to_app) receive this type by
218/// `&mut` **before** the header is stamped and before a sequence number is
219/// spent, so a field added there is encoded into the frame that goes out. The
220/// canonical use is stamping `Username` (553) and `Password` (554) onto the
221/// outbound Logon.
222///
223/// The header fields are not visible here — in particular `MsgSeqNum` (34) is
224/// not yet decided when the callback runs, which is what lets a rejected
225/// message cost nothing.
226///
227/// # Constraints
228///
229/// [`OutboundMessage::new`] accepts any MsgType so the engine can build its own
230/// administrative messages with it; the restriction to application MsgTypes is
231/// enforced by [`Connection::send`](crate::Connection::send), which is the
232/// public path. See [`RESERVED_TAGS`] for the tags a body may not carry.
233///
234/// # Credentials never print
235///
236/// This is the object a `to_admin` callback stamps a `Password` (554) onto, so
237/// its [`Debug`] impl is hand-written to redact the values of `Password` (554),
238/// `NewPassword` (925) and `RawData` (96) — a derived `Debug` would let a single
239/// `tracing::debug!(?message)` leak a secret. Every other field is shown as
240/// normal.
241#[derive(Clone)]
242pub struct OutboundMessage {
243    /// Message type (tag 35).
244    msg_type: MsgType,
245    /// Body fields in insertion order.
246    fields: Vec<OutboundField>,
247}
248
249impl std::fmt::Debug for OutboundMessage {
250    /// Redacts the value of every credential-bearing field so the type cannot
251    /// leak a `Password` (554), `NewPassword` (925) or `RawData` (96) through a
252    /// log line.
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        /// Renders the body list, redacting the values of sensitive tags.
255        struct RedactedFields<'a>(&'a [OutboundField]);
256        impl std::fmt::Debug for RedactedFields<'_> {
257            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258                let mut list = f.debug_list();
259                for field in self.0 {
260                    match field {
261                        OutboundField::Raw { tag, value } => {
262                            if SENSITIVE_TAGS.contains(tag) {
263                                list.entry(&(tag, "<redacted>"));
264                            } else {
265                                list.entry(&(tag, &String::from_utf8_lossy(value)));
266                            }
267                        }
268                        OutboundField::Data {
269                            length_tag,
270                            data_tag,
271                            value,
272                        } => {
273                            if SENSITIVE_TAGS.contains(data_tag) {
274                                list.entry(&(length_tag, data_tag, "<redacted>"));
275                            } else {
276                                list.entry(&(
277                                    length_tag,
278                                    data_tag,
279                                    &String::from_utf8_lossy(value),
280                                ));
281                            }
282                        }
283                    }
284                }
285                list.finish()
286            }
287        }
288        f.debug_struct("OutboundMessage")
289            .field("msg_type", &self.msg_type)
290            .field("fields", &RedactedFields(&self.fields))
291            .finish()
292    }
293}
294
295impl OutboundMessage {
296    /// Creates a new outbound message of the given type.
297    ///
298    /// # Arguments
299    /// * `msg_type` - The message type (tag 35)
300    #[must_use]
301    pub fn new(msg_type: MsgType) -> Self {
302        Self {
303            msg_type,
304            fields: Vec::new(),
305        }
306    }
307
308    /// Returns the message type.
309    #[must_use]
310    pub fn msg_type(&self) -> &MsgType {
311        &self.msg_type
312    }
313
314    /// Appends a field with raw bytes.
315    ///
316    /// # Arguments
317    /// * `tag` - The field tag number
318    /// * `value` - The field value bytes
319    pub fn push_raw(&mut self, tag: u32, value: impl Into<Vec<u8>>) -> &mut Self {
320        self.fields.push(OutboundField::Raw {
321            tag,
322            value: value.into(),
323        });
324        self
325    }
326
327    /// Appends a counted `LENGTH`/`DATA` field pair.
328    ///
329    /// A FIX `DATA` field (`RawData`/96, `Signature`/89, the `Encoded*` family)
330    /// legally contains SOH and `=`; it is decodable only because its paired
331    /// `LENGTH` field declares its byte count. The engine derives that count
332    /// from `value` when the message is encoded, so the two cannot disagree and
333    /// the payload's SOH bytes are never read as field terminators. Use this
334    /// instead of [`OutboundMessage::push_raw`] for any `DATA` field —
335    /// `push_raw` refuses a `LENGTH` or `DATA` tag precisely because writing one
336    /// half alone would corrupt the frame.
337    ///
338    /// # Arguments
339    /// * `length_tag` - The `LENGTH` field tag (e.g., 95 `RawDataLength`)
340    /// * `data_tag` - The paired `DATA` field tag (e.g., 96 `RawData`)
341    /// * `value` - The raw payload
342    pub fn push_data(
343        &mut self,
344        length_tag: u32,
345        data_tag: u32,
346        value: impl Into<Vec<u8>>,
347    ) -> &mut Self {
348        self.fields.push(OutboundField::Data {
349            length_tag,
350            data_tag,
351            value: value.into(),
352        });
353        self
354    }
355
356    /// Appends a field with a string value.
357    ///
358    /// # Arguments
359    /// * `tag` - The field tag number
360    /// * `value` - The field value
361    pub fn push_str(&mut self, tag: u32, value: &str) -> &mut Self {
362        self.push_raw(tag, value.as_bytes().to_vec())
363    }
364
365    /// Appends a field with an integer value.
366    ///
367    /// # Arguments
368    /// * `tag` - The field tag number
369    /// * `value` - The field value
370    pub fn push_int(&mut self, tag: u32, value: i64) -> &mut Self {
371        self.push_raw(tag, value.to_string().into_bytes())
372    }
373
374    /// Appends a field with an unsigned integer value.
375    ///
376    /// # Arguments
377    /// * `tag` - The field tag number
378    /// * `value` - The field value
379    pub fn push_uint(&mut self, tag: u32, value: u64) -> &mut Self {
380        self.push_raw(tag, value.to_string().into_bytes())
381    }
382
383    /// Appends a field with a single character value.
384    ///
385    /// # Arguments
386    /// * `tag` - The field tag number
387    /// * `value` - The field value
388    pub fn push_char(&mut self, tag: u32, value: char) -> &mut Self {
389        let mut buf = [0u8; 4];
390        let s = value.encode_utf8(&mut buf);
391        self.push_raw(tag, s.as_bytes().to_vec())
392    }
393
394    /// Appends a field with a boolean value (Y/N).
395    ///
396    /// # Arguments
397    /// * `tag` - The field tag number
398    /// * `value` - The field value
399    pub fn push_bool(&mut self, tag: u32, value: bool) -> &mut Self {
400        self.push_raw(tag, if value { b"Y".to_vec() } else { b"N".to_vec() })
401    }
402
403    /// Returns the value of the first field with `tag`, if present.
404    ///
405    /// # Arguments
406    /// * `tag` - The field tag number
407    #[must_use]
408    pub fn get(&self, tag: u32) -> Option<&[u8]> {
409        self.fields.iter().find_map(|field| match field {
410            OutboundField::Raw {
411                tag: field_tag,
412                value,
413            } if *field_tag == tag => Some(value.as_slice()),
414            OutboundField::Data {
415                data_tag, value, ..
416            } if *data_tag == tag => Some(value.as_slice()),
417            _ => None,
418        })
419    }
420
421    /// Removes every field carrying `tag`, returning how many were removed.
422    ///
423    /// A [`OutboundField::Data`] pair is removed when either its `LENGTH` or its
424    /// `DATA` tag matches, so the counted halves never survive apart.
425    ///
426    /// # Arguments
427    /// * `tag` - The field tag number
428    pub fn remove(&mut self, tag: u32) -> usize {
429        let before = self.fields.len();
430        self.fields.retain(|field| match field {
431            OutboundField::Raw { tag: field_tag, .. } => *field_tag != tag,
432            OutboundField::Data {
433                length_tag,
434                data_tag,
435                ..
436            } => *length_tag != tag && *data_tag != tag,
437        });
438        before - self.fields.len()
439    }
440
441    /// Returns the body fields in insertion order.
442    #[must_use]
443    pub fn fields(&self) -> &[OutboundField] {
444        &self.fields
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_outbound_message_fields_in_order() {
454        let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
455        msg.push_str(11, "ORDER-1")
456            .push_char(54, '1')
457            .push_uint(38, 100)
458            .push_int(9999, -5)
459            .push_bool(59, true);
460
461        assert_eq!(msg.msg_type(), &MsgType::NewOrderSingle);
462        let fields = msg.fields();
463        assert_eq!(
464            fields[0],
465            OutboundField::Raw {
466                tag: 11,
467                value: b"ORDER-1".to_vec()
468            }
469        );
470        assert_eq!(
471            fields[1],
472            OutboundField::Raw {
473                tag: 54,
474                value: b"1".to_vec()
475            }
476        );
477        assert_eq!(
478            fields[2],
479            OutboundField::Raw {
480                tag: 38,
481                value: b"100".to_vec()
482            }
483        );
484        assert_eq!(
485            fields[3],
486            OutboundField::Raw {
487                tag: 9999,
488                value: b"-5".to_vec()
489            }
490        );
491        assert_eq!(
492            fields[4],
493            OutboundField::Raw {
494                tag: 59,
495                value: b"Y".to_vec()
496            }
497        );
498    }
499
500    #[test]
501    fn test_outbound_message_push_data_records_a_counted_pair() {
502        // A RawData payload carrying an embedded SOH is expressed as a
503        // LENGTH/DATA pair, interleaved in insertion order with ordinary fields.
504        let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
505        msg.push_str(11, "ORDER-1")
506            .push_data(95, 96, b"a\x01b".to_vec())
507            .push_str(58, "after");
508
509        let fields = msg.fields();
510        assert_eq!(
511            fields[1],
512            OutboundField::Data {
513                length_tag: 95,
514                data_tag: 96,
515                value: b"a\x01b".to_vec()
516            }
517        );
518        // Insertion order is preserved across raw and data fields.
519        assert_eq!(
520            fields[0],
521            OutboundField::Raw {
522                tag: 11,
523                value: b"ORDER-1".to_vec()
524            }
525        );
526        assert_eq!(
527            fields[2],
528            OutboundField::Raw {
529                tag: 58,
530                value: b"after".to_vec()
531            }
532        );
533    }
534
535    #[test]
536    fn test_outbound_message_get_and_remove_round_trip() {
537        let mut msg = OutboundMessage::new(MsgType::Logon);
538        msg.push_str(553, "trader").push_str(554, "secret");
539
540        assert_eq!(msg.get(553), Some(&b"trader"[..]));
541        assert_eq!(msg.get(9999), None);
542        assert_eq!(msg.remove(554), 1);
543        assert_eq!(msg.get(554), None);
544        assert_eq!(msg.remove(554), 0);
545        assert_eq!(msg.fields().len(), 1);
546    }
547
548    #[test]
549    fn test_check_sendable_application_message_is_accepted() {
550        let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
551        msg.push_str(11, "ORDER-1").push_char(54, '1');
552        assert!(check_sendable(&msg).is_ok());
553    }
554
555    #[test]
556    fn test_check_sendable_admin_msg_type_is_reserved() {
557        // Every administrative MsgType belongs to the session layer: one sent
558        // behind the state machine's back leaves the engine's phase tracking
559        // describing a session that no longer exists.
560        for msg_type in [
561            MsgType::Logon,
562            MsgType::Logout,
563            MsgType::SequenceReset,
564            MsgType::Heartbeat,
565            MsgType::TestRequest,
566            MsgType::ResendRequest,
567            MsgType::Reject,
568        ] {
569            let expected = msg_type.as_str().to_string();
570            let msg = OutboundMessage::new(msg_type);
571            match check_sendable(&msg) {
572                Err(EngineError::ReservedMsgType { msg_type }) => assert_eq!(msg_type, expected),
573                other => panic!("35={expected} must be refused, got {other:?}"),
574            }
575        }
576    }
577
578    #[test]
579    fn test_check_sendable_reserved_tag_is_refused() {
580        for tag in RESERVED_TAGS {
581            let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
582            msg.push_str(tag, "1");
583            match check_sendable(&msg) {
584                Err(EngineError::ReservedTag { tag: actual }) => assert_eq!(actual, tag),
585                other => panic!("tag {tag} must be refused, got {other:?}"),
586            }
587        }
588    }
589
590    #[test]
591    fn test_check_body_zero_tag_is_refused() {
592        let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
593        msg.push_str(0, "x");
594        match check_body(&msg) {
595            Err(EngineError::InvalidField { tag: 0, .. }) => {}
596            other => panic!("tag 0 must be refused, got {other:?}"),
597        }
598    }
599
600    #[test]
601    fn test_check_body_empty_value_is_refused() {
602        let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
603        msg.push_str(11, "");
604        match check_body(&msg) {
605            Err(EngineError::InvalidField { tag: 11, .. }) => {}
606            other => panic!("an empty value must be refused, got {other:?}"),
607        }
608    }
609
610    #[test]
611    fn test_check_body_soh_in_value_is_refused_without_quoting_it() {
612        let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
613        msg.push_str(58, "text\x0149=EVIL");
614        match check_body(&msg) {
615            Err(EngineError::InvalidField { tag: 58, reason }) => {
616                assert!(
617                    !reason.contains("EVIL"),
618                    "the rejection must not quote the value, got {reason}"
619                );
620            }
621            other => panic!("an embedded SOH must be refused, got {other:?}"),
622        }
623    }
624
625    #[test]
626    fn test_check_body_admin_missing_required_field_is_refused() {
627        // The (MsgType, required-tag) pairs a `to_admin` callback must not be
628        // able to strip and still have the message reach the wire.
629        for (msg_type, required) in [
630            (MsgType::Logon, 98u32),
631            (MsgType::Logon, 108),
632            (MsgType::TestRequest, 112),
633            (MsgType::ResendRequest, 7),
634            (MsgType::ResendRequest, 16),
635            (MsgType::SequenceReset, 36),
636        ] {
637            let mut msg = OutboundMessage::new(msg_type.clone());
638            // Populate every required tag, then drop just the one under test.
639            for &tag in admin_required_tags(&msg_type) {
640                msg.push_str(tag, "1");
641            }
642            assert_eq!(msg.remove(required), 1);
643            match check_body(&msg) {
644                Err(EngineError::MissingRequiredField {
645                    msg_type: reported,
646                    tag,
647                }) => {
648                    assert_eq!(reported, msg_type.as_str());
649                    assert_eq!(tag, required);
650                }
651                other => {
652                    panic!("{msg_type:?} without tag {required} must be refused, got {other:?}")
653                }
654            }
655        }
656    }
657
658    #[test]
659    fn test_check_body_admin_with_all_required_fields_is_accepted() {
660        let mut logon = OutboundMessage::new(MsgType::Logon);
661        logon.push_uint(98, 0).push_uint(108, 30);
662        assert!(check_body(&logon).is_ok());
663    }
664
665    #[test]
666    fn test_check_body_application_message_has_no_required_admin_fields() {
667        // An application MsgType places no admin-required-field demand, so an
668        // empty body is fine here (per-field checks aside).
669        let msg = OutboundMessage::new(MsgType::NewOrderSingle);
670        assert!(check_body(&msg).is_ok());
671    }
672
673    #[test]
674    fn test_outbound_message_debug_redacts_credentials() {
675        let mut msg = OutboundMessage::new(MsgType::Logon);
676        msg.push_str(553, "trader")
677            .push_str(554, "s3cret-password")
678            .push_str(925, "n3w-password")
679            .push_str(96, "raw-secret-bytes");
680        let rendered = format!("{msg:?}");
681
682        // The username is not a credential and stays visible.
683        assert!(rendered.contains("trader"), "got {rendered}");
684        // None of the credential values may appear.
685        for secret in ["s3cret-password", "n3w-password", "raw-secret-bytes"] {
686            assert!(
687                !rendered.contains(secret),
688                "Debug must not print {secret}, got {rendered}"
689            );
690        }
691        assert!(rendered.contains("<redacted>"), "got {rendered}");
692    }
693}