use crate::error::EngineError;
use ironfix_core::message::MsgType;
use ironfix_tagvalue::SOH;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutboundField {
Raw {
tag: u32,
value: Vec<u8>,
},
Data {
length_tag: u32,
data_tag: u32,
value: Vec<u8>,
},
}
pub const RESERVED_TAGS: [u32; 13] = [8, 9, 10, 34, 35, 43, 49, 50, 52, 56, 57, 122, 1128];
const SENSITIVE_TAGS: [u32; 3] = [96, 554, 925];
fn admin_required_tags(msg_type: &MsgType) -> &'static [u32] {
match msg_type.as_str() {
"A" => &[98, 108], "1" => &[112], "2" => &[7, 16], "4" => &[36], _ => &[],
}
}
pub(crate) fn check_sendable(message: &OutboundMessage) -> Result<(), EngineError> {
if message.msg_type().is_admin() {
return Err(EngineError::ReservedMsgType {
msg_type: message.msg_type().as_str().to_string(),
});
}
check_body(message)
}
pub(crate) fn check_body(message: &OutboundMessage) -> Result<(), EngineError> {
for field in message.fields() {
match field {
OutboundField::Raw { tag, value } => {
check_body_tag(*tag)?;
if value.is_empty() {
return Err(EngineError::InvalidField {
tag: *tag,
reason: "value is empty; a FIX field carries at least one byte".to_string(),
});
}
if value.contains(&SOH) {
return Err(EngineError::InvalidField {
tag: *tag,
reason:
"value contains the SOH delimiter, which would terminate the field \
early and inject the remainder as further fields"
.to_string(),
});
}
}
OutboundField::Data {
length_tag,
data_tag,
..
} => {
check_body_tag(*length_tag)?;
check_body_tag(*data_tag)?;
}
}
}
for &required in admin_required_tags(message.msg_type()) {
if message.get(required).is_none() {
return Err(EngineError::MissingRequiredField {
msg_type: message.msg_type().as_str().to_string(),
tag: required,
});
}
}
Ok(())
}
fn check_body_tag(tag: u32) -> Result<(), EngineError> {
if tag == 0 {
return Err(EngineError::InvalidField {
tag,
reason: "0 is not a legal FIX field tag: tags are positive integers starting at 1"
.to_string(),
});
}
if RESERVED_TAGS.contains(&tag) {
return Err(EngineError::ReservedTag { tag });
}
Ok(())
}
#[derive(Clone)]
pub struct OutboundMessage {
msg_type: MsgType,
fields: Vec<OutboundField>,
}
impl std::fmt::Debug for OutboundMessage {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
struct RedactedFields<'a>(&'a [OutboundField]);
impl std::fmt::Debug for RedactedFields<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut list = f.debug_list();
for field in self.0 {
match field {
OutboundField::Raw { tag, value } => {
if SENSITIVE_TAGS.contains(tag) {
list.entry(&(tag, "<redacted>"));
} else {
list.entry(&(tag, &String::from_utf8_lossy(value)));
}
}
OutboundField::Data {
length_tag,
data_tag,
value,
} => {
if SENSITIVE_TAGS.contains(data_tag) {
list.entry(&(length_tag, data_tag, "<redacted>"));
} else {
list.entry(&(
length_tag,
data_tag,
&String::from_utf8_lossy(value),
));
}
}
}
}
list.finish()
}
}
f.debug_struct("OutboundMessage")
.field("msg_type", &self.msg_type)
.field("fields", &RedactedFields(&self.fields))
.finish()
}
}
impl OutboundMessage {
#[must_use]
pub fn new(msg_type: MsgType) -> Self {
Self {
msg_type,
fields: Vec::new(),
}
}
#[must_use]
pub fn msg_type(&self) -> &MsgType {
&self.msg_type
}
pub fn push_raw(&mut self, tag: u32, value: impl Into<Vec<u8>>) -> &mut Self {
self.fields.push(OutboundField::Raw {
tag,
value: value.into(),
});
self
}
pub fn push_data(
&mut self,
length_tag: u32,
data_tag: u32,
value: impl Into<Vec<u8>>,
) -> &mut Self {
self.fields.push(OutboundField::Data {
length_tag,
data_tag,
value: value.into(),
});
self
}
pub fn push_str(&mut self, tag: u32, value: &str) -> &mut Self {
self.push_raw(tag, value.as_bytes().to_vec())
}
pub fn push_int(&mut self, tag: u32, value: i64) -> &mut Self {
self.push_raw(tag, value.to_string().into_bytes())
}
pub fn push_uint(&mut self, tag: u32, value: u64) -> &mut Self {
self.push_raw(tag, value.to_string().into_bytes())
}
pub fn push_char(&mut self, tag: u32, value: char) -> &mut Self {
let mut buf = [0u8; 4];
let s = value.encode_utf8(&mut buf);
self.push_raw(tag, s.as_bytes().to_vec())
}
pub fn push_bool(&mut self, tag: u32, value: bool) -> &mut Self {
self.push_raw(tag, if value { b"Y".to_vec() } else { b"N".to_vec() })
}
#[must_use]
pub fn get(&self, tag: u32) -> Option<&[u8]> {
self.fields.iter().find_map(|field| match field {
OutboundField::Raw {
tag: field_tag,
value,
} if *field_tag == tag => Some(value.as_slice()),
OutboundField::Data {
data_tag, value, ..
} if *data_tag == tag => Some(value.as_slice()),
_ => None,
})
}
pub fn remove(&mut self, tag: u32) -> usize {
let before = self.fields.len();
self.fields.retain(|field| match field {
OutboundField::Raw { tag: field_tag, .. } => *field_tag != tag,
OutboundField::Data {
length_tag,
data_tag,
..
} => *length_tag != tag && *data_tag != tag,
});
before - self.fields.len()
}
#[must_use]
pub fn fields(&self) -> &[OutboundField] {
&self.fields
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_outbound_message_fields_in_order() {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(11, "ORDER-1")
.push_char(54, '1')
.push_uint(38, 100)
.push_int(9999, -5)
.push_bool(59, true);
assert_eq!(msg.msg_type(), &MsgType::NewOrderSingle);
let fields = msg.fields();
assert_eq!(
fields[0],
OutboundField::Raw {
tag: 11,
value: b"ORDER-1".to_vec()
}
);
assert_eq!(
fields[1],
OutboundField::Raw {
tag: 54,
value: b"1".to_vec()
}
);
assert_eq!(
fields[2],
OutboundField::Raw {
tag: 38,
value: b"100".to_vec()
}
);
assert_eq!(
fields[3],
OutboundField::Raw {
tag: 9999,
value: b"-5".to_vec()
}
);
assert_eq!(
fields[4],
OutboundField::Raw {
tag: 59,
value: b"Y".to_vec()
}
);
}
#[test]
fn test_outbound_message_push_data_records_a_counted_pair() {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(11, "ORDER-1")
.push_data(95, 96, b"a\x01b".to_vec())
.push_str(58, "after");
let fields = msg.fields();
assert_eq!(
fields[1],
OutboundField::Data {
length_tag: 95,
data_tag: 96,
value: b"a\x01b".to_vec()
}
);
assert_eq!(
fields[0],
OutboundField::Raw {
tag: 11,
value: b"ORDER-1".to_vec()
}
);
assert_eq!(
fields[2],
OutboundField::Raw {
tag: 58,
value: b"after".to_vec()
}
);
}
#[test]
fn test_outbound_message_get_and_remove_round_trip() {
let mut msg = OutboundMessage::new(MsgType::Logon);
msg.push_str(553, "trader").push_str(554, "secret");
assert_eq!(msg.get(553), Some(&b"trader"[..]));
assert_eq!(msg.get(9999), None);
assert_eq!(msg.remove(554), 1);
assert_eq!(msg.get(554), None);
assert_eq!(msg.remove(554), 0);
assert_eq!(msg.fields().len(), 1);
}
#[test]
fn test_check_sendable_application_message_is_accepted() {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(11, "ORDER-1").push_char(54, '1');
assert!(check_sendable(&msg).is_ok());
}
#[test]
fn test_check_sendable_admin_msg_type_is_reserved() {
for msg_type in [
MsgType::Logon,
MsgType::Logout,
MsgType::SequenceReset,
MsgType::Heartbeat,
MsgType::TestRequest,
MsgType::ResendRequest,
MsgType::Reject,
] {
let expected = msg_type.as_str().to_string();
let msg = OutboundMessage::new(msg_type);
match check_sendable(&msg) {
Err(EngineError::ReservedMsgType { msg_type }) => assert_eq!(msg_type, expected),
other => panic!("35={expected} must be refused, got {other:?}"),
}
}
}
#[test]
fn test_check_sendable_reserved_tag_is_refused() {
for tag in RESERVED_TAGS {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(tag, "1");
match check_sendable(&msg) {
Err(EngineError::ReservedTag { tag: actual }) => assert_eq!(actual, tag),
other => panic!("tag {tag} must be refused, got {other:?}"),
}
}
}
#[test]
fn test_check_body_zero_tag_is_refused() {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(0, "x");
match check_body(&msg) {
Err(EngineError::InvalidField { tag: 0, .. }) => {}
other => panic!("tag 0 must be refused, got {other:?}"),
}
}
#[test]
fn test_check_body_empty_value_is_refused() {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(11, "");
match check_body(&msg) {
Err(EngineError::InvalidField { tag: 11, .. }) => {}
other => panic!("an empty value must be refused, got {other:?}"),
}
}
#[test]
fn test_check_body_soh_in_value_is_refused_without_quoting_it() {
let mut msg = OutboundMessage::new(MsgType::NewOrderSingle);
msg.push_str(58, "text\x0149=EVIL");
match check_body(&msg) {
Err(EngineError::InvalidField { tag: 58, reason }) => {
assert!(
!reason.contains("EVIL"),
"the rejection must not quote the value, got {reason}"
);
}
other => panic!("an embedded SOH must be refused, got {other:?}"),
}
}
#[test]
fn test_check_body_admin_missing_required_field_is_refused() {
for (msg_type, required) in [
(MsgType::Logon, 98u32),
(MsgType::Logon, 108),
(MsgType::TestRequest, 112),
(MsgType::ResendRequest, 7),
(MsgType::ResendRequest, 16),
(MsgType::SequenceReset, 36),
] {
let mut msg = OutboundMessage::new(msg_type.clone());
for &tag in admin_required_tags(&msg_type) {
msg.push_str(tag, "1");
}
assert_eq!(msg.remove(required), 1);
match check_body(&msg) {
Err(EngineError::MissingRequiredField {
msg_type: reported,
tag,
}) => {
assert_eq!(reported, msg_type.as_str());
assert_eq!(tag, required);
}
other => {
panic!("{msg_type:?} without tag {required} must be refused, got {other:?}")
}
}
}
}
#[test]
fn test_check_body_admin_with_all_required_fields_is_accepted() {
let mut logon = OutboundMessage::new(MsgType::Logon);
logon.push_uint(98, 0).push_uint(108, 30);
assert!(check_body(&logon).is_ok());
}
#[test]
fn test_check_body_application_message_has_no_required_admin_fields() {
let msg = OutboundMessage::new(MsgType::NewOrderSingle);
assert!(check_body(&msg).is_ok());
}
#[test]
fn test_outbound_message_debug_redacts_credentials() {
let mut msg = OutboundMessage::new(MsgType::Logon);
msg.push_str(553, "trader")
.push_str(554, "s3cret-password")
.push_str(925, "n3w-password")
.push_str(96, "raw-secret-bytes");
let rendered = format!("{msg:?}");
assert!(rendered.contains("trader"), "got {rendered}");
for secret in ["s3cret-password", "n3w-password", "raw-secret-bytes"] {
assert!(
!rendered.contains(secret),
"Debug must not print {secret}, got {rendered}"
);
}
assert!(rendered.contains("<redacted>"), "got {rendered}");
}
}