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}