Skip to main content

dvgw_edi/
error.rs

1//! Error type and the log-injection guard shared by every diagnostic path.
2
3/// Errors produced by `dvgw-edi`.
4///
5/// All public API entry points return `Result<_, Error>`.
6#[derive(Debug, thiserror::Error)]
7#[non_exhaustive]
8pub enum Error {
9    /// The underlying EDIFACT tokeniser rejected the input.
10    #[error("EDIFACT parse error: {0}")]
11    Parse(#[from] edifact_rs::EdifactError),
12
13    /// Rendering an outbound message back to EDIFACT bytes failed.
14    #[error("EDIFACT serialization error: {0}")]
15    Serialize(String),
16
17    /// A segment the DVGW Nachrichtenbeschreibung marks `Muss` is absent, so the
18    /// message cannot even be identified.
19    #[error("required segment {0} is missing")]
20    MissingSegment(&'static str),
21
22    /// `BGM` C002 DE 1001 does not carry a document-name code this crate knows.
23    ///
24    /// DVGW identifies the logical message (ALOCAT / NOMINT / NOMRES) by this
25    /// code — **not** by the `UNH` message type, which is always the UN/EDIFACT
26    /// carrier `ORDERS` or `ORDRSP`.
27    ///
28    /// The raw code is kept out of `Display` so an untrusted value cannot reach
29    /// operator logs; read it from `raw_code` when diagnosing.
30    #[error("unknown DVGW document-name code (check BGM C002 DE 1001)")]
31    UnknownDocumentCode {
32        /// The sanitized `BGM` DE 1001 value.
33        raw_code: String,
34    },
35
36    /// `UNH` S009 DE 0065 names a carrier that does not match the document code.
37    ///
38    /// NOMINT rides `ORDERS`; ALOCAT and NOMRES ride `ORDRSP`. A mismatch means
39    /// the two identifying fields disagree, which no conformant sender produces.
40    #[error(
41        "UNH carrier and BGM document code disagree: document {document} is carried by \
42         {expected} but UNH names a different message type"
43    )]
44    CarrierMismatch {
45        /// The `BGM` DE 1001 code that was read.
46        document: &'static str,
47        /// The carrier that code requires.
48        expected: &'static str,
49        /// The sanitized `UNH` DE 0065 value that was found instead.
50        raw_code: String,
51    },
52
53    /// A wrapped I/O error from the reader-based entry points.
54    #[error("I/O error: {0}")]
55    Io(#[from] std::io::Error),
56}
57
58// ── Sanitization helper ───────────────────────────────────────────────────────
59
60/// Sanitize an untrusted EDIFACT code for safe inclusion in error fields and logs.
61///
62/// DVGW codes are at most a handful of ASCII alphanumerics plus `.`; anything
63/// else is replaced with `?` so ANSI escapes and other log-injection payloads
64/// are neutralised.
65///
66/// Truncation is done on **character** boundaries, not byte offsets: the value
67/// comes straight off the wire and slicing a multi-byte character in half would
68/// panic on the parsing hot path.
69pub(crate) fn sanitize_code(s: &str) -> String {
70    const MAX_CHARS: usize = 16;
71    s.chars()
72        .take(MAX_CHARS)
73        .map(|c| {
74            if c.is_ascii_alphanumeric() || c == '.' {
75                c
76            } else {
77                '?'
78            }
79        })
80        .collect()
81}
82
83#[cfg(test)]
84mod tests {
85    use super::sanitize_code;
86
87    #[test]
88    fn truncates_on_character_boundaries() {
89        // 17 two-byte characters: a byte-offset slice at 16 would split one.
90        let hostile = "ü".repeat(17);
91        assert_eq!(sanitize_code(&hostile), "?".repeat(16));
92    }
93
94    #[test]
95    fn passes_plain_codes_through() {
96        assert_eq!(sanitize_code("ORDRSP"), "ORDRSP");
97        assert_eq!(sanitize_code("5.11a"), "5.11a");
98        assert_eq!(sanitize_code("X1G\u{1b}[31m"), "X1G??31m");
99    }
100}