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 | |
| 25 | use crate::upnp::{ |
| 26 | escape, |
| 27 | unescape, |
| 28 | NS_SOAP_ENVELOPE, |
| 29 | NS_UPNP_CONTROL, |
| 30 | SOAP_ENCODING, |
| 31 | }; |
| 32 | |
| 33 | use oxedyne_fe2o3_core::prelude::*; |
| 34 | |
| 35 | use std::collections::BTreeMap; |
| 36 | |
| 37 | |
| 38 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 39 | pub 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 | |
| 45 | impl 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 `#`. |
| 87 | pub 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 | |
| 97 | pub 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. |
| 122 | fn 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. |
| 160 | fn 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. |
| 207 | fn 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. |
| 222 | pub 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)] |
| 245 | pub 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 | |
| 254 | impl 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)] |
| 304 | mod 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 "Rosie & Chloe"</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><DIDL-Lite/></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 | } |