hl7_2_mllp/lib.rs
1//! MLLP — the Minimal Lower Layer Protocol, which is how HL7 v2 messages
2//! actually cross a network.
3//!
4//! A TCP stream is bytes without edges, and an HL7 v2 message carries no
5//! length prefix and no self-delimiting syntax, so a receiver reading a
6//! socket cannot tell where one message stops and the next begins. MLLP is
7//! the three-byte answer to that, and nothing more: wrap each message in a
8//! start block and an end block.
9//!
10//! ```text
11//! <VT> message <FS><CR>
12//! 0x0B 0x1C 0x0D
13//! ```
14//!
15//! That is the whole protocol. It is deliberately minimal — no length, no
16//! checksum, no session, no negotiation, no encryption — and everything
17//! else people expect of a messaging layer is either HL7's own
18//! acknowledgement messages ([`ack`]), TLS underneath, or the caller's
19//! business.
20//!
21//! ## What is here
22//!
23//! | | |
24//! |---|---|
25//! | [`encode`], [`decode`] | one frame in hand |
26//! | [`Framer`] | a byte stream, where frames arrive split across reads or several to a read — this is the one a socket needs |
27//! | [`Transport`], [`IoTransport`] | frames over anything that reads and writes bytes |
28//! | [`ack`] | turning a received message into the acknowledgement HL7 expects back |
29//!
30//! ```
31//! use hl7_2_mllp as mllp;
32//!
33//! let message = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|99|P|2.5\rPID|1";
34//! let frame = mllp::encode(message.as_bytes());
35//!
36//! assert_eq!(frame[0], mllp::START_BLOCK);
37//! assert_eq!(&frame[frame.len() - 2..], &[mllp::END_BLOCK, mllp::CARRIAGE_RETURN]);
38//! assert_eq!(mllp::decode(&frame)?, message.as_bytes());
39//! # Ok::<(), mllp::Error>(())
40//! ```
41//!
42//! ## Reading a socket
43//!
44//! ```no_run
45//! use hl7_2_mllp::{IoTransport, Transport};
46//! use std::net::TcpListener;
47//!
48//! let listener = TcpListener::bind("127.0.0.1:2575")?;
49//! for stream in listener.incoming() {
50//! let mut transport = IoTransport::new(stream?);
51//! while let Some(message) = transport.receive()? {
52//! // ... process the message, then answer ...
53//! # let _ = &message;
54//! }
55//! }
56//! # Ok::<(), std::io::Error>(())
57//! ```
58//!
59//! See `examples/tcp_listener.rs` for a complete server that also
60//! acknowledges, and `examples/tcp_sender.rs` for the other end.
61//!
62//! ## Strictness
63//!
64//! By default this crate is strict: a frame must start with `<VT>`, end
65//! with `<FS><CR>`, and contain neither block character in between. Real
66//! senders are not always strict — a missing `<CR>` after `<FS>`, and stray
67//! bytes between frames, are the two common sins — so the `noncompliance`
68//! feature relaxes exactly those two and nothing else.
69//!
70//! It is off by default because a receiver that quietly accepts malformed
71//! framing is how a truncated message becomes a clinical record. Turn it on
72//! when you have a specific sender that needs it, and know which of the two
73//! you are forgiving.
74//!
75//! `spec/index.md` in the repository is the normative specification of
76//! everything above; where this documentation and that document disagree,
77//! that document is right.
78
79#![warn(missing_docs, clippy::pedantic)]
80
81#[cfg(feature = "ack")]
82pub mod ack;
83mod framer;
84mod transport;
85
86#[cfg(feature = "ack")]
87pub use ack::AckCode;
88pub use framer::{Framer, Tolerance};
89pub use transport::{IoTransport, Transport};
90
91/// The HL7 v2 crate acknowledgements are built with, re-exported so callers
92/// can name [`hl7_2::Message`] without adding their own dependency.
93#[cfg(feature = "ack")]
94pub use hl7_2;
95
96use std::fmt;
97
98/// `<VT>`, the start block: a vertical tab, `0x0B`. Begins every frame.
99pub const START_BLOCK: u8 = 0x0B;
100
101/// `<FS>`, the end block: a file separator, `0x1C`. Ends every frame,
102/// followed by [`CARRIAGE_RETURN`].
103pub const END_BLOCK: u8 = 0x1C;
104
105/// `<CR>`, the carriage return `0x0D` that follows [`END_BLOCK`].
106///
107/// It is also HL7 v2's segment terminator, which is why a message's own
108/// trailing `\r` — if the sender wrote one — sits harmlessly before the
109/// `<FS>` rather than being mistaken for this one.
110pub const CARRIAGE_RETURN: u8 = 0x0D;
111
112/// The default cap on how much a [`Framer`] buffers while waiting for an
113/// end block: 16 MiB.
114///
115/// MLLP has no length field, so a sender that never sends `<FS>` — or a
116/// peer speaking some other protocol entirely, or a port scanner — would
117/// otherwise grow the buffer until the process dies. A real HL7 v2 message
118/// is kilobytes; megabytes only when it carries a document in OBX-5.
119pub const DEFAULT_LIMIT: usize = 16 * 1024 * 1024;
120
121/// Wrap a payload in a frame: `<VT>` + payload + `<FS><CR>`.
122///
123/// The payload is neither inspected nor modified. MLLP has no escaping — it
124/// cannot, having defined no escape character — so a payload containing
125/// `<VT>` or `<FS>` cannot be framed unambiguously. [`is_framable`] is the
126/// check; HL7 v2 text never contains either byte.
127#[must_use]
128pub fn encode(payload: &[u8]) -> Vec<u8> {
129 let mut frame = Vec::with_capacity(payload.len() + 3);
130 frame.push(START_BLOCK);
131 frame.extend_from_slice(payload);
132 frame.push(END_BLOCK);
133 frame.push(CARRIAGE_RETURN);
134 frame
135}
136
137/// Whether `payload` can be framed unambiguously — that is, whether it is
138/// free of the two bytes MLLP reserves.
139///
140/// [`encode`] does not check, because for HL7 v2 text the answer is always
141/// yes and a caller framing something else already knows what they are
142/// doing. Check when the payload came from somewhere you do not control.
143#[must_use]
144pub fn is_framable(payload: &[u8]) -> bool {
145 !payload
146 .iter()
147 .any(|&byte| byte == START_BLOCK || byte == END_BLOCK)
148}
149
150/// Unwrap one complete frame, returning the payload.
151///
152/// This is for a frame already in hand: a test fixture, a file, a datagram.
153/// Against a stream use [`Framer`], which handles a frame split across
154/// reads and several frames in one read.
155/// # Errors
156///
157/// [`Error`] when the bytes are not one complete frame: no start block, no
158/// end block, or a missing carriage return after it.
159pub fn decode(frame: &[u8]) -> Result<&[u8], Error> {
160 decode_with(frame, Tolerance::default())
161}
162
163/// Unwrap one complete frame at a chosen [`Tolerance`].
164/// # Errors
165///
166/// The same conditions as [`decode`], less whatever `tolerance` forgives.
167pub fn decode_with(frame: &[u8], tolerance: Tolerance) -> Result<&[u8], Error> {
168 let Some((&first, rest)) = frame.split_first() else {
169 return Err(Error::Incomplete);
170 };
171 if first != START_BLOCK {
172 return Err(Error::NoStartBlock);
173 }
174 let Some(end) = rest.iter().position(|&byte| byte == END_BLOCK) else {
175 return Err(Error::Incomplete);
176 };
177 let payload = &rest[..end];
178 if payload.contains(&START_BLOCK) {
179 return Err(Error::EmbeddedStartBlock);
180 }
181 match &rest[end + 1..] {
182 [CARRIAGE_RETURN] => Ok(payload),
183 [] if tolerance.allows_missing_carriage_return() => Ok(payload),
184 [] => Err(Error::Incomplete),
185 [CARRIAGE_RETURN, extra @ ..] => Err(Error::TrailingBytes(extra.len())),
186 extra if tolerance.allows_missing_carriage_return() => {
187 Err(Error::TrailingBytes(extra.len()))
188 }
189 _ => Err(Error::NoCarriageReturn),
190 }
191}
192
193/// What can go wrong reading a frame.
194///
195/// Every variant means the bytes on the wire are not MLLP. None of them
196/// means the *message* is wrong — that question belongs one layer up, to
197/// `hl7_2::Message::validate`, and can only be asked once framing has
198/// succeeded.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum Error {
201 /// The frame does not begin with `<VT>`.
202 NoStartBlock,
203 /// `<FS>` was not followed by `<CR>`.
204 NoCarriageReturn,
205 /// The frame stops mid-way: no `<FS><CR>` yet.
206 ///
207 /// Against a stream this is not an error but a "read more", which is
208 /// what [`Framer`] does with it; from [`decode`] it means the caller was
209 /// handed a partial frame.
210 Incomplete,
211 /// A complete frame was followed by bytes that do not begin another.
212 /// Carries how many.
213 TrailingBytes(usize),
214 /// A `<VT>` inside the payload, which makes where the frame ends
215 /// ambiguous.
216 EmbeddedStartBlock,
217 /// Bytes arrived before `<VT>`, outside any frame. Carries how many.
218 /// The `noncompliance` feature discards them instead.
219 LeadingBytes(usize),
220 /// More bytes accumulated than the limit allows without a complete
221 /// frame arriving; see [`DEFAULT_LIMIT`].
222 TooLarge {
223 /// How many bytes had accumulated.
224 buffered: usize,
225 /// The limit that was exceeded.
226 limit: usize,
227 },
228}
229
230impl fmt::Display for Error {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 match self {
233 Error::NoStartBlock => write!(f, "frame does not begin with the MLLP start block"),
234 Error::NoCarriageReturn => {
235 write!(f, "the MLLP end block is not followed by a carriage return")
236 }
237 Error::Incomplete => write!(f, "frame is incomplete: no end block yet"),
238 Error::TrailingBytes(count) => {
239 write!(
240 f,
241 "{count} byte(s) follow the frame without beginning another"
242 )
243 }
244 Error::EmbeddedStartBlock => {
245 write!(
246 f,
247 "a start block inside the payload makes the frame ambiguous"
248 )
249 }
250 Error::LeadingBytes(count) => write!(f, "{count} byte(s) arrived outside any frame"),
251 Error::TooLarge { buffered, limit } => write!(
252 f,
253 "buffered {buffered} bytes without a complete frame, over the {limit}-byte limit"
254 ),
255 }
256 }
257}
258
259impl std::error::Error for Error {}
260
261impl From<Error> for std::io::Error {
262 /// A framing error reaching a caller through [`Transport`] is an I/O
263 /// error of kind `InvalidData`: the connection worked, what came over
264 /// it did not.
265 fn from(error: Error) -> std::io::Error {
266 std::io::Error::new(std::io::ErrorKind::InvalidData, error)
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 const MESSAGE: &str = "MSH|^~\\&|LAB|ACME|EHR|CLINIC|20260814080000||ORU^R01|99|P|2.5\rPID|1";
275
276 #[test]
277 fn wraps_and_unwraps_a_message() {
278 let frame = encode(MESSAGE.as_bytes());
279 assert_eq!(frame[0], START_BLOCK);
280 assert_eq!(frame[frame.len() - 2], END_BLOCK);
281 assert_eq!(frame[frame.len() - 1], CARRIAGE_RETURN);
282 assert_eq!(decode(&frame).unwrap(), MESSAGE.as_bytes());
283 }
284
285 #[test]
286 fn leaves_the_payload_exactly_as_it_was() {
287 // A message's own segment terminators are the same byte as the
288 // frame's trailer, and must survive untouched.
289 assert_eq!(decode(&encode(b"A\rB\r")).unwrap(), b"A\rB\r");
290 assert_eq!(decode(&encode(b"")).unwrap(), b"");
291 assert_eq!(decode(&encode(&[0u8, 255, 128])).unwrap(), &[0u8, 255, 128]);
292 }
293
294 #[test]
295 fn refuses_framing_that_is_not_framing() {
296 // Pinned to strict, so this says the same thing whether or not the
297 // `noncompliance` feature is on.
298 fn strict(bytes: &[u8]) -> Result<&[u8], Error> {
299 decode_with(bytes, Tolerance::strict())
300 }
301 assert_eq!(strict(b""), Err(Error::Incomplete));
302 assert_eq!(strict(b"MSH|"), Err(Error::NoStartBlock));
303 assert_eq!(strict(b"\x0bMSH|"), Err(Error::Incomplete));
304 assert_eq!(strict(b"\x0bMSH|\x1c"), Err(Error::Incomplete));
305 assert_eq!(strict(b"\x0bMSH|\x1cX"), Err(Error::NoCarriageReturn));
306 assert_eq!(strict(b"\x0bMSH|\x1c\rextra"), Err(Error::TrailingBytes(5)));
307 assert_eq!(strict(b"\x0bA\x0bB\x1c\r"), Err(Error::EmbeddedStartBlock));
308 }
309
310 #[test]
311 fn the_feature_chooses_the_default_and_nothing_else() {
312 // Whichever way this build is compiled, the default is one of the
313 // two tolerances, and both remain reachable by name.
314 let missing_carriage_return = b"\x0bMSH|\x1c";
315 if cfg!(feature = "noncompliance") {
316 assert_eq!(Tolerance::default(), Tolerance::Lenient);
317 assert_eq!(decode(missing_carriage_return).unwrap(), b"MSH|");
318 } else {
319 assert_eq!(Tolerance::default(), Tolerance::Strict);
320 assert_eq!(decode(missing_carriage_return), Err(Error::Incomplete));
321 }
322 assert_eq!(
323 decode_with(missing_carriage_return, Tolerance::strict()),
324 Err(Error::Incomplete)
325 );
326 assert_eq!(
327 decode_with(missing_carriage_return, Tolerance::lenient()).unwrap(),
328 b"MSH|"
329 );
330 }
331
332 #[test]
333 fn knows_what_cannot_be_framed() {
334 assert!(is_framable(MESSAGE.as_bytes()));
335 assert!(!is_framable(b"before\x0bafter"));
336 assert!(!is_framable(b"before\x1cafter"));
337 }
338
339 #[test]
340 fn tolerance_forgives_a_missing_carriage_return_and_nothing_else() {
341 let lenient = Tolerance::lenient();
342 assert_eq!(decode_with(b"\x0bMSH|\x1c", lenient).unwrap(), b"MSH|");
343 assert_eq!(decode_with(b"\x0bMSH|\x1c\r", lenient).unwrap(), b"MSH|");
344 // Still not a frame, however tolerant we are being.
345 assert_eq!(decode_with(b"MSH|\x1c", lenient), Err(Error::NoStartBlock));
346 assert_eq!(
347 decode_with(b"\x0bA\x0bB\x1c\r", lenient),
348 Err(Error::EmbeddedStartBlock)
349 );
350 }
351
352 #[test]
353 fn errors_carry_across_the_io_boundary() {
354 let error = std::io::Error::from(Error::NoStartBlock);
355 assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
356 assert!(error.to_string().contains("start block"));
357 }
358}