hl7probe 0.10.0

Read and check HL7 v2 messages: a library and a command-line tool
Documentation
//! Properties that must hold for every message, checked over a large body of
//! deliberately damaged input.
//!
//! hl7probe reads files it did not produce, from interfaces that have been
//! running for decades. The useful question is not whether it handles the
//! sample messages but whether anything at all can make it panic, and whether
//! what it decodes still adds up to what it was given.
#![allow(
    clippy::unwrap_used,
    reason = "panicking is the failure mode a test wants"
)]

use hl7probe::parser::{parse_message, split_messages, Segment};

/// A seeded xorshift, so the corpus is the same on every machine and every run.
/// A failing case is reproducible from its seed rather than luck.
struct Rng(u64);

impl Rng {
    const fn next(&mut self) -> u64 {
        self.0 ^= self.0 << 13;
        self.0 ^= self.0 >> 7;
        self.0 ^= self.0 << 17;
        self.0
    }

    /// A value below `n`, wide enough for any corpus this test builds.
    fn below(&mut self, n: usize) -> usize {
        usize::try_from(self.next() % n as u64).unwrap_or(0)
    }

    /// Any byte at all, including ones that are not valid UTF-8.
    const fn byte(&mut self) -> u8 {
        (self.next() & 0xff) as u8
    }
}

fn examples() -> Vec<Vec<u8>> {
    let mut out = Vec::new();
    for name in ["adt_a01", "batch", "invalid", "oru_r01"] {
        out.push(std::fs::read(format!("examples/{name}.hl7")).unwrap());
    }
    out
}

/// Reads a message the way the tool does, touching every accessor a report
/// would. Returns the number of segments so the walk cannot be optimised away.
fn walk(text: &str) -> usize {
    let (raws, _notes) = split_messages(text);
    let mut seen = 0;
    for raw in &raws {
        let Ok(message) = parse_message(raw) else {
            continue;
        };
        let _ = (
            message.version(),
            message.type_label(),
            message.control_id(),
        );
        let _ = message.message_type();
        for segment in &message.segments {
            seen += 1;
            let _ = (segment.is_custom(), segment.last_populated());
            for seq in 1..=segment.last_populated().max(1) {
                let _ = (segment.has(seq), segment.text(seq), segment.comp(seq, 1));
                let Some(field) = segment.field(seq) else {
                    continue;
                };
                let _ = (field.is_empty(), field.is_null(), field.rep_count());
                for rep in field.reps() {
                    let _ = (rep.is_empty(), rep.filled_comps(), rep.text());
                    for comp in rep.comps() {
                        let _ = comp.is_empty();
                        for sub in comp.subs() {
                            let _ = sub.len();
                        }
                    }
                }
            }
        }
    }
    seen
}

/// Every field of a segment, rejoined with the separator it was split on, must
/// give back the line it came from. A decoder that loses or invents a character
/// is worse than one that refuses the message.
///
/// The text before the first separator is not always the segment name: a
/// damaged line can carry anything there, and the parser splits on the
/// separator rather than trusting the name. So the check rejoins onto whatever
/// actually preceded it.
fn rebuilds_its_own_line(segment: &Segment<'_>, field_separator: char) -> bool {
    // A segment cannot have more fields than its line has characters.
    let count = (1..=segment.raw.len())
        .take_while(|seq| segment.field(*seq).is_some())
        .count();
    let fields: Vec<&str> = (1..=count)
        .map(|seq| segment.field(seq).unwrap().text())
        .collect();
    let joiner = field_separator.to_string();

    let rebuilt = if segment.name == "MSH" {
        // MSH-1 is the separator itself, so it joins rather than separates.
        let Some(first) = fields.first() else {
            return true;
        };
        format!(
            "{}{first}{}",
            &segment.raw[..segment.name.len()],
            fields[1..].join(&joiner)
        )
    } else {
        let Some(at) = segment.raw.find(field_separator) else {
            // Nothing to split on, so there are no fields to rejoin.
            return fields.is_empty();
        };
        let head = &segment.raw[..at + field_separator.len_utf8()];
        format!("{head}{}", fields.join(&joiner))
    };
    rebuilt == segment.raw
}

#[test]
fn no_damaged_input_can_make_the_parser_panic() {
    let seeds = examples();
    let mut rng = Rng(0x5eed_1234_abcd_ef01);
    let mut cases = 0;

    for seed in &seeds {
        // Every truncation: a file cut off mid-field, mid-segment, mid-escape.
        for cut in 0..seed.len() {
            let text = String::from_utf8_lossy(&seed[..cut]).into_owned();
            walk(&text);
            cases += 1;
        }
        // Random byte corruption, including bytes that are not valid UTF-8 and
        // bytes that happen to be delimiters.
        for _ in 0..400 {
            let mut bytes = seed.clone();
            for _ in 0..=rng.below(8) {
                let at = rng.below(bytes.len());
                bytes[at] = rng.byte();
            }
            let text = String::from_utf8_lossy(&bytes).into_owned();
            walk(&text);
            cases += 1;
        }
    }

    // Shapes worth naming, because each one broke something at some point.
    for text in [
        "",
        "\r",
        "\r\n\r\n",
        "MSH",
        "MSH|",
        "MSH|^~\\&",
        "MSH|^~\\&|\r",
        "MSHzzzz\r",
        "MSH|^~\\&|\rPID|\r",
        "MSH|^~\\&|\rPID|~~~|^^^|&&&\r",
        "MSH|^~\\&|\rPID|\\X\\|\\Xzz\\|\\E\\|\\\\\r",
        "MSH|^~\\&|\rPID|\u{e9}\u{4e2d}\u{1f600}\r",
        "MSH|^^^^|\rPID|a^b\r",
        "\u{b}MSH|^~\\&|\r\u{1c}\r",
        "FHS|x\rBHS|y\rMSH|^~\\&|\rBTS|1\rFTS|1\r",
    ] {
        walk(text);
        cases += 1;
    }

    assert!(
        cases > 2_000,
        "the corpus should be substantial, got {cases}"
    );
}

#[test]
fn a_decoded_segment_still_adds_up_to_the_line_it_came_from() {
    let seeds = examples();
    let mut rng = Rng(0x0bad_c0ff_ee12_3456);
    let mut checked = 0;

    let mut verify = |text: &str| {
        let (raws, _notes) = split_messages(text);
        for raw in &raws {
            let Ok(message) = parse_message(raw) else {
                continue;
            };
            for segment in &message.segments {
                assert!(
                    rebuilds_its_own_line(segment, message.sep.field),
                    "{} on line {} does not rebuild:\n  raw: {:?}",
                    segment.name,
                    segment.line,
                    segment.raw
                );
                checked += 1;
            }
        }
    };

    for seed in &seeds {
        verify(&String::from_utf8_lossy(seed));
        for _ in 0..300 {
            let mut bytes = seed.clone();
            for _ in 0..=rng.below(6) {
                let at = rng.below(bytes.len());
                bytes[at] = rng.byte();
            }
            verify(&String::from_utf8_lossy(&bytes));
        }
    }

    assert!(checked > 1_000, "expected a real sample, got {checked}");
}

#[test]
fn reading_the_same_text_twice_gives_the_same_answer() {
    for seed in examples() {
        let text = String::from_utf8_lossy(&seed).into_owned();
        let (first, first_notes) = split_messages(&text);
        let (second, second_notes) = split_messages(&text);
        assert_eq!(first.len(), second.len());
        assert_eq!(first_notes, second_notes);
        for (a, b) in first.iter().zip(&second) {
            let (a, b) = (parse_message(a), parse_message(b));
            match (a, b) {
                (Ok(a), Ok(b)) => {
                    assert_eq!(a.segments.len(), b.segments.len());
                    assert_eq!(a.type_label(), b.type_label());
                    assert_eq!(a.notes, b.notes);
                }
                (Err(a), Err(b)) => assert_eq!(a.to_string(), b.to_string()),
                _ => panic!("the same text parsed differently on a second reading"),
            }
        }
    }
}