PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
query_envelope.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 "query_envelope.h"
11
12#include <pugixml.hpp>
13
14#include <chrono>
15#include <cstdint>
16#include <random>
17#include <sstream>
18#include <string>
19#include <string_view>
20
22
23namespace {
24
25constexpr const char* kSoapEnv = "http://www.w3.org/2003/05/soap-envelope";
26constexpr const char* kWsa = "http://www.w3.org/2005/08/addressing";
27constexpr const char* kWsse =
28 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
29constexpr const char* kWsu =
30 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
31constexpr const char* kQuery = "urn:oasis:names:tc:ebxml-regrep:xsd:query:3.0";
32constexpr const char* kRim = "urn:oasis:names:tc:ebxml-regrep:xsd:rim:3.0";
33
34// Stored query IDs from IHE ITI TF-2a ยง3.18.4.1.2.3.7 (FindDocuments)
35// and ยง3.18.4.1.2.3.8 (GetDocuments). These must match the spec bit-exactly
36// or the registry returns "XDSUnknownStoredQuery".
37constexpr const char* kFindDocumentsQueryId =
38 "urn:uuid:14d4debf-8f97-4251-9a1e-a3a9d68b2a6f";
39constexpr const char* kGetDocumentsQueryId =
40 "urn:uuid:5737b14c-8a1a-4539-b659-e03a34a5e1e4";
41
42// Default status filter applied by FindDocuments when the caller does not
43// override it. "Approved" is the only state that represents a document
44// published and usable in the affinity domain.
45constexpr const char* kDefaultApprovedStatus =
46 "urn:oasis:names:tc:ebxml-regrep:StatusType:Approved";
47
48// ebRIM StoredQuery slot values are wrapped in a list literal
49// ('value1','value2',...) that uses single quotes as the delimiter with no
50// defined escape mechanism. A value containing one of these characters
51// would either break out of the literal (') or fracture the comma-separated
52// list (,) and let a caller forge additional slot values. Reject rather
53// than escape - the XDS ID shapes that legitimately appear in the slots
54// (CX patient id, OID-based unique id, urn:uuid: entry uuid) never contain
55// these characters in conformant metadata, so rejection closes the
56// injection vector without rejecting real traffic.
57//
58// We also reject control characters and the XML-significant bytes that
59// pugixml's text setter does not escape automatically (pugixml escapes
60// <, >, &, " in text nodes but not ' or NUL). This keeps the responsibility
61// at the input-validation boundary rather than relying on downstream
62// serializer behavior.
63bool contains_slot_literal_metachar(std::string_view v) {
64 for (char c : v) {
65 if (c == '\'' || c == '(' || c == ')' || c == ',') return true;
66 // Control characters below 0x20 break ebRIM XML text nodes. Tab,
67 // CR, LF are technically allowed in XML but not in IHE XDS
68 // identifier fields, which are flat ASCII.
69 if (static_cast<unsigned char>(c) < 0x20) return true;
70 }
71 return false;
72}
73
74// Per-envelope id generator; matches the approach used by retrieve_envelope
75// so logs from both actors share the same id shape. Not RFC 4122 compliant
76// but sufficient for an xml:id whose lifetime is a single message.
77std::string make_xml_id(std::string_view prefix) {
78 static thread_local std::mt19937_64 rng(
79 static_cast<std::uint64_t>(
80 std::chrono::steady_clock::now().time_since_epoch().count()));
81 std::uniform_int_distribution<std::uint64_t> dist;
82 std::ostringstream oss;
83 oss << prefix << '-' << std::hex << dist(rng) << dist(rng);
84 return oss.str();
85}
86
87std::string utc_iso8601_now() {
88 const auto now = std::chrono::system_clock::now();
89 const auto tt = std::chrono::system_clock::to_time_t(now);
90 std::tm tm{};
91#ifdef _WIN32
92 gmtime_s(&tm, &tt);
93#else
94 gmtime_r(&tt, &tm);
95#endif
96 char buf[32];
97 std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm);
98 return std::string(buf);
99}
100
101struct xml_string_writer : pugi::xml_writer {
102 std::string out;
103 void write(const void* data, std::size_t size) override {
104 out.append(static_cast<const char*>(data), size);
105 }
106};
107
108// Emit an ebRIM Slot with name @p slot_name carrying one single-quoted
109// Value. The StoredQuery wire format wraps scalar slot values as
110// "('value')" - a one-element list literal that ebRS parsers reduce back
111// to the scalar. The quote character is single because the registry's
112// query parser requires it; double quotes are a parse error in at least
113// one conformant stack (Open Health Tools).
114void append_slot_single(pugi::xml_node parent, const char* slot_name,
115 const std::string& value) {
116 auto slot = parent.append_child("rim:Slot");
117 slot.append_attribute("name") = slot_name;
118 auto values = slot.append_child("rim:ValueList");
119 auto v = values.append_child("rim:Value");
120 std::string quoted = "('";
121 quoted += value;
122 quoted += "')";
123 v.text().set(quoted.c_str());
124}
125
126// Emit an ebRIM Slot with name @p slot_name carrying one Value whose text
127// is a list literal of the given strings. For GetDocuments, the registry
128// expects ('uuid1','uuid2',...) - comma-separated, each element
129// single-quoted, no spaces around commas.
130void append_slot_list(pugi::xml_node parent, const char* slot_name,
131 const std::vector<std::string>& values) {
132 auto slot = parent.append_child("rim:Slot");
133 slot.append_attribute("name") = slot_name;
134 auto value_list = slot.append_child("rim:ValueList");
135 auto v = value_list.append_child("rim:Value");
136 std::string text = "(";
137 for (std::size_t i = 0; i < values.size(); ++i) {
138 if (i != 0) text += ",";
139 text += "'";
140 text += values[i];
141 text += "'";
142 }
143 text += ")";
144 v.text().set(text.c_str());
145}
146
147} // namespace
148
150 const registry_query_request& req, stored_query_kind kind) {
152 if (req.patient_id.empty()) {
153 return kcenon::common::make_error<built_envelope>(
154 static_cast<int>(
156 "registry_query_request.patient_id is empty",
157 std::string(error_source));
158 }
159 if (contains_slot_literal_metachar(req.patient_id)) {
160 return kcenon::common::make_error<built_envelope>(
161 static_cast<int>(
163 "patient_id contains a character that is not legal inside "
164 "an ebRIM StoredQuery slot literal ('(),\\' or control "
165 "character)",
166 std::string(error_source));
167 }
168 for (const auto& s : req.options.status_values) {
169 if (contains_slot_literal_metachar(s)) {
170 return kcenon::common::make_error<built_envelope>(
171 static_cast<int>(
173 "options.status_values contains an element with a "
174 "character that is not legal inside an ebRIM slot "
175 "literal",
176 std::string(error_source));
177 }
178 }
179 if (contains_slot_literal_metachar(req.options.creation_time_from) ||
180 contains_slot_literal_metachar(req.options.creation_time_to)) {
181 return kcenon::common::make_error<built_envelope>(
182 static_cast<int>(
184 "options.creation_time_* contains a character that is not "
185 "legal inside an ebRIM slot literal",
186 std::string(error_source));
187 }
188 }
190 if (req.document_uuids.empty()) {
191 return kcenon::common::make_error<built_envelope>(
192 static_cast<int>(
194 "registry_query_request.document_uuids is empty",
195 std::string(error_source));
196 }
197 for (const auto& u : req.document_uuids) {
198 if (u.empty()) {
199 return kcenon::common::make_error<built_envelope>(
200 static_cast<int>(
202 "registry_query_request.document_uuids contains an "
203 "empty element",
204 std::string(error_source));
205 }
206 if (contains_slot_literal_metachar(u)) {
207 return kcenon::common::make_error<built_envelope>(
208 static_cast<int>(
210 "registry_query_request.document_uuids contains an "
211 "element with a character that is not legal inside "
212 "an ebRIM StoredQuery slot literal",
213 std::string(error_source));
214 }
215 }
216 }
217
218 pugi::xml_document doc;
219 auto decl = doc.append_child(pugi::node_declaration);
220 decl.append_attribute("version") = "1.0";
221 decl.append_attribute("encoding") = "UTF-8";
222
223 auto env = doc.append_child("soap:Envelope");
224 env.append_attribute("xmlns:soap") = kSoapEnv;
225 env.append_attribute("xmlns:wsa") = kWsa;
226 env.append_attribute("xmlns:wsse") = kWsse;
227 env.append_attribute("xmlns:wsu") = kWsu;
228 env.append_attribute("xmlns:query") = kQuery;
229 env.append_attribute("xmlns:rim") = kRim;
230
231 auto header = env.append_child("soap:Header");
232
233 auto action = header.append_child("wsa:Action");
234 action.append_attribute("soap:mustUnderstand") = "true";
235 action.text().set("urn:ihe:iti:2007:RegistryStoredQuery");
236
237 auto message_id = header.append_child("wsa:MessageID");
238 message_id.text().set(("urn:uuid:" + make_xml_id("msg")).c_str());
239
240 auto security = header.append_child("wsse:Security");
241 security.append_attribute("soap:mustUnderstand") = "true";
242
244 out.timestamp_id = make_xml_id("ts");
245 out.binary_security_token_id = make_xml_id("bst");
246 out.body_id = make_xml_id("body");
247
248 auto timestamp = security.append_child("wsu:Timestamp");
249 timestamp.append_attribute("wsu:Id") = out.timestamp_id.c_str();
250 auto created = timestamp.append_child("wsu:Created");
251 const std::string now = utc_iso8601_now();
252 created.text().set(now.c_str());
253
254 auto bst = security.append_child("wsse:BinarySecurityToken");
255 bst.append_attribute("wsu:Id") = out.binary_security_token_id.c_str();
256 bst.append_attribute("EncodingType") =
257 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-"
258 "security-1.0#Base64Binary";
259 bst.append_attribute("ValueType") =
260 "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-"
261 "profile-1.0#X509v3";
262
263 auto body = env.append_child("soap:Body");
264 body.append_attribute("wsu:Id") = out.body_id.c_str();
265
266 auto adhoc_request = body.append_child("query:AdhocQueryRequest");
267 // ReturnType=LeafClass is the only form the XDS.b profile of ebRS
268 // accepts for FindDocuments/GetDocuments. ObjectRef returns IDs only
269 // and is unusable for the registry_query_result we assemble later.
270 auto response_option =
271 adhoc_request.append_child("query:ResponseOption");
272 response_option.append_attribute("returnComposedObjects") = "true";
273 response_option.append_attribute("returnType") = "LeafClass";
274
275 auto adhoc_query = adhoc_request.append_child("rim:AdhocQuery");
276 adhoc_query.append_attribute("id") =
277 (kind == stored_query_kind::find_documents) ? kFindDocumentsQueryId
278 : kGetDocumentsQueryId;
279
281 append_slot_single(adhoc_query, "$XDSDocumentEntryPatientId",
282 req.patient_id);
283 if (req.options.status_values.empty()) {
284 append_slot_single(adhoc_query, "$XDSDocumentEntryStatus",
285 kDefaultApprovedStatus);
286 } else {
287 append_slot_list(adhoc_query, "$XDSDocumentEntryStatus",
289 }
290 if (!req.options.creation_time_from.empty()) {
291 append_slot_single(adhoc_query,
292 "$XDSDocumentEntryCreationTimeFrom",
294 }
295 if (!req.options.creation_time_to.empty()) {
296 append_slot_single(adhoc_query,
297 "$XDSDocumentEntryCreationTimeTo",
299 }
300 } else {
301 append_slot_list(adhoc_query, "$XDSDocumentEntryEntryUUID",
302 req.document_uuids);
303 }
304
305 xml_string_writer writer;
306 doc.save(writer, "", pugi::format_raw | pugi::format_no_declaration,
307 pugi::encoding_utf8);
308 if (writer.out.empty()) {
309 return kcenon::common::make_error<built_envelope>(
311 "pugixml produced an empty envelope",
312 std::string(error_source));
313 }
314
315 out.xml = R"(<?xml version="1.0" encoding="UTF-8"?>)";
316 out.xml += writer.out;
317 return out;
318}
319
320} // namespace kcenon::pacs::ihe::xds::detail
kcenon::common::Result< built_envelope > build_iti18_envelope(const registry_query_request &req, stored_query_kind kind)
Build a SOAP envelope for ITI-18 from a registry_query_request.
stored_query_kind
Discriminator for the two stored queries this actor supports.
constexpr std::string_view error_source
Short identifier used as the "source" field of make_error.
Definition errors.h:95
SOAP 1.2 envelope builder for IHE XDS.b ITI-18 RegistryStoredQuery.
std::string out
Result of envelope construction.
ITI-18 RegistryStoredQuery request target.