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}