truefix_core/error.rs
1//! Typed errors for the core layer. No code path panics (Constitution Principle I).
2
3use thiserror::Error;
4
5/// Error converting a field's raw value to a typed Rust value.
6#[derive(Debug, Clone, PartialEq, Eq, Error)]
7pub enum FieldError {
8 /// The raw value is not valid UTF-8.
9 #[error("field {tag}: value is not valid UTF-8")]
10 NotUtf8 {
11 /// The field tag.
12 tag: u32,
13 },
14 /// The value is not a valid integer.
15 #[error("field {tag}: expected an integer, got {value:?}")]
16 NotInt {
17 /// The field tag.
18 tag: u32,
19 /// The offending value.
20 value: String,
21 },
22 /// The value is not a valid decimal.
23 #[error("field {tag}: expected a decimal, got {value:?}")]
24 NotDecimal {
25 /// The field tag.
26 tag: u32,
27 /// The offending value.
28 value: String,
29 },
30 /// The value is not a single character.
31 #[error("field {tag}: expected a single character, got {value:?}")]
32 NotChar {
33 /// The field tag.
34 tag: u32,
35 /// The offending value.
36 value: String,
37 },
38 /// The value is not a boolean (`Y`/`N`).
39 #[error("field {tag}: expected 'Y' or 'N', got {value:?}")]
40 NotBool {
41 /// The field tag.
42 tag: u32,
43 /// The offending value.
44 value: String,
45 },
46 /// The value is not a valid UTC timestamp.
47 #[error("field {tag}: invalid UTC timestamp {value:?}")]
48 NotTimestamp {
49 /// The field tag.
50 tag: u32,
51 /// The offending value.
52 value: String,
53 },
54 /// The value is not a valid UTC date-only (`YYYYMMDD`).
55 #[error("field {tag}: invalid UTC date-only {value:?}")]
56 NotDateOnly {
57 /// The field tag.
58 tag: u32,
59 /// The offending value.
60 value: String,
61 },
62 /// The value is not a valid UTC time-only (`HH:MM:SS[.sss...]`).
63 #[error("field {tag}: invalid UTC time-only {value:?}")]
64 NotTimeOnly {
65 /// The field tag.
66 tag: u32,
67 /// The offending value.
68 value: String,
69 },
70}
71
72/// Error decoding raw bytes into a [`Message`](crate::Message).
73#[derive(Debug, Clone, PartialEq, Eq, Error)]
74pub enum DecodeError {
75 /// The input was empty.
76 #[error("empty input")]
77 Empty,
78 /// The message does not start with BeginString (tag 8).
79 #[error("message does not begin with BeginString (tag 8)")]
80 MissingBeginString,
81 /// BodyLength (tag 9) is missing or not a valid number.
82 #[error("missing or invalid BodyLength (tag 9)")]
83 InvalidBodyLength,
84 /// BodyLength (tag 9) is declared as exactly 0 (BUG-100/FR-014, feature 007) — every real FIX
85 /// message has a non-empty body (at minimum MsgType), matching QuickFIX/J (QFJ-903) and
86 /// QuickFIX/Go, which both reject this rather than framing an empty-body message. Distinct
87 /// from [`Self::InvalidBodyLength`] (missing/non-numeric) so the transport layer can treat it
88 /// like [`Self::BodyLengthTooLarge`] — closing the connection outright — rather than the
89 /// generic malformed-frame skip-and-resync recovery `InvalidBodyLength` gets.
90 #[error("declared BodyLength is 0")]
91 ZeroBodyLength,
92 /// BodyLength (tag 9) declares a frame larger than the configured/sane maximum (BUG-13/
93 /// FR-024, feature 006) — the connection must be closed instead of buffering unboundedly
94 /// while waiting for that much data to arrive (a memory-exhaustion DoS vector, reachable
95 /// pre- or post-Logon on any open acceptor port).
96 #[error("declared BodyLength {declared} exceeds the maximum frame size {max}")]
97 BodyLengthTooLarge {
98 /// Value declared in tag 9.
99 declared: usize,
100 /// The configured/sane maximum.
101 max: usize,
102 },
103 /// Dictionary-driven repeating-group nesting exceeded the defensive recursion limit.
104 #[error("repeating-group nesting exceeds the maximum depth {max}")]
105 GroupNestingTooDeep {
106 /// Maximum supported nesting depth.
107 max: usize,
108 },
109 /// The declared BodyLength does not match the actual body size.
110 #[error("BodyLength mismatch: declared {declared}, actual {actual}")]
111 BodyLengthMismatch {
112 /// Value declared in tag 9.
113 declared: usize,
114 /// Actual measured body length.
115 actual: usize,
116 },
117 /// The third field is not MsgType (tag 35) — BUG-80/FR-049 (feature 007): every real FIX
118 /// message has a MsgType immediately after BeginString/BodyLength (matching QuickFIX/J and
119 /// QuickFIX/Go, which both require it); a message missing it entirely was previously accepted.
120 #[error("message does not carry MsgType (tag 35) as its third field")]
121 MissingMsgType,
122 /// CheckSum (tag 10) is missing or not a valid number.
123 #[error("missing or invalid CheckSum (tag 10)")]
124 MissingChecksum,
125 /// The declared CheckSum does not match the computed one.
126 #[error("CheckSum mismatch: declared {declared:03}, computed {computed:03}")]
127 ChecksumMismatch {
128 /// Value declared in tag 10.
129 declared: u32,
130 /// Locally computed checksum.
131 computed: u32,
132 },
133 /// A field could not be parsed.
134 #[error("garbled field near byte {offset}: {reason}")]
135 GarbledField {
136 /// Byte offset where the problem was detected.
137 offset: usize,
138 /// Human-readable reason.
139 reason: &'static str,
140 },
141 /// The input ended before a field was complete.
142 #[error("truncated input near byte {offset}")]
143 Truncated {
144 /// Byte offset where truncation was detected.
145 offset: usize,
146 },
147 /// A tag number was not a valid positive integer.
148 #[error("invalid tag number near byte {offset}")]
149 InvalidTag {
150 /// Byte offset of the offending tag.
151 offset: usize,
152 },
153}
154
155/// A session-level rejection returned from an admin/logon callback — the engine emits a Reject
156/// (35=3). Typed, not stringly (Constitution Principle I; FR-016). `reason` is the numeric
157/// SessionRejectReason (tag 373).
158#[derive(Debug, Clone, PartialEq, Eq, Error)]
159#[error("reject (reason {reason}, ref tag {ref_tag:?}): {text:?}")]
160pub struct Reject {
161 /// SessionRejectReason (tag 373) numeric code.
162 pub reason: u32,
163 /// Optional referenced tag (RefTagID, 371).
164 pub ref_tag: Option<u32>,
165 /// Optional human-readable text (tag 58).
166 pub text: Option<String>,
167 /// Optional `SessionStatus` (tag 573) reason, propagated onto the outbound Logout when a
168 /// `from_admin` callback refuses a Logon (US10, FR-013).
169 pub session_status: Option<u16>,
170}
171
172/// Returned from an outbound callback to suppress a message: the engine does not send it and does
173/// not store it as sent (it consumes no outbound sequence number). FR-016.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
175#[error("do not send")]
176pub struct DoNotSend;
177
178/// A business-level rejection returned from an application callback — the engine emits a
179/// BusinessMessageReject (35=j). `reason` is the numeric BusinessRejectReason (tag 380). FR-016.
180#[derive(Debug, Clone, PartialEq, Eq, Error)]
181#[error("business reject (reason {reason}, ref tag {ref_tag:?}): {text:?}")]
182pub struct BusinessReject {
183 /// BusinessRejectReason (tag 380) numeric code.
184 pub reason: u32,
185 /// Optional referenced tag (RefTagID, 371).
186 pub ref_tag: Option<u32>,
187 /// Optional human-readable text (tag 58).
188 pub text: Option<String>,
189}