Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
590 changes: 531 additions & 59 deletions crates/hotfix-message/src/builder.rs

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions crates/hotfix-message/src/encoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ mod tests {
msg.set(fix44::PRICE, 150);
msg.set(fix44::ORDER_QTY, 60);

let config = Config { separator: b'|' };
let config = Config::with_separator(b'|');
let raw_message = msg.encode(&config)?;

let builder = MessageBuilder::new(Dictionary::fix44(), config)?;
Expand Down Expand Up @@ -146,7 +146,7 @@ mod tests {
party_2.store_field(Field::new(fix44::PARTY_ROLE.tag(), b"2".to_vec()));

msg.body.set_groups(vec![party_1, party_2])?;
let config = Config { separator: b'|' };
let config = Config::with_separator(b'|');
let raw_message = msg.encode(&config)?;

let builder = MessageBuilder::new(Dictionary::fix44(), config)?;
Expand Down
5 changes: 5 additions & 0 deletions crates/hotfix-message/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ pub enum ParserError {
InvalidComponent(String),
#[error("MsgType {0} is not a valid message type")]
InvalidMsgType(String),
#[error(
"required field (tag = {tag}) is missing{}",
match group_tag { Some(g) => format!(" from repeating group (group tag = {g})"), None => String::new() }
)]
RequiredFieldMissing { tag: u32, group_tag: Option<u32> },
#[error("malformed message: {0}")]
Malformed(String),
}
Expand Down
16 changes: 14 additions & 2 deletions crates/hotfix-message/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,16 +119,28 @@ impl Part for Message {
#[derive(Clone, Copy)]
pub struct Config {
pub(crate) separator: u8,
pub(crate) validate_user_defined_fields: bool,
}

impl Config {
pub const fn with_separator(separator: u8) -> Self {
Self { separator }
Self {
separator,
validate_user_defined_fields: true,
}
}

pub const fn validate_user_defined_fields(mut self, value: bool) -> Self {
self.validate_user_defined_fields = value;
self
}
}

impl Default for Config {
fn default() -> Self {
Self { separator: SOH }
Self {
separator: SOH,
validate_user_defined_fields: true,
}
}
}
1 change: 1 addition & 0 deletions crates/hotfix-message/src/parsed_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub enum InvalidReason {
InvalidOrderInGroup { tag: u32, group_tag: u32 },
InvalidComponent(String),
InvalidMsgType(String),
RequiredFieldMissing { tag: u32, group_tag: Option<u32> },
}

#[derive(Debug)]
Expand Down
142 changes: 141 additions & 1 deletion crates/hotfix/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,63 @@ pub struct SessionConfig {

/// The schedule configuration for the session
pub schedule: Option<ScheduleConfig>,

/// The validation configuration for the session
#[serde(default)]
pub validation: ValidationConfig,
}

#[derive(Clone, Debug, Deserialize)]
/// The configuration of validation rules.
pub struct ValidationConfig {
/// Specifies whether unknown user-defined tags (>= 5000) should cause the message to be rejected.
#[serde(default = "default_true")]
pub validate_user_defined_fields: bool,
}

impl ValidationConfig {
pub fn builder() -> VerificationConfigBuilder {
VerificationConfigBuilder::default()
}
}

impl Default for ValidationConfig {
fn default() -> Self {
VerificationConfigBuilder::default().build()
}
}

pub struct VerificationConfigBuilder {
validate_user_defined_fields: bool,
}

impl Default for VerificationConfigBuilder {
fn default() -> Self {
Self {
validate_user_defined_fields: true,
}
}
}

impl VerificationConfigBuilder {
pub fn new() -> Self {
Self::default()
}

pub fn validate_user_defined_fields(mut self, value: bool) -> Self {
self.validate_user_defined_fields = value;
self
}

pub fn build(self) -> ValidationConfig {
ValidationConfig {
validate_user_defined_fields: self.validate_user_defined_fields,
}
}
}

fn default_true() -> bool {
true
}

/// Errors that may occur when loading configuration.
Expand All @@ -128,7 +185,9 @@ pub enum ConfigError {

#[cfg(test)]
mod tests {
use crate::config::{Config, ConfigError, TlsConfig};
use crate::config::{
Config, ConfigError, TlsConfig, ValidationConfig, VerificationConfigBuilder,
};
use chrono::{NaiveTime, Weekday};
use std::io::Write;
use tempfile::NamedTempFile;
Expand Down Expand Up @@ -170,6 +229,7 @@ reset_on_logon = false
assert_eq!(session_config.tls_config, Some(expected_tls_config));
assert_eq!(session_config.reconnect_interval, 30);
assert_eq!(session_config.logon_timeout, 10);
assert!(session_config.validation.validate_user_defined_fields);
}

#[test]
Expand Down Expand Up @@ -439,6 +499,86 @@ end_day = "Friday"
assert_eq!(session_config.reconnect_interval, 15);
}

#[test]
fn test_verification_config_defaults_when_omitted() {
let config_contents = r#"
[[sessions]]
begin_string = "FIX.4.4"
sender_comp_id = "send-comp-id"
target_comp_id = "target-comp-id"
connection_port = 443
connection_host = "127.0.0.1"
heartbeat_interval = 30
"#;

let config: Config = toml::from_str(config_contents).unwrap();
let session_config = config.sessions.first().unwrap();

assert!(session_config.validation.validate_user_defined_fields);
}

#[test]
fn test_verification_config_can_disable_user_defined_field_validation() {
let config_contents = r#"
[[sessions]]
begin_string = "FIX.4.4"
sender_comp_id = "send-comp-id"
target_comp_id = "target-comp-id"
connection_port = 443
connection_host = "127.0.0.1"
heartbeat_interval = 30

[sessions.validation]
validate_user_defined_fields = false
"#;

let config: Config = toml::from_str(config_contents).unwrap();
let session_config = config.sessions.first().unwrap();

assert!(!session_config.validation.validate_user_defined_fields);
}

#[test]
fn test_validation_config_builder_produces_default() {
let config = ValidationConfig::builder().build();
assert!(config.validate_user_defined_fields);
}

#[test]
fn test_verification_config_builder_new_and_override() {
let enabled = VerificationConfigBuilder::new().build();
assert!(enabled.validate_user_defined_fields);

let disabled = VerificationConfigBuilder::new()
.validate_user_defined_fields(false)
.build();
assert!(!disabled.validate_user_defined_fields);
}

#[test]
fn test_validation_field_default_when_section_present_but_field_omitted() {
let config_contents = r#"
[[sessions]]
begin_string = "FIX.4.4"
sender_comp_id = "send-comp-id"
target_comp_id = "target-comp-id"
connection_port = 443
connection_host = "127.0.0.1"
heartbeat_interval = 30

[sessions.validation]
"#;

let config: Config = toml::from_str(config_contents).unwrap();
let session_config = config.sessions.first().unwrap();

// The [sessions.validation] table is present but omits the field, so the
// field-level serde default (`default_true`) supplies the value, unlike
// `test_verification_config_defaults_when_omitted` where the whole table
// is absent and `ValidationConfig::default()` is used instead.
assert!(session_config.validation.validate_user_defined_fields);
}

#[test]
fn test_load_from_path_success() {
let config_contents = r#"
Expand Down
1 change: 1 addition & 0 deletions crates/hotfix/src/initiator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ mod tests {
reconnect_interval: 1, // Short for tests
reset_on_logon: false,
schedule: None,
validation: Default::default(),
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/hotfix/src/message/verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ fn check_target_comp_id(
#[cfg(test)]
mod tests {
use super::{Message, SessionConfig, VerificationFlags, verify_message};
use crate::config::ValidationConfig;
use crate::message::sequence_reset::SequenceReset;
use crate::message::verification_issue::{CompIdType, MessageError, VerificationIssue};
use hotfix_message::field_types::Timestamp;
Expand All @@ -258,6 +259,7 @@ mod tests {
reconnect_interval: 0,
reset_on_logon: false,
schedule: None,
validation: ValidationConfig::default(),
}
}

Expand Down
26 changes: 25 additions & 1 deletion crates/hotfix/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ where
let schedule_check_timer = sleep(Duration::from_secs(SCHEDULE_CHECK_INTERVAL));

let dictionary = Self::get_data_dictionary(&config)?;
let message_config = MessageConfig::default();
let message_config = MessageConfig::default()
.validate_user_defined_fields(config.validation.validate_user_defined_fields);
let message_builder = MessageBuilder::new(dictionary, message_config)?;
let schedule = config.schedule.as_ref().try_into()?;
let ctx = SessionCtx {
Expand Down Expand Up @@ -184,6 +185,27 @@ where
}
}
}
InvalidReason::RequiredFieldMissing { tag, group_tag } => {
match message.header().get(MSG_SEQ_NUM) {
Ok(msg_seq_num) => {
let text = match group_tag {
Some(group_tag) => {
format!("required tag missing: {tag} (in group {group_tag})")
}
None => format!("required tag missing: {tag}"),
};
let reject = Reject::new(msg_seq_num)
.session_reject_reason(SessionRejectReason::RequiredTagMissing)
.text(&text);
self.send_message(reject)
.await
.with_send_context("reject for missing required field")?;
}
Err(err) => {
error!("failed to get message seq num: {:?}", err);
}
}
}
},
ParsedMessage::UnexpectedError(err) => {
error!("unexpected error: {:?}", err);
Expand Down Expand Up @@ -771,6 +793,7 @@ async fn run_session<App, Store>(
mod tests {
use super::*;
use crate::application::{InboundDecision, OutboundDecision};
use crate::config::ValidationConfig;
use crate::message::OutboundMessage;
use crate::store::{Result as StoreResult, StoreError};
use chrono::{DateTime, Datelike, NaiveDate, NaiveTime, TimeDelta, Timelike};
Expand Down Expand Up @@ -907,6 +930,7 @@ mod tests {
reconnect_interval: 30,
reset_on_logon: false,
schedule: None,
validation: ValidationConfig::default(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/hotfix/src/session/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ pub(crate) fn create_test_ctx(store: FakeMessageStore) -> SessionCtx<(), FakeMes
reconnect_interval: 30,
reset_on_logon: false,
schedule: None,
validation: Default::default(),
},
store,
application: (),
Expand Down
1 change: 1 addition & 0 deletions crates/hotfix/tests/connection_test_cases/connect_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ fn create_session_config(host: &str, port: u16, tls_config: Option<TlsConfig>) -
reconnect_interval: 30,
reset_on_logon: false,
schedule: None,
validation: Default::default(),
}
}

Expand Down
1 change: 1 addition & 0 deletions crates/hotfix/tests/session_test_cases/common/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ pub fn create_session_config() -> SessionConfig {
reconnect_interval: 30,
reset_on_logon: false,
schedule: None,
validation: Default::default(),
}
}

Expand Down
22 changes: 22 additions & 0 deletions crates/hotfix/tests/session_test_cases/common/test_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,28 @@ pub fn build_execution_report_with_sending_time_too_old(msg_seq_num: u64) -> Vec
msg.encode(&Config::default()).unwrap()
}

/// Builds a MassQuote (MsgType=i) whose single NoQuoteSets (296) entry omits the
/// required TotNoQuoteEntries (304) field.
///
/// The message is well-framed and parses successfully up to the group, where the
/// missing required in-group field makes it `Invalid(RequiredFieldMissing)`, so
/// the session must reject it with `RequiredTagMissing`.
pub fn build_mass_quote_with_missing_required_group_field(msg_seq_num: u64) -> Vec<u8> {
const SOH: char = '\u{1}';
// The NoQuoteSets entry carries only its delimiter QuoteSetID (302); the
// required TotNoQuoteEntries (304) is intentionally omitted.
let body = format!(
"35=i{SOH}49={COUNTERPARTY_COMP_ID}{SOH}56={OUR_COMP_ID}{SOH}\
34={msg_seq_num}{SOH}52=20250101-00:00:00{SOH}296=1{SOH}302=QSET1{SOH}"
);
let mut message = format!("8=FIX.4.4{SOH}9={}{SOH}", body.len()).into_bytes();
message.extend_from_slice(body.as_bytes());

let checksum = message.iter().fold(0u8, |acc, &b| acc.wrapping_add(b));
message.extend_from_slice(format!("10={checksum:03}{SOH}").as_bytes());
message
}

/// Replaces the value of a field in a raw FIX message.
pub fn replace_field_value(raw_message: &mut Vec<u8>, tag: u32, new_value: &[u8]) {
let tag_bytes = format!("{}=", tag).into_bytes();
Expand Down
Loading
Loading