Skip to content

Commit 5482ea0

Browse files
authored
feat: forbid unwraps and expects in main hotfix crate (#272)
* Fix hotfix-web changelog to remove duplicate entry * Disallow unwraps and expects in the core hotfix crate * Introduce new ConnectionError to replace unwraps in the code to create new connections * Replace expects with logging in reader code * Introduce new SessionSendError to replace expects in session ref * Replace expects in config loading code with custom error type * Fix sample application following tightening of error handling * Handle unwraps in session message types * Fix clippy warnings in integration tests * Add integration tests for TLS connections * Refactor structure of integration tests * Add test cases for establishing TCP/TLS connections * Add test cases for loading configs from the file system
1 parent 8b46ed0 commit 5482ea0

35 files changed

Lines changed: 1195 additions & 106 deletions

Cargo.lock

Lines changed: 53 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/hotfix-web/CHANGELOG.md

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
### Other
1313

14-
- release ([#268](https://github.com/Validus-Risk-Management/hotfix/pull/268))
15-
16-
## [0.1.5](https://github.com/Validus-Risk-Management/hotfix/compare/hotfix-web-v0.1.4...hotfix-web-v0.1.5) - 2026-01-20
17-
18-
### Other
19-
2014
- updated the following local packages: hotfix, hotfix-web-ui
2115

2216
## [0.1.4](https://github.com/Validus-Risk-Management/hotfix/compare/hotfix-web-v0.1.3...hotfix-web-v0.1.4) - 2025-12-09
@@ -41,7 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4135

4236
### Added
4337

44-
- support reconnects in shutdowns initiated via CLI tool ([#250](https://github.com/Validus-Risk-Management/hotfix/pull/250))
38+
- support reconnects in shutdowns initiated via CLI
39+
tool ([#250](https://github.com/Validus-Risk-Management/hotfix/pull/250))
4540

4641
## [0.1.0](https://github.com/Validus-Risk-Management/hotfix/releases/tag/hotfix-web-v0.1.0) - 2025-11-26
4742

crates/hotfix/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,5 +47,8 @@ uuid = { workspace = true, features = ["v4"] }
4747
[dev-dependencies]
4848
hotfix-message = { version = "0.2.8", path = "../hotfix-message", features = ["fix44", "utils-chrono"] }
4949

50+
rcgen = "0.13"
51+
rustls = { workspace = true, features = ["ring"] }
52+
tempfile = "3"
5053
testcontainers = { workspace = true }
5154
tokio = { workspace = true, features = ["test-util"] }

crates/hotfix/clippy.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
allow-expect-in-tests = true
2+
allow-panic-in-tests = true
3+
allow-unwrap-in-tests = true

crates/hotfix/src/config.rs

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ pub struct Config {
1919

2020
impl Config {
2121
/// Load a [Config] from a `toml` file.
22-
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Self {
23-
let config_str = fs::read_to_string(path).expect("to be able to load config");
24-
toml::from_str::<Self>(&config_str).expect("to be able to parse config")
22+
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
23+
let config_str = fs::read_to_string(path)?;
24+
let config = toml::from_str::<Self>(&config_str)?;
25+
Ok(config)
2526
}
2627
}
2728

@@ -115,10 +116,22 @@ pub struct SessionConfig {
115116
pub schedule: Option<ScheduleConfig>,
116117
}
117118

119+
/// Errors that may occur when loading configuration.
120+
#[derive(Debug, thiserror::Error)]
121+
pub enum ConfigError {
122+
#[error("failed to read config file")]
123+
Io(#[from] std::io::Error),
124+
125+
#[error("failed to parse config")]
126+
Parse(#[from] toml::de::Error),
127+
}
128+
118129
#[cfg(test)]
119130
mod tests {
120-
use crate::config::{Config, TlsConfig};
131+
use crate::config::{Config, ConfigError, TlsConfig};
121132
use chrono::{NaiveTime, Weekday};
133+
use std::io::Write;
134+
use tempfile::NamedTempFile;
122135

123136
#[test]
124137
fn test_simple_config() {
@@ -425,4 +438,50 @@ end_day = "Friday"
425438
let session_config = config.sessions.first().unwrap();
426439
assert_eq!(session_config.reconnect_interval, 15);
427440
}
441+
442+
#[test]
443+
fn test_load_from_path_success() {
444+
let config_contents = r#"
445+
[[sessions]]
446+
begin_string = "FIX.4.4"
447+
sender_comp_id = "sender"
448+
target_comp_id = "target"
449+
connection_host = "127.0.0.1"
450+
connection_port = 9876
451+
heartbeat_interval = 30
452+
"#;
453+
454+
let mut temp_file = NamedTempFile::new().unwrap();
455+
temp_file.write_all(config_contents.as_bytes()).unwrap();
456+
457+
let config = Config::load_from_path(temp_file.path()).unwrap();
458+
assert_eq!(config.sessions.len(), 1);
459+
460+
let session = config.sessions.first().unwrap();
461+
assert_eq!(session.begin_string, "FIX.4.4");
462+
assert_eq!(session.sender_comp_id, "sender");
463+
assert_eq!(session.target_comp_id, "target");
464+
assert_eq!(session.connection_host, "127.0.0.1");
465+
assert_eq!(session.connection_port, 9876);
466+
assert_eq!(session.heartbeat_interval, 30);
467+
}
468+
469+
#[test]
470+
fn test_load_from_path_missing_file() {
471+
let result = Config::load_from_path("/nonexistent/path/to/config.toml");
472+
assert!(result.is_err());
473+
assert!(matches!(result.unwrap_err(), ConfigError::Io(_)));
474+
}
475+
476+
#[test]
477+
fn test_load_from_path_invalid_toml() {
478+
let invalid_toml = "this is not valid toml {{{{";
479+
480+
let mut temp_file = NamedTempFile::new().unwrap();
481+
temp_file.write_all(invalid_toml.as_bytes()).unwrap();
482+
483+
let result = Config::load_from_path(temp_file.path());
484+
assert!(result.is_err());
485+
assert!(matches!(result.unwrap_err(), ConfigError::Parse(_)));
486+
}
428487
}

crates/hotfix/src/initiator.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,21 @@ async fn establish_connection<Outbound: OutboundMessage>(
9494
completion_tx: watch::Sender<bool>,
9595
) {
9696
loop {
97-
session_ref.await_active_session_time().await;
97+
if session_ref.await_active_session_time().await.is_err() {
98+
warn!("session task terminated when checking active session time");
99+
break;
100+
}
98101

99102
match connect(&config, session_ref.clone()).await {
100103
Ok(conn) => {
101-
session_ref.register_writer(conn.get_writer()).await;
104+
if session_ref
105+
.register_writer(conn.get_writer())
106+
.await
107+
.is_err()
108+
{
109+
warn!("session task terminated when trying to register writer");
110+
break;
111+
};
102112
conn.run_until_disconnect().await;
103113
warn!("session connection dropped, attempting to reconnect");
104114
}
@@ -108,9 +118,18 @@ async fn establish_connection<Outbound: OutboundMessage>(
108118
}
109119
};
110120

111-
if !session_ref.should_reconnect().await {
112-
warn!("session indicated we shouldn't reconnect");
113-
break;
121+
match session_ref.should_reconnect().await {
122+
Ok(false) => {
123+
warn!("session indicated we shouldn't reconnect");
124+
break;
125+
}
126+
Ok(true) => {
127+
debug!("session indicated we should reconnect");
128+
}
129+
Err(_) => {
130+
warn!("session task terminated when making decision to reconnect");
131+
break;
132+
}
114133
}
115134
let reconnect_interval = config.reconnect_interval;
116135
debug!("waiting for {reconnect_interval} seconds before attempting to reconnect");

crates/hotfix/src/lib.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
//!
2121
//! Check out the [examples](https://github.com/Validus-Risk-Management/hotfix/tree/main/examples)
2222
//! to get started.
23+
24+
#![deny(clippy::expect_used)]
25+
#![deny(clippy::panic)]
26+
#![deny(clippy::unwrap_used)]
27+
2328
pub mod application;
2429
pub mod config;
2530
pub(crate) mod error;

crates/hotfix/src/message/reject.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,11 @@ impl OutboundMessage for Reject {
7878
impl InboundMessage for Reject {
7979
fn parse(message: &Message) -> Self {
8080
Self {
81-
ref_seq_num: message.get(REF_SEQ_NUM).unwrap(),
81+
// TODO: how do we handle errors in parsing messages?
82+
#[allow(clippy::expect_used)]
83+
ref_seq_num: message
84+
.get(REF_SEQ_NUM)
85+
.expect("ref_seq_num should be present"),
8286
ref_tag_id: message.get(REF_TAG_ID).ok(),
8387
ref_msg_type: message.get(REF_MSG_TYPE).ok(),
8488
session_reject_reason: message.get(SESSION_REJECT_REASON).ok(),

crates/hotfix/src/message/sequence_reset.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ impl OutboundMessage for SequenceReset {
1616
fn write(&self, msg: &mut Message) {
1717
msg.set(GAP_FILL_FLAG, self.gap_fill);
1818
msg.set(NEW_SEQ_NO, self.new_seq_no);
19-
let sending_time: Timestamp = msg.header().get(SENDING_TIME).unwrap();
19+
#[allow(clippy::expect_used)]
20+
let sending_time: Timestamp = msg.header().get(SENDING_TIME).expect(
21+
"sending time should always be present due to previously having validated message",
22+
);
2023
msg.header_mut().set(ORIG_SENDING_TIME, sending_time);
2124
msg.header_mut().set(POSS_DUP_FLAG, true);
2225
}

crates/hotfix/src/session/session_ref.rs

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use anyhow::Result;
2+
use thiserror::Error;
23
use tokio::sync::{mpsc, oneshot};
34
use tracing::debug;
45

@@ -42,44 +43,63 @@ impl<Outbound: OutboundMessage> InternalSessionRef<Outbound> {
4243
})
4344
}
4445

45-
pub async fn register_writer(&self, writer: WriterRef) {
46+
pub async fn register_writer(&self, writer: WriterRef) -> Result<(), SessionGone> {
4647
self.event_sender
4748
.send(SessionEvent::Connected(writer))
48-
.await
49-
.expect("be able to register writer");
49+
.await?;
50+
51+
Ok(())
5052
}
5153

52-
pub async fn new_fix_message_received(&self, msg: RawFixMessage) {
54+
pub async fn new_fix_message_received(&self, msg: RawFixMessage) -> Result<(), SessionGone> {
5355
self.event_sender
5456
.send(SessionEvent::FixMessageReceived(msg))
55-
.await
56-
.expect("be able to receive message");
57+
.await?;
58+
59+
Ok(())
5760
}
5861

59-
pub async fn disconnect(&self, reason: String) {
62+
pub async fn disconnect(&self, reason: String) -> Result<(), SessionGone> {
6063
self.event_sender
6164
.send(SessionEvent::Disconnected(reason))
62-
.await
63-
.expect("be able to send disconnect");
65+
.await?;
66+
67+
Ok(())
6468
}
6569

66-
pub async fn should_reconnect(&self) -> bool {
70+
pub async fn should_reconnect(&self) -> Result<bool, SessionGone> {
6771
let (sender, receiver) = oneshot::channel();
6872
self.event_sender
6973
.send(SessionEvent::ShouldReconnect(sender))
70-
.await
71-
.unwrap();
72-
receiver.await.expect("to receive a response")
74+
.await?;
75+
Ok(receiver.await?)
7376
}
7477

75-
pub async fn await_active_session_time(&self) {
78+
pub async fn await_active_session_time(&self) -> Result<(), SessionGone> {
7679
debug!("awaiting active session time");
7780
let (sender, receiver) = oneshot::channel::<AwaitingActiveSessionResponse>();
7881
self.event_sender
7982
.send(SessionEvent::AwaitingActiveSession(sender))
80-
.await
81-
.unwrap();
82-
receiver.await.expect("to receive a response");
83+
.await?;
84+
receiver.await?;
85+
8386
debug!("resuming connection as session is active");
87+
Ok(())
88+
}
89+
}
90+
91+
#[derive(Debug, Error)]
92+
#[error("session task terminated")]
93+
pub struct SessionGone(String);
94+
95+
impl From<mpsc::error::SendError<SessionEvent>> for SessionGone {
96+
fn from(err: mpsc::error::SendError<SessionEvent>) -> Self {
97+
Self(err.to_string())
98+
}
99+
}
100+
101+
impl From<oneshot::error::RecvError> for SessionGone {
102+
fn from(err: oneshot::error::RecvError) -> Self {
103+
Self(err.to_string())
84104
}
85105
}

0 commit comments

Comments
 (0)