Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/upnp/soap.rs

17.9 KiB, 61 runs

created by r1870400018:19717, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! SOAP as UPnP uses it: one action, flat arguments, no schema (UPnP DA 2.0 §3).
2//!
3//! A control point invokes an action by POSTing an envelope to a control URL and
4//! naming the action twice: once in a `SOAPACTION` header field, and again as the
5//! single element inside `<s:Body>`. The arguments are that element's children,
6//! each holding text and nothing else. The answer is the same shape with `Response`
7//! on the end of the name.
8//!
9//! That is the whole protocol as it is met in practice, and it is why this module
10//! is a scanner rather than an XML parser: the document is machine-written, one
11//! level deep, and the alternative is a parser dependency for a body that is
12//! always the same six lines.
13//!
14//! # What this does not do
15//!
16//! Namespace prefixes are compared by local name, so a control point that binds
17//! the envelope namespace to `SOAP-ENV` rather than `s` is understood, and one
18//! that binds `s` to something else entirely is misunderstood. No control point
19//! does the second. Attributes on argument elements are ignored, and so is
20//! anything outside `<s:Body>`.
21//!
22//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
23//! Anthropic Claude
24
25use crate::upnp::{
26 escape,
27 unescape,
28 NS_SOAP_ENVELOPE,
29 NS_UPNP_CONTROL,
30 SOAP_ENCODING,
31};
32
33use oxedyne_fe2o3_core::prelude::*;
34
35use std::collections::BTreeMap;
36
37
38#[derive(Clone, Debug, Default, Eq, PartialEq)]
39pub struct Action {
40 pub service: String, // service type, empty where `SOAPACTION` was absent
41 pub name: String, // e.g. `Browse`
42 pub args: BTreeMap<String, String>, // by name, unescaped
43}
44
45impl Action {
46
47 /// The header is advisory: the body is what says which action to run, and a
48 /// control point whose header disagrees with its body is answered from the
49 /// body. Pass `None` where the field was absent.
50 pub fn parse(soap_action: Option<&str>, body: &str) -> Outcome<Self> {
51 let (service, _named) = match soap_action {
52 Some(v) => res!(parse_action_field(v)),
53 None => (String::new(), String::new()),
54 };
55 let (name, args) = res!(parse_body(body));
56 Ok(Self {
57 service,
58 name,
59 args,
60 })
61 }
62
63 /// UPnP answers a missing argument with error 402, and a caller that wants
64 /// that spelling wraps this in [`SoapError::InvalidArgs`].
65 pub fn need(&self, arg: &str) -> Outcome<&str> {
66 match self.args.get(arg) {
67 Some(v) => Ok(v.as_str()),
68 None => Err(err!(
69 "The {} action carried no {} argument.", self.name, arg;
70 Input, Missing)),
71 }
72 }
73
74 /// An absent or unreadable argument reads as zero. Every numeric argument in
75 /// ContentDirectory:1 is an unsigned index or count, and a control point that
76 /// writes an empty `StartingIndex` means the beginning rather than an error.
77 pub fn count(&self, arg: &str) -> u64 {
78 match self.args.get(arg) {
79 Some(v) => v.trim().parse::<u64>().unwrap_or(0),
80 None => 0,
81 }
82 }
83}
84
85/// The value is `"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"`, with
86/// the quotation marks part of the field, and it splits at the `#`.
87pub fn parse_action_field(value: &str) -> Outcome<(String, String)> {
88 let trimmed = value.trim().trim_matches('"');
89 match trimmed.rsplit_once('#') {
90 Some((service, action)) => Ok((service.to_string(), action.to_string())),
91 None => Err(err!(
92 "A SOAPACTION of {:?} names no action: it wants service#Action.", value;
93 Input, Invalid)),
94 }
95}
96
97pub fn parse_body(body: &str) -> Outcome<(String, BTreeMap<String, String>)> {
98 let inner = match element_body(body, "Body") {
99 Some(inner) => inner,
100 None => return Err(err!(
101 "A SOAP envelope carried no Body element."; Input, Missing)),
102 };
103 let (name, (contents, _after)) = match first_element(inner) {
104 Some(pair) => pair,
105 None => return Err(err!(
106 "A SOAP Body carried no action element."; Input, Missing)),
107 };
108 let mut args = BTreeMap::new();
109 let mut rest = contents;
110 while let Some((arg, (value, after))) = first_element(rest) {
111 // Where the same argument is sent twice the last wins, which is what a
112 // map does and what no control point relies on either way.
113 args.insert(arg.to_string(), unescape(value));
114 rest = after;
115 }
116 Ok((name.to_string(), args))
117}
118
119/// Matched on local name, ignoring any prefix. `None` for an element that is not
120/// there, and for one written as an empty tag, which for a SOAP body means the
121/// same thing.
122fn element_body<'a>(xml: &'a str, local: &str) -> Option<&'a str> {
123 let mut from = 0usize;
124 while let Some(open) = xml[from..].find('<') {
125 let start = from + open;
126 let close = match xml[start..].find('>') {
127 Some(rel) => start + rel,
128 None => return None,
129 };
130 let tag = &xml[start + 1..close];
131 if !tag.starts_with('/') && !tag.starts_with('?') && !tag.starts_with('!')
132 && !tag.ends_with('/')
133 && local_name(tag) == local
134 {
135 // The matching close tag, found by its local name so that a prefix
136 // change between the two does not lose it.
137 let after = close + 1;
138 let mut at = after;
139 while let Some(rel) = xml[at..].find("</") {
140 let shut = at + rel;
141 let shut_end = match xml[shut..].find('>') {
142 Some(r) => shut + r,
143 None => return None,
144 };
145 if local_name(&xml[shut + 2..shut_end]) == local {
146 return Some(&xml[after..shut]);
147 }
148 at = shut_end + 1;
149 }
150 return None;
151 }
152 from = close + 1;
153 }
154 None
155}
156
157/// Its local name, its text, and what follows it. An empty element (`<Filter/>`)
158/// yields an empty value, which is what a control point asking for every field
159/// sends.
160fn first_element(xml: &str) -> Option<(&str, (&str, &str))> {
161 let open = match xml.find('<') {
162 Some(at) => at,
163 None => return None,
164 };
165 let close = match xml[open..].find('>') {
166 Some(rel) => open + rel,
167 None => return None,
168 };
169 let tag = &xml[open + 1..close];
170 if tag.starts_with('/') || tag.starts_with('?') || tag.starts_with('!') {
171 return None;
172 }
173 let local = local_name(tag);
174 if tag.ends_with('/') {
175 return Some((local, ("", &xml[close + 1..])));
176 }
177 let after = close + 1;
178 let mut at = after;
179 let mut depth = 0usize;
180 loop {
181 let next = match xml[at..].find('<') {
182 Some(rel) => at + rel,
183 None => return None,
184 };
185 let shut_end = match xml[next..].find('>') {
186 Some(rel) => next + rel,
187 None => return None,
188 };
189 let inner = &xml[next + 1..shut_end];
190 if let Some(name) = inner.strip_prefix('/') {
191 if local_name(name) == local && depth == 0 {
192 return Some((local, (&xml[after..next], &xml[shut_end + 1..])));
193 }
194 depth = depth.saturating_sub(1);
195 } else if !inner.ends_with('/') && !inner.starts_with('?') && !inner.starts_with('!')
196 && local_name(inner) == local
197 {
198 // A nested element of the same name, which argument values do not
199 // have and which costs nothing to survive.
200 depth += 1;
201 }
202 at = shut_end + 1;
203 }
204}
205
206/// What is left after any namespace prefix and before any attribute.
207fn local_name(tag: &str) -> &str {
208 let name = match tag.find(|c: char| c.is_whitespace()) {
209 Some(at) => &tag[..at],
210 None => tag,
211 };
212 let name = name.trim_end_matches('/');
213 match name.rsplit_once(':') {
214 Some((_, local)) => local,
215 None => name,
216 }
217}
218
219/// `service` is the service *type*, which the response element carries as its
220/// namespace, and the arguments go out in the order given: ContentDirectory:1
221/// specifies an order for them and some control points read them positionally.
222pub fn response(service: &str, action: &str, args: &[(&str, String)]) -> String {
223 let mut out = String::with_capacity(512);
224 out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n");
225 out.push_str(&fmt!(
226 "<s:Envelope xmlns:s=\"{}\" s:encodingStyle=\"{}\">",
227 NS_SOAP_ENVELOPE, SOAP_ENCODING));
228 out.push_str("<s:Body>");
229 out.push_str(&fmt!("<u:{}Response xmlns:u=\"{}\">", action, service));
230 for (name, value) in args {
231 out.push_str(&fmt!("<{}>{}</{}>", name, escape(value), name));
232 }
233 out.push_str(&fmt!("</u:{}Response>", action));
234 out.push_str("</s:Body></s:Envelope>");
235 out
236}
237
238/// The UPnP errors a ContentDirectory server actually returns (UPnP DA 2.0 §3.3.2
239/// and ContentDirectory:1 §2.7).
240///
241/// Held as an enum rather than as bare numbers because the code and the phrase
242/// belong together: a control point shows the phrase to somebody, and a fault
243/// whose two halves disagree is worse than no fault at all.
244#[derive(Clone, Copy, Debug, Eq, PartialEq)]
245pub enum SoapError {
246 InvalidAction, // this service has no such action
247 InvalidArgs, // an argument is missing, misnamed or unreadable
248 ActionFailed, // understood, and could not be carried out
249 NoSuchObject, // the `ObjectID` names nothing
250 UnsupportedSort, // the `SortCriteria` names a property this server cannot sort on
251 CannotProcess, // no reason of its own, so the last resort
252}
253
254impl SoapError {
255
256 /// The `errorCode` a fault carries.
257 pub fn code(&self) -> u16 {
258 match self {
259 Self::InvalidAction => 401,
260 Self::InvalidArgs => 402,
261 Self::ActionFailed => 501,
262 Self::NoSuchObject => 701,
263 Self::UnsupportedSort => 709,
264 Self::CannotProcess => 720,
265 }
266 }
267
268 /// The `errorDescription` that goes with it, in the specification's words.
269 pub fn description(&self) -> &'static str {
270 match self {
271 Self::InvalidAction => "Invalid Action",
272 Self::InvalidArgs => "Invalid Args",
273 Self::ActionFailed => "Action Failed",
274 Self::NoSuchObject => "No such object",
275 Self::UnsupportedSort => "Unsupported or invalid sort criteria",
276 Self::CannotProcess => "Cannot process the request",
277 }
278 }
279
280 /// A SOAP fault goes back with HTTP status 500, which is the caller's to set:
281 /// a control point that receives a fault under a 200 discards it.
282 pub fn envelope(&self) -> String {
283 let mut out = String::with_capacity(512);
284 out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n");
285 out.push_str(&fmt!(
286 "<s:Envelope xmlns:s=\"{}\" s:encodingStyle=\"{}\">",
287 NS_SOAP_ENVELOPE, SOAP_ENCODING));
288 out.push_str("<s:Body><s:Fault>");
289 out.push_str("<faultcode>s:Client</faultcode>");
290 out.push_str("<faultstring>UPnPError</faultstring>");
291 out.push_str("<detail>");
292 out.push_str(&fmt!("<UPnPError xmlns=\"{}\">", NS_UPNP_CONTROL));
293 out.push_str(&fmt!("<errorCode>{}</errorCode>", self.code()));
294 out.push_str(&fmt!("<errorDescription>{}</errorDescription>",
295 escape(self.description())));
296 out.push_str("</UPnPError></detail>");
297 out.push_str("</s:Fault></s:Body></s:Envelope>");
298 out
299 }
300}
301
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 // A `Browse` as a control point sends one: prefixed envelope, unprefixed
308 // arguments, and an escaped filter.
309 const A_BROWSE: &str = "<?xml version=\"1.0\"?>\
310 <s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\" \
311 s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">\
312 <s:Body>\
313 <u:Browse xmlns:u=\"urn:schemas-upnp-org:service:ContentDirectory:1\">\
314 <ObjectID>0</ObjectID>\
315 <BrowseFlag>BrowseDirectChildren</BrowseFlag>\
316 <Filter>*</Filter>\
317 <StartingIndex>0</StartingIndex>\
318 <RequestedCount>25</RequestedCount>\
319 <SortCriteria></SortCriteria>\
320 </u:Browse>\
321 </s:Body>\
322 </s:Envelope>";
323
324 #[test]
325 fn test_a_browse_is_read() -> Outcome<()> {
326 let action = res!(Action::parse(
327 Some("\"urn:schemas-upnp-org:service:ContentDirectory:1#Browse\""),
328 A_BROWSE,
329 ));
330 assert_eq!(action.service, "urn:schemas-upnp-org:service:ContentDirectory:1");
331 assert_eq!(action.name, "Browse");
332 assert_eq!(res!(action.need("ObjectID")), "0");
333 assert_eq!(res!(action.need("BrowseFlag")), "BrowseDirectChildren");
334 assert_eq!(action.count("RequestedCount"), 25);
335 assert_eq!(action.count("StartingIndex"), 0);
336 assert_eq!(res!(action.need("SortCriteria")), "");
337 Ok(())
338 }
339
340 /// A control point that uses another prefix for the envelope, writes its
341 /// arguments as empty tags, and puts whitespace between them, is still
342 /// understood. All three are seen on real networks.
343 #[test]
344 fn test_a_differently_written_envelope_is_read() -> Outcome<()> {
345 let odd = "<SOAP-ENV:Envelope \
346 xmlns:SOAP-ENV=\"http://schemas.xmlsoap.org/soap/envelope/\">\n\
347 <SOAP-ENV:Body>\n\
348 <m:Browse xmlns:m=\"urn:schemas-upnp-org:service:ContentDirectory:1\">\n\
349 <ObjectID>0$D</ObjectID>\n\
350 <BrowseFlag>BrowseMetadata</BrowseFlag>\n\
351 <Filter/>\n\
352 <StartingIndex>0</StartingIndex>\n\
353 <RequestedCount>0</RequestedCount>\n\
354 <SortCriteria/>\n\
355 </m:Browse>\n\
356 </SOAP-ENV:Body>\n\
357 </SOAP-ENV:Envelope>";
358 let action = res!(Action::parse(None, odd));
359 assert_eq!(action.name, "Browse");
360 assert_eq!(res!(action.need("ObjectID")), "0$D");
361 assert_eq!(res!(action.need("Filter")), "");
362 assert_eq!(action.count("RequestedCount"), 0);
363 Ok(())
364 }
365
366 /// An escaped argument comes back as what it meant.
367 #[test]
368 fn test_an_escaped_argument_is_unescaped() -> Outcome<()> {
369 let body = "<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\">\
370 <s:Body><u:Search xmlns:u=\"urn:x\">\
371 <SearchCriteria>dc:title contains &quot;Rosie &amp; Chloe&quot;</SearchCriteria>\
372 </u:Search></s:Body></s:Envelope>";
373 let action = res!(Action::parse(None, body));
374 assert_eq!(action.name, "Search");
375 assert_eq!(res!(action.need("SearchCriteria")),
376 "dc:title contains \"Rosie & Chloe\"");
377 Ok(())
378 }
379
380 #[test]
381 fn test_what_is_not_an_invocation_is_refused() {
382 for bad in [
383 "",
384 "<html><body>not soap</body></html>",
385 "<s:Envelope xmlns:s=\"x\"><s:Body></s:Body></s:Envelope>",
386 ] {
387 assert!(Action::parse(None, bad).is_err(), "{:?} should not have parsed", bad);
388 }
389 assert!(parse_action_field("no-hash-here").is_err());
390 }
391
392 #[test]
393 fn test_the_action_field_splits_into_service_and_action() -> Outcome<()> {
394 let (service, action) = res!(parse_action_field(
395 "\"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\""));
396 assert_eq!(service, "urn:schemas-upnp-org:service:ConnectionManager:1");
397 assert_eq!(action, "GetProtocolInfo");
398 Ok(())
399 }
400
401 /// The answer is the request's shape with `Response` on the name, and its
402 /// arguments keep the order they were given.
403 #[test]
404 fn test_a_response_names_the_action_and_keeps_its_argument_order() -> Outcome<()> {
405 let xml = response(
406 "urn:schemas-upnp-org:service:ContentDirectory:1",
407 "Browse",
408 &[
409 ("Result", "<DIDL-Lite/>".to_string()),
410 ("NumberReturned", "3".to_string()),
411 ("TotalMatches", "40".to_string()),
412 ("UpdateID", "1".to_string()),
413 ],
414 );
415 assert!(xml.contains("<u:BrowseResponse xmlns:u=\
416 \"urn:schemas-upnp-org:service:ContentDirectory:1\">"));
417 // The DIDL-Lite payload goes inside a string, so it is escaped.
418 assert!(xml.contains("<Result>&lt;DIDL-Lite/&gt;</Result>"), "{}", xml);
419 let returned = match xml.find("<NumberReturned>") {
420 Some(at) => at,
421 None => return Err(err!("No NumberReturned in {}", xml; Test, Missing)),
422 };
423 let total = match xml.find("<TotalMatches>") {
424 Some(at) => at,
425 None => return Err(err!("No TotalMatches in {}", xml; Test, Missing)),
426 };
427 assert!(returned < total, "the arguments came out in the wrong order");
428 Ok(())
429 }
430
431 /// The envelope this module writes, it can read back.
432 #[test]
433 fn test_an_answer_survives_a_round_trip() -> Outcome<()> {
434 let xml = response("urn:x:service:ContentDirectory:1", "Browse", &[
435 ("Result", "<DIDL-Lite xmlns=\"y\"><item id=\"0$A$1\"/></DIDL-Lite>".to_string()),
436 ("NumberReturned", "1".to_string()),
437 ]);
438 let (name, args) = res!(parse_body(&xml));
439 assert_eq!(name, "BrowseResponse");
440 assert_eq!(args.get("NumberReturned").map(String::as_str), Some("1"));
441 assert_eq!(args.get("Result").map(String::as_str),
442 Some("<DIDL-Lite xmlns=\"y\"><item id=\"0$A$1\"/></DIDL-Lite>"));
443 Ok(())
444 }
445
446 #[test]
447 fn test_a_fault_carries_the_code_and_the_phrase_that_belongs_to_it() {
448 let xml = SoapError::NoSuchObject.envelope();
449 assert!(xml.contains("<errorCode>701</errorCode>"), "{}", xml);
450 assert!(xml.contains("<errorDescription>No such object</errorDescription>"), "{}", xml);
451 assert!(xml.contains("<faultstring>UPnPError</faultstring>"), "{}", xml);
452 assert_eq!(SoapError::InvalidArgs.code(), 402);
453 assert_eq!(SoapError::ActionFailed.code(), 501);
454 }
455}