Skip to main content

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}