Oregami
Repositories/oxedyne/fe2o3

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
34pub mod device;
35pub mod didl;
36pub mod soap;
37
38use oxedyne_fe2o3_core::prelude::*;
39
40//// Description namespaces.
41// UPnP DA 2.0 §2.3 and §2.5.
42pub const NS_DEVICE: &str = "urn:schemas-upnp-org:device-1-0";
43pub 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.
46pub const NS_DLNA_DEVICE: &str = "urn:schemas-dlna-org:device-1-0";
47pub 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.
53pub const NS_DIDL: &str = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/";
54pub const NS_DC: &str = "http://purl.org/dc/elements/1.1/";
55pub 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.
60pub const NS_SOAP_ENVELOPE: &str = "http://schemas.xmlsoap.org/soap/envelope/";
61pub const SOAP_ENCODING: &str = "http://schemas.xmlsoap.org/soap/encoding/";
62pub 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.
67pub const DEVICE_MEDIA_SERVER: &str = "urn:schemas-upnp-org:device:MediaServer:1";
68pub const SERVICE_CONTENT_DIRECTORY: &str =
69 "urn:schemas-upnp-org:service:ContentDirectory:1";
70pub const SERVICE_CONNECTION_MANAGER: &str =
71 "urn:schemas-upnp-org:service:ConnectionManager:1";
72pub const ID_CONTENT_DIRECTORY: &str = "urn:upnp-org:serviceId:ContentDirectory";
73pub 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.
76pub const DLNA_DOC_DMS: &str = "DMS-1.50";
77// The quoting of the charset is part of what UPnP asks for.
78pub 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.
84pub 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("&amp;"),
89 '<' => out.push_str("&lt;"),
90 '>' => out.push_str("&gt;"),
91 '"' => out.push_str("&quot;"),
92 '\'' => out.push_str("&apos;"),
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.
109pub 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.
148fn 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.
180pub 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.
214fn 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)]
223mod 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 `&#38;` where this crate writes `&amp;`, 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&#38;b"), "a&b");
239 assert_eq!(unescape("a&#x26;b"), "a&b");
240 assert_eq!(unescape("caf&#233;"), "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("&notanentity;"), "&notanentity;");
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}