Skip to main content

zerodds_soap/
mtom.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! MTOM/XOP — Message Transmission Optimization Mechanism.
5//!
6//! W3C MTOM Recommendation (`http://www.w3.org/2005/REC-soap12-mtom-20050125/`)
7//! + XOP (`http://www.w3.org/TR/xop10/`).
8//!
9//! Erlaubt das Anhaengen binaerer Daten an SOAP-Messages via
10//! MIME-Multipart/Related-Container statt Base64-Inlining.
11
12use alloc::format;
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16/// Eine MTOM/XOP-Attachment-Part.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Attachment {
19    /// Content-Id (z.B. `<image1@demo>`).
20    pub content_id: String,
21    /// MIME-Type (z.B. `image/png`).
22    pub content_type: String,
23    /// Bytes.
24    pub bytes: Vec<u8>,
25}
26
27/// Komplette MTOM-Message: SOAP-Envelope + Attachments.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct MtomMessage {
30    /// Boundary-String fuer den Multipart-Container.
31    pub boundary: String,
32    /// SOAP-Envelope-XML (Root-Part).
33    pub envelope_xml: String,
34    /// Anhang-Liste.
35    pub attachments: Vec<Attachment>,
36}
37
38/// Encode eine MTOM-Message zu einem Multipart/Related-Body.
39/// Spec MTOM §4.
40#[must_use]
41pub fn encode_mtom_packaging(msg: &MtomMessage) -> String {
42    let mut out = String::new();
43    let b = &msg.boundary;
44    // Root-Part — application/xop+xml.
45    out.push_str(&format!("--{b}\r\n"));
46    out.push_str(
47        "Content-Type: application/xop+xml; charset=UTF-8; type=\"application/soap+xml\"\r\n",
48    );
49    out.push_str("Content-Transfer-Encoding: 8bit\r\n");
50    out.push_str("Content-ID: <root.message@zerodds-soap>\r\n\r\n");
51    out.push_str(&msg.envelope_xml);
52    out.push_str("\r\n");
53    // Attachment-Parts.
54    for att in &msg.attachments {
55        out.push_str(&format!("--{b}\r\n"));
56        out.push_str(&format!("Content-Type: {}\r\n", att.content_type));
57        out.push_str("Content-Transfer-Encoding: binary\r\n");
58        out.push_str(&format!("Content-ID: {}\r\n\r\n", att.content_id));
59        // We can't push raw bytes through String, so we use base64-equivalent
60        // representation: hex. Caller-Layer kann die echte Binary-
61        // Serialisierung bei Bedarf substitueren.
62        out.push_str(&hex_encode(&att.bytes));
63        out.push_str("\r\n");
64    }
65    out.push_str(&format!("--{b}--\r\n"));
66    out
67}
68
69/// Erzeugt eine `<xop:Include href="cid:...">`-Element-Referenz in
70/// einem Body-XML, wo ein Binaer-Feld eingehaengt waere.
71/// Spec XOP §4.1.
72#[must_use]
73pub fn xop_include(content_id: &str) -> String {
74    format!(
75        r#"<xop:Include xmlns:xop="http://www.w3.org/2004/08/xop/include" href="cid:{}"/>"#,
76        content_id.trim_start_matches('<').trim_end_matches('>')
77    )
78}
79
80fn hex_encode(b: &[u8]) -> String {
81    let mut out = String::with_capacity(b.len() * 2);
82    for byte in b {
83        let hi = (byte >> 4) & 0x0f;
84        let lo = byte & 0x0f;
85        out.push(hex_digit(hi));
86        out.push(hex_digit(lo));
87    }
88    out
89}
90
91fn hex_digit(n: u8) -> char {
92    match n {
93        0..=9 => char::from(b'0' + n),
94        10..=15 => char::from(b'A' + n - 10),
95        _ => '?',
96    }
97}
98
99impl MtomMessage {
100    /// Konstruktor mit Default-Boundary.
101    #[must_use]
102    pub fn new(envelope_xml: String) -> Self {
103        Self {
104            boundary: "MIME_boundary_dds_soap".to_string(),
105            envelope_xml,
106            attachments: Vec::new(),
107        }
108    }
109
110    /// Anhang hinzufuegen.
111    pub fn attach(&mut self, att: Attachment) {
112        self.attachments.push(att);
113    }
114}
115
116#[cfg(test)]
117#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
118mod tests {
119    use super::*;
120
121    fn sample_attachment() -> Attachment {
122        Attachment {
123            content_id: "<image1@demo>".into(),
124            content_type: "image/png".into(),
125            bytes: alloc::vec![0xCA, 0xFE, 0xBA, 0xBE],
126        }
127    }
128
129    #[test]
130    fn package_starts_with_xop_root_part() {
131        let mut msg = MtomMessage::new("<env/>".into());
132        msg.attach(sample_attachment());
133        let s = encode_mtom_packaging(&msg);
134        assert!(s.contains("application/xop+xml"));
135        assert!(s.contains("Content-ID: <root.message@zerodds-soap>"));
136    }
137
138    #[test]
139    fn package_includes_attachment_part() {
140        let mut msg = MtomMessage::new("<env/>".into());
141        msg.attach(sample_attachment());
142        let s = encode_mtom_packaging(&msg);
143        assert!(s.contains("Content-Type: image/png"));
144        assert!(s.contains("Content-ID: <image1@demo>"));
145        assert!(s.contains("CAFEBABE"));
146    }
147
148    #[test]
149    fn package_terminates_with_closing_boundary() {
150        let msg = MtomMessage::new("<env/>".into());
151        let s = encode_mtom_packaging(&msg);
152        assert!(s.ends_with("--MIME_boundary_dds_soap--\r\n"));
153    }
154
155    #[test]
156    fn xop_include_strips_angle_brackets() {
157        let s = xop_include("<image1@demo>");
158        assert!(s.contains("cid:image1@demo"));
159        assert!(!s.contains("<image1"));
160    }
161
162    #[test]
163    fn xop_include_namespace_is_correct() {
164        let s = xop_include("x");
165        assert!(s.contains("http://www.w3.org/2004/08/xop/include"));
166    }
167
168    #[test]
169    fn empty_attachments_still_produce_root_part() {
170        let msg = MtomMessage::new("<env/>".into());
171        let s = encode_mtom_packaging(&msg);
172        assert!(s.contains("application/xop+xml"));
173        assert!(s.contains("<env/>"));
174    }
175}