oxedyne/fe2o3/fe2o3_net/src/upnp/mod.rs
10.6 KiB, 40 runs
created by r1870400018:19715, 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 | //! UPnP: the part that begins once SSDP has said where to look. |
| 2 | //! |
| 3 | //! [`crate::ssdp`] carries a `LOCATION` to a control point. This module is what |
| 4 | //! sits at the other end of it, and what the control point says next: |
| 5 | //! |
| 6 | //! - a **device description** document, XML, listing the device and its services |
| 7 | //! ([`device`]), |
| 8 | //! - a **service description** (SCPD) per service, listing its actions |
| 9 | //! ([`device::content_directory_scpd`] and friends), |
| 10 | //! - **SOAP** requests and responses against the control URLs ([`soap`]), |
| 11 | //! - and, for a MediaServer, **DIDL-Lite** as the payload a `Browse` answers |
| 12 | //! with ([`didl`]). |
| 13 | //! |
| 14 | //! # Pure primitives |
| 15 | //! |
| 16 | //! Nothing here opens a socket, reads a file or knows what a library is. The |
| 17 | //! caller owns the transport: it routes its own HTTP, hands the request body to |
| 18 | //! [`soap::Action::parse`], builds the answer out of [`didl`] types and writes it |
| 19 | //! back. That keeps this usable from a synchronous server, an async one, or a |
| 20 | //! test with no server at all. |
| 21 | //! |
| 22 | //! # The two names of everything |
| 23 | //! |
| 24 | //! A UPnP service is named by a *type* (`urn:schemas-upnp-org:service:...`) and, |
| 25 | //! separately, by an *identifier* (`urn:upnp-org:serviceId:...`). They look alike |
| 26 | //! and are not interchangeable: the type says what the service is, the identifier |
| 27 | //! says which one it is on this device. A description that swaps them is accepted |
| 28 | //! by some control points and silently ignored by others, which is the failure |
| 29 | //! that eats an afternoon. |
| 30 | //! |
| 31 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 32 | //! Anthropic Claude |
| 33 | |
| 34 | pub mod device; |
| 35 | pub mod didl; |
| 36 | pub mod soap; |
| 37 | |
| 38 | use oxedyne_fe2o3_core::prelude::*; |
| 39 | |
| 40 | //// Description namespaces. |
| 41 | // UPnP DA 2.0 §2.3 and §2.5. |
| 42 | pub const NS_DEVICE: &str = "urn:schemas-upnp-org:device-1-0"; |
| 43 | pub const NS_SERVICE: &str = "urn:schemas-upnp-org:service-1-0"; |
| 44 | // The DLNA device namespace goes on `<dlna:X_DLNADOC>`, the metadata one on |
| 45 | // DIDL-Lite documents. |
| 46 | pub const NS_DLNA_DEVICE: &str = "urn:schemas-dlna-org:device-1-0"; |
| 47 | pub const NS_DLNA_METADATA: &str = "urn:schemas-dlna-org:metadata-1-0/"; |
| 48 | |
| 49 | //// Content namespaces. |
| 50 | // DIDL-Lite (ContentDirectory:1 §2.8), and the two vocabularies its elements |
| 51 | // draw on: Dublin Core for a title and a date, UPnP metadata for an object |
| 52 | // class. |
| 53 | pub const NS_DIDL: &str = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"; |
| 54 | pub const NS_DC: &str = "http://purl.org/dc/elements/1.1/"; |
| 55 | pub const NS_UPNP: &str = "urn:schemas-upnp-org:metadata-1-0/upnp/"; |
| 56 | |
| 57 | //// SOAP namespaces. |
| 58 | // SOAP 1.1 is the version UPnP speaks. The encoding style is required verbatim |
| 59 | // on the envelope, and the control namespace appears inside a fault. |
| 60 | pub const NS_SOAP_ENVELOPE: &str = "http://schemas.xmlsoap.org/soap/envelope/"; |
| 61 | pub const SOAP_ENCODING: &str = "http://schemas.xmlsoap.org/soap/encoding/"; |
| 62 | pub const NS_UPNP_CONTROL: &str = "urn:schemas-upnp-org:control-1-0"; |
| 63 | |
| 64 | //// Device and service names. |
| 65 | // The type and the identifier of a service are the pair the header describes, |
| 66 | // and each `SERVICE_` here has to be used with the `ID_` beside it. |
| 67 | pub const DEVICE_MEDIA_SERVER: &str = "urn:schemas-upnp-org:device:MediaServer:1"; |
| 68 | pub const SERVICE_CONTENT_DIRECTORY: &str = |
| 69 | "urn:schemas-upnp-org:service:ContentDirectory:1"; |
| 70 | pub const SERVICE_CONNECTION_MANAGER: &str = |
| 71 | "urn:schemas-upnp-org:service:ConnectionManager:1"; |
| 72 | pub const ID_CONTENT_DIRECTORY: &str = "urn:upnp-org:serviceId:ContentDirectory"; |
| 73 | pub const ID_CONNECTION_MANAGER: &str = "urn:upnp-org:serviceId:ConnectionManager"; |
| 74 | |
| 75 | // DLNA version 1.50 as a Digital Media Server, which is what the value spells. |
| 76 | pub const DLNA_DOC_DMS: &str = "DMS-1.50"; |
| 77 | // The quoting of the charset is part of what UPnP asks for. |
| 78 | pub const XML_CONTENT_TYPE: &str = "text/xml; charset=\"utf-8\""; |
| 79 | |
| 80 | |
| 81 | /// All five predefined entities are written, including the two that only matter |
| 82 | /// inside an attribute, because the same title goes into both places and a |
| 83 | /// separate attribute escaper is one more thing to forget to call. |
| 84 | pub fn escape(text: &str) -> String { |
| 85 | let mut out = String::with_capacity(text.len() + 16); |
| 86 | for c in text.chars() { |
| 87 | match c { |
| 88 | '&' => out.push_str("&"), |
| 89 | '<' => out.push_str("<"), |
| 90 | '>' => out.push_str(">"), |
| 91 | '"' => out.push_str("""), |
| 92 | '\'' => out.push_str("'"), |
| 93 | // XML 1.0 admits tab, newline and carriage return and no other |
| 94 | // control character. A title carrying one would make the whole |
| 95 | // document unparseable, so it is dropped rather than written. |
| 96 | c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}, |
| 97 | c => out.push(c), |
| 98 | } |
| 99 | } |
| 100 | out |
| 101 | } |
| 102 | |
| 103 | /// Undo [`escape`], including the numeric character references a control point |
| 104 | /// may have written instead of the named ones. |
| 105 | /// |
| 106 | /// Anything that is not a whole reference is taken literally, which is what a |
| 107 | /// forgiving parser does and what keeps a stray ampersand in a file name from |
| 108 | /// turning into an error. |
| 109 | pub fn unescape(text: &str) -> String { |
| 110 | let bytes = text.as_bytes(); |
| 111 | let mut out = String::with_capacity(text.len()); |
| 112 | let mut i = 0usize; |
| 113 | while i < bytes.len() { |
| 114 | if bytes[i] != b'&' { |
| 115 | // Push the whole run up to the next ampersand, so that multi-byte |
| 116 | // characters are copied without being taken apart. |
| 117 | let start = i; |
| 118 | while i < bytes.len() && bytes[i] != b'&' { |
| 119 | i += 1; |
| 120 | } |
| 121 | out.push_str(&text[start..i]); |
| 122 | continue; |
| 123 | } |
| 124 | match text[i..].find(';') { |
| 125 | Some(rel) if rel <= 10 => { |
| 126 | let entity = &text[i + 1..i + rel]; |
| 127 | match named_entity(entity) { |
| 128 | Some(c) => { |
| 129 | out.push(c); |
| 130 | i += rel + 1; |
| 131 | }, |
| 132 | None => { |
| 133 | out.push('&'); |
| 134 | i += 1; |
| 135 | }, |
| 136 | } |
| 137 | }, |
| 138 | _ => { |
| 139 | out.push('&'); |
| 140 | i += 1; |
| 141 | }, |
| 142 | } |
| 143 | } |
| 144 | out |
| 145 | } |
| 146 | |
| 147 | /// One entity body, without its ampersand and semicolon. |
| 148 | fn named_entity(entity: &str) -> Option<char> { |
| 149 | match entity { |
| 150 | "amp" => return Some('&'), |
| 151 | "lt" => return Some('<'), |
| 152 | "gt" => return Some('>'), |
| 153 | "quot" => return Some('"'), |
| 154 | "apos" => return Some('\''), |
| 155 | _ => {}, |
| 156 | } |
| 157 | let digits = match entity.strip_prefix('#') { |
| 158 | Some(d) => d, |
| 159 | None => return None, |
| 160 | }; |
| 161 | let code = match digits.strip_prefix('x').or_else(|| digits.strip_prefix('X')) { |
| 162 | Some(hex) => u32::from_str_radix(hex, 16).ok(), |
| 163 | None => digits.parse::<u32>().ok(), |
| 164 | }; |
| 165 | match code { |
| 166 | Some(n) => char::from_u32(n), |
| 167 | None => None, |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /// The targets and matching `USN` values a device announces over SSDP. |
| 172 | /// |
| 173 | /// A UPnP root device is not one announcement but several: `upnp:rootdevice`, |
| 174 | /// its own UUID, its device type, and one per service it carries. Every one of |
| 175 | /// them pairs a target with a `USN` built from the UUID, and the two must agree |
| 176 | /// or the device is discovered and then cannot be reached ([`crate::ssdp`]). |
| 177 | /// Building the pairs in one place is what keeps them agreeing. |
| 178 | /// |
| 179 | /// `uuid` is the bare identifier, without the `uuid:` prefix. |
| 180 | pub fn announcements( |
| 181 | uuid: &str, |
| 182 | device_type: &str, |
| 183 | services: &[&str], |
| 184 | ) |
| 185 | -> Vec<(crate::ssdp::Target, String)> |
| 186 | { |
| 187 | use crate::ssdp::Target; |
| 188 | let mut out = Vec::with_capacity(services.len() + 3); |
| 189 | // The root device announcement, whose USN is the UUID and the target. |
| 190 | out.push(( |
| 191 | Target::RootDevice, |
| 192 | fmt!("uuid:{}::upnp:rootdevice", uuid), |
| 193 | )); |
| 194 | // The device itself, whose USN is the UUID alone. |
| 195 | out.push(( |
| 196 | Target::Uuid(uuid.to_string()), |
| 197 | fmt!("uuid:{}", uuid), |
| 198 | )); |
| 199 | out.push(( |
| 200 | res_target(device_type), |
| 201 | fmt!("uuid:{}::{}", uuid, device_type), |
| 202 | )); |
| 203 | for service in services { |
| 204 | out.push(( |
| 205 | res_target(service), |
| 206 | fmt!("uuid:{}::{}", uuid, service), |
| 207 | )); |
| 208 | } |
| 209 | out |
| 210 | } |
| 211 | |
| 212 | /// A `urn:` string as a target, without going through a fallible parse: every |
| 213 | /// caller of [`announcements`] passes a constant from this module. |
| 214 | fn res_target(urn: &str) -> crate::ssdp::Target { |
| 215 | match urn.strip_prefix("urn:") { |
| 216 | Some(rest) => crate::ssdp::Target::Urn(rest.to_string()), |
| 217 | None => crate::ssdp::Target::Other(urn.to_string()), |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | |
| 222 | #[cfg(test)] |
| 223 | mod tests { |
| 224 | use super::*; |
| 225 | |
| 226 | #[test] |
| 227 | fn test_the_five_entities_go_out_and_come_back() { |
| 228 | let awkward = "Rosie & Chloe <\"2016\"> 'x'"; |
| 229 | let there = escape(awkward); |
| 230 | assert!(!there.contains('<'), "an angle bracket survived: {}", there); |
| 231 | assert_eq!(unescape(&there), awkward); |
| 232 | } |
| 233 | |
| 234 | /// A control point may write `&` where this crate writes `&`, and a |
| 235 | /// reader that only knows the named entities silently mangles a title. |
| 236 | #[test] |
| 237 | fn test_a_numeric_reference_is_read() { |
| 238 | assert_eq!(unescape("a&b"), "a&b"); |
| 239 | assert_eq!(unescape("a&b"), "a&b"); |
| 240 | assert_eq!(unescape("café"), "café"); |
| 241 | } |
| 242 | |
| 243 | /// A bare ampersand is not an entity and must not eat what follows it. |
| 244 | #[test] |
| 245 | fn test_what_is_not_an_entity_is_left_alone() { |
| 246 | assert_eq!(unescape("100% & rising"), "100% & rising"); |
| 247 | assert_eq!(unescape("¬anentity;"), "¬anentity;"); |
| 248 | assert_eq!(unescape("&"), "&"); |
| 249 | } |
| 250 | |
| 251 | /// A character XML 1.0 does not admit at all is dropped rather than written, |
| 252 | /// because one of them makes the whole document unparseable. |
| 253 | #[test] |
| 254 | fn test_a_control_character_does_not_reach_the_document() { |
| 255 | assert_eq!(escape("a\u{0}b\u{7}c"), "abc"); |
| 256 | assert_eq!(escape("a\tb\nc"), "a\tb\nc"); |
| 257 | } |
| 258 | |
| 259 | /// Every announcement pairs a target with a USN that names the same thing. |
| 260 | #[test] |
| 261 | fn test_an_announcement_names_one_thing_twice_and_agrees_with_itself() { |
| 262 | let pairs = announcements( |
| 263 | "4d696e69-444c-164e-9d41-0011328c0e2f", |
| 264 | DEVICE_MEDIA_SERVER, |
| 265 | &[SERVICE_CONTENT_DIRECTORY, SERVICE_CONNECTION_MANAGER], |
| 266 | ); |
| 267 | assert_eq!(pairs.len(), 5); |
| 268 | for (target, usn) in &pairs { |
| 269 | let named = fmt!("{}", target); |
| 270 | if named.starts_with("uuid:") { |
| 271 | // The device's own announcement: the USN is the UUID alone. |
| 272 | assert_eq!(usn, &named); |
| 273 | } else { |
| 274 | assert!(usn.ends_with(&fmt!("::{}", named)), |
| 275 | "{} does not end with the target {}", usn, named); |
| 276 | } |
| 277 | assert!(usn.starts_with("uuid:4d696e69-"), "{} names no device", usn); |
| 278 | } |
| 279 | } |
| 280 | } |