PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
mtom_packager.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
10#include "mtom_packager.h"
11
12#include <algorithm>
13#include <chrono>
14#include <cstdint>
15#include <cstring>
16#include <random>
17#include <sstream>
18#include <string>
19#include <string_view>
20#include <utility>
21#include <vector>
22
24
25namespace {
26
27constexpr const char* kCrLf = "\r\n";
28constexpr const char* kSoapStartCid = "root.message@cxf.apache.org";
29
30std::string make_boundary() {
31 static thread_local std::mt19937_64 rng(
32 static_cast<std::uint64_t>(
33 std::chrono::steady_clock::now().time_since_epoch().count()));
34 std::uniform_int_distribution<std::uint64_t> dist;
35 std::ostringstream oss;
36 oss << "----=_Part_kcenon_" << std::hex << dist(rng) << dist(rng);
37 return oss.str();
38}
39
40bool contains(const std::string& haystack, const std::string& needle) {
41 return haystack.find(needle) != std::string::npos;
42}
43
44bool bytes_contain(const std::vector<std::uint8_t>& haystack,
45 const std::string& needle) {
46 if (haystack.size() < needle.size()) return false;
47 const auto n = needle.size();
48 for (std::size_t i = 0; i + n <= haystack.size(); ++i) {
49 if (std::equal(needle.begin(), needle.end(),
50 haystack.begin() + static_cast<std::ptrdiff_t>(i))) {
51 return true;
52 }
53 }
54 return false;
55}
56
57} // namespace
58
60 const std::string& envelope_xml, const std::vector<mtom_part>& parts) {
61 if (envelope_xml.empty()) {
62 return kcenon::common::make_error<packaged_mtom>(
63 static_cast<int>(error_code::mtom_empty_payload),
64 "envelope XML is empty", std::string(error_source));
65 }
66
67 std::string boundary;
68 bool ok = false;
69 for (int attempt = 0; attempt < 8; ++attempt) {
70 boundary = make_boundary();
71 if (contains(envelope_xml, boundary)) continue;
72 bool collides = false;
73 for (const auto& p : parts) {
74 if (bytes_contain(p.content, boundary)) {
75 collides = true;
76 break;
77 }
78 }
79 if (!collides) {
80 ok = true;
81 break;
82 }
83 }
84 if (!ok) {
85 return kcenon::common::make_error<packaged_mtom>(
87 "failed to generate a collision-free MIME boundary",
88 std::string(error_source));
89 }
90
92 out.content_type =
93 std::string("multipart/related; boundary=\"") + boundary +
94 std::string("\"; type=\"application/xop+xml\"; start=\"<") +
95 kSoapStartCid +
96 std::string(">\"; start-info=\"application/soap+xml; action=\\\"urn:ihe:iti:2007:RegisterDocumentSet-b\\\"\"");
97
98 std::string body;
99 body.reserve(envelope_xml.size() + 4096);
100
101 body += "--";
102 body += boundary;
103 body += kCrLf;
104 body += "Content-Type: application/xop+xml; charset=UTF-8; type=\"application/soap+xml\"";
105 body += kCrLf;
106 body += "Content-Transfer-Encoding: 8bit";
107 body += kCrLf;
108 body += "Content-ID: <";
109 body += kSoapStartCid;
110 body += ">";
111 body += kCrLf;
112 body += kCrLf;
113 body += envelope_xml;
114 body += kCrLf;
115
116 for (const auto& p : parts) {
117 if (p.content_id.empty()) {
118 return kcenon::common::make_error<packaged_mtom>(
119 static_cast<int>(error_code::mtom_empty_payload),
120 "part has empty content-id", std::string(error_source));
121 }
122 if (p.content.empty()) {
123 return kcenon::common::make_error<packaged_mtom>(
124 static_cast<int>(error_code::mtom_empty_payload),
125 "part '" + p.content_id + "' has empty content",
126 std::string(error_source));
127 }
128
129 body += "--";
130 body += boundary;
131 body += kCrLf;
132 body += "Content-Type: ";
133 body += (p.mime_type.empty() ? "application/octet-stream" : p.mime_type);
134 body += kCrLf;
135 body += "Content-Transfer-Encoding: binary";
136 body += kCrLf;
137 body += "Content-ID: <";
138 body += p.content_id;
139 body += ">";
140 body += kCrLf;
141 body += kCrLf;
142 body.append(reinterpret_cast<const char*>(p.content.data()),
143 p.content.size());
144 body += kCrLf;
145 }
146
147 body += "--";
148 body += boundary;
149 body += "--";
150 body += kCrLf;
151
152 out.body = std::move(body);
153 return out;
154}
155
156namespace {
157
158// Extract the boundary= parameter from a Content-Type header. Handles
159// both quoted (boundary="abc") and unquoted (boundary=abc) forms, and
160// stops at the next ';' or whitespace. Returns the boundary value
161// *without* the surrounding quotes.
162std::string extract_boundary_from_header(std::string_view header) {
163 if (header.empty()) return {};
164 constexpr std::string_view kName = "boundary=";
165 std::size_t pos = std::string::npos;
166 for (std::size_t i = 0; i + kName.size() <= header.size(); ++i) {
167 bool match = true;
168 for (std::size_t j = 0; j < kName.size(); ++j) {
169 const char c = header[i + j];
170 const char lc =
171 (c >= 'A' && c <= 'Z') ? static_cast<char>(c + 32) : c;
172 if (lc != kName[j]) {
173 match = false;
174 break;
175 }
176 }
177 if (match) {
178 pos = i + kName.size();
179 break;
180 }
181 }
182 if (pos == std::string::npos) return {};
183 if (pos < header.size() && header[pos] == '"') {
184 ++pos;
185 const auto end = header.find('"', pos);
186 if (end == std::string::npos) return {};
187 return std::string(header.substr(pos, end - pos));
188 }
189 std::size_t end = pos;
190 while (end < header.size() && header[end] != ';' &&
191 header[end] != ' ' && header[end] != '\t' &&
192 header[end] != '\r' && header[end] != '\n') {
193 ++end;
194 }
195 return std::string(header.substr(pos, end - pos));
196}
197
198// Locate the MIME boundary in a body by sniffing. Fallback when the
199// Content-Type header was absent or malformed. The first "--" followed
200// by at least one non-whitespace byte and terminated by CR/LF (or the
201// end of the part separator "--") is the boundary. Returns the boundary
202// *without* leading "--".
203std::string detect_boundary(const std::string& body) {
204 const auto pos = body.find("--");
205 if (pos == std::string::npos) return {};
206 const std::size_t start = pos + 2;
207 std::size_t end = start;
208 while (end < body.size()) {
209 const char c = body[end];
210 if (c == '\r' || c == '\n' || c == ' ' || c == '\t' || c == ';' ||
211 c == '"') {
212 break;
213 }
214 ++end;
215 }
216 if (end == start) return {};
217 return body.substr(start, end - start);
218}
219
220// Case-insensitive prefix check on the ASCII subset of the MIME headers
221// we care about.
222bool iequals_prefix(std::string_view line, std::string_view prefix) {
223 if (line.size() < prefix.size()) return false;
224 for (std::size_t i = 0; i < prefix.size(); ++i) {
225 const char a = line[i];
226 const char b = prefix[i];
227 const char la = (a >= 'A' && a <= 'Z') ? static_cast<char>(a + 32) : a;
228 const char lb = (b >= 'A' && b <= 'Z') ? static_cast<char>(b + 32) : b;
229 if (la != lb) return false;
230 }
231 return true;
232}
233
234std::string trim(std::string_view s) {
235 std::size_t start = 0;
236 std::size_t end = s.size();
237 while (start < end && (s[start] == ' ' || s[start] == '\t')) ++start;
238 while (end > start && (s[end - 1] == ' ' || s[end - 1] == '\t' ||
239 s[end - 1] == '\r' || s[end - 1] == '\n')) {
240 --end;
241 }
242 return std::string(s.substr(start, end - start));
243}
244
245// Strip angle brackets around a content-id value, so we can match it
246// against xop:Include href="cid:..." references that have no brackets.
247std::string strip_cid_brackets(std::string_view raw) {
248 std::string out = trim(raw);
249 if (out.size() >= 2 && out.front() == '<' && out.back() == '>') {
250 out = out.substr(1, out.size() - 2);
251 }
252 return out;
253}
254
255} // namespace
256
258 const std::string& content_type_header, const std::string& body) {
260
261 if (body.empty()) {
262 return kcenon::common::make_error<parsed_mtom>(
264 "response body is empty", std::string(error_source));
265 }
266
267 // Plain SOAP (no multipart framing) fallback: if the body starts with
268 // "<?xml" or "<soap" it is the envelope itself and we surface it as a
269 // root-only parsed_mtom. This keeps the caller's pipeline uniform.
270 {
271 std::size_t leading = 0;
272 while (leading < body.size() &&
273 (body[leading] == ' ' || body[leading] == '\t' ||
274 body[leading] == '\r' || body[leading] == '\n')) {
275 ++leading;
276 }
277 if (body.compare(leading, 5, "<?xml") == 0 ||
278 body.compare(leading, 5, "<soap") == 0 ||
279 body.compare(leading, 14, "<soap:Envelope") == 0) {
280 out.root_xml = body;
281 return out;
282 }
283 }
284
285 // Prefer the Content-Type header's boundary parameter - this is the
286 // authoritative source per RFC 2387. The body-sniffing fallback in
287 // detect_boundary only runs when the header was absent or missing a
288 // boundary parameter, which happens in closed test harnesses that
289 // bypass libcurl via the transport override.
290 std::string boundary = extract_boundary_from_header(content_type_header);
291 if (boundary.empty()) {
292 boundary = detect_boundary(body);
293 }
294 if (boundary.empty()) {
295 return kcenon::common::make_error<parsed_mtom>(
297 "no MIME boundary detected in response body or Content-Type",
298 std::string(error_source));
299 }
300
301 const std::string delim = "--" + boundary;
302
303 // Split body on every occurrence of "--<boundary>". The last segment
304 // before "--<boundary>--" is the final part; anything after the closer
305 // is the epilogue (ignored).
306 std::vector<std::string> segments;
307 std::size_t cursor = 0;
308 while (cursor < body.size()) {
309 const auto next = body.find(delim, cursor);
310 if (next == std::string::npos) break;
311 if (next > cursor) {
312 segments.push_back(body.substr(cursor, next - cursor));
313 }
314 cursor = next + delim.size();
315 // Close marker: "--<boundary>--"
316 if (cursor + 2 <= body.size() && body[cursor] == '-' &&
317 body[cursor + 1] == '-') {
318 cursor += 2;
319 break;
320 }
321 }
322 if (segments.empty()) {
323 // The preamble before the first boundary is allowed by RFC 2046,
324 // so an "empty" segments list only means the body had no proper
325 // first-part marker.
326 return kcenon::common::make_error<parsed_mtom>(
328 "multipart body yielded zero parts",
329 std::string(error_source));
330 }
331
332 // Discard any preamble-only first segment that isn't actually a part
333 // (i.e. the bytes between the MIME body start and the first "--B").
334 // Our parser starts cursor at 0, so the first segment is whatever
335 // preceded the first boundary - usually empty after CRLFs. A real part
336 // always has a header-body split we can detect.
337 bool first_part_consumed = false;
338 for (auto& seg : segments) {
339 // A valid MIME part starts with CRLF, then headers, then CRLF CRLF,
340 // then content, then CRLF. Strip the leading CRLF if present.
341 std::size_t start = 0;
342 while (start < seg.size() &&
343 (seg[start] == '\r' || seg[start] == '\n')) {
344 ++start;
345 }
346 if (start >= seg.size()) continue;
347
348 const auto header_end = seg.find("\r\n\r\n", start);
349 const std::size_t content_start =
350 (header_end == std::string::npos) ? seg.size() : header_end + 4;
351 if (content_start > seg.size()) continue;
352
353 // Parse headers.
354 const std::string_view header_block(
355 seg.data() + start,
356 (header_end == std::string::npos ? seg.size() - start
357 : header_end - start));
358 std::string mime_type;
359 std::string content_id;
360 std::size_t pos = 0;
361 while (pos < header_block.size()) {
362 const auto eol = header_block.find("\r\n", pos);
363 const std::size_t line_end =
364 (eol == std::string::npos) ? header_block.size() : eol;
365 const std::string_view line = header_block.substr(pos, line_end - pos);
366 if (iequals_prefix(line, "Content-Type:")) {
367 mime_type = trim(line.substr(std::strlen("Content-Type:")));
368 const auto semi = mime_type.find(';');
369 if (semi != std::string::npos) {
370 mime_type = trim(std::string_view(mime_type).substr(0, semi));
371 }
372 } else if (iequals_prefix(line, "Content-ID:")) {
373 content_id = strip_cid_brackets(
374 line.substr(std::strlen("Content-ID:")));
375 }
376 if (eol == std::string::npos) break;
377 pos = eol + 2;
378 }
379
380 // Extract content, trimming the trailing CRLF that precedes the
381 // next boundary delimiter.
382 std::size_t content_end = seg.size();
383 while (content_end > content_start &&
384 (seg[content_end - 1] == '\r' ||
385 seg[content_end - 1] == '\n')) {
386 --content_end;
387 }
388 if (content_end < content_start) content_end = content_start;
389 const std::size_t content_len = content_end - content_start;
390
391 if (!first_part_consumed) {
392 out.root_xml.assign(seg, content_start, content_len);
393 first_part_consumed = true;
394 continue;
395 }
396 mtom_part mp;
397 mp.content_id = std::move(content_id);
398 mp.mime_type = std::move(mime_type);
399 mp.content.assign(
400 reinterpret_cast<const std::uint8_t*>(seg.data() + content_start),
401 reinterpret_cast<const std::uint8_t*>(seg.data() + content_end));
402 out.parts.push_back(std::move(mp));
403 }
404
405 if (out.root_xml.empty()) {
406 return kcenon::common::make_error<parsed_mtom>(
408 "multipart body had no root part with XML payload",
409 std::string(error_source));
410 }
411
412 return out;
413}
414
415} // namespace kcenon::pacs::ihe::xds::detail
MTOM / XOP multipart/related packager for ITI-41.
kcenon::common::Result< packaged_mtom > package_mtom(const std::string &envelope_xml, const std::vector< mtom_part > &parts)
Assemble a multipart/related MTOM body.
kcenon::common::Result< parsed_mtom > parse_mtom_response(const std::string &content_type_header, const std::string &body)
Parse a multipart/related MTOM response body.
constexpr std::string_view error_source
Short identifier used as the "source" field of make_error.
Definition errors.h:95
std::string out
One attachment part, mapped 1:1 to an xop:Include in the envelope.
std::vector< std::uint8_t > content
Fully assembled MTOM request body.
Result of parsing an inbound multipart/related MTOM response.
pugi::xml_node match
std::string needle