Oregami
Repositories/oxedyne/fe2o3

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

19.2 KiB, 66 runs

created by r1870400018:19711, 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//! The two documents a control point fetches before it asks anything: the device
2//! description a `LOCATION` points at, and the service description behind each
3//! `SCPDURL` in it (UPnP DA 2.0 §2.3 and §2.5).
4//!
5//! Both are static for a given device, so both are built once and served from
6//! memory. What is not static is the URLs inside them: a description reached at
7//! one address must name its services at that same address, or a control point on
8//! another machine follows a relative path from the wrong base. The URL fields
9//! here are therefore whatever the caller puts in them, absolute or relative, and
10//! the caller is the one that knows.
11//!
12//! # A stub is not optional
13//!
14//! A MediaServer must carry ConnectionManager as well as ContentDirectory, and a
15//! television checks that it is there before it will browse. Everything it is
16//! asked is answerable with a constant, which is why
17//! [`connection_manager_scpd`] exists and why a server that skips it is refused
18//! by sets that would otherwise have worked.
19//!
20//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
21//! Anthropic Claude
22
23use crate::upnp::{
24 escape,
25 DLNA_DOC_DMS,
26 NS_DEVICE,
27 NS_DLNA_DEVICE,
28 NS_SERVICE,
29};
30
31use oxedyne_fe2o3_core::prelude::*;
32
33
34/// One service on a device, as the description lists it.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct Service {
37 // The type says what the service is, the identifier which one it is on this
38 // device; the two look alike and are not interchangeable.
39 pub service_type: String,
40 pub service_id: String,
41 pub scpd_url: String, // the service description document
42 pub control_url: String, // where SOAP actions are posted
43 pub event_url: String, // subscriptions, required even where none is sent
44}
45
46impl Service {
47
48 /// A service whose three URLs sit under one prefix, which is the ordinary
49 /// arrangement and the one that cannot get them out of step.
50 pub fn under(service_type: &str, service_id: &str, base: &str) -> Self {
51 Self {
52 service_type: service_type.to_string(),
53 service_id: service_id.to_string(),
54 scpd_url: fmt!("{}/scpd.xml", base),
55 control_url: fmt!("{}/control", base),
56 event_url: fmt!("{}/event", base),
57 }
58 }
59
60 fn write(&self, out: &mut String) {
61 out.push_str("<service>");
62 out.push_str(&fmt!("<serviceType>{}</serviceType>", escape(&self.service_type)));
63 out.push_str(&fmt!("<serviceId>{}</serviceId>", escape(&self.service_id)));
64 out.push_str(&fmt!("<SCPDURL>{}</SCPDURL>", escape(&self.scpd_url)));
65 out.push_str(&fmt!("<controlURL>{}</controlURL>", escape(&self.control_url)));
66 out.push_str(&fmt!("<eventSubURL>{}</eventSubURL>", escape(&self.event_url)));
67 out.push_str("</service>");
68 }
69}
70
71/// A picture of the device, which a control point shows beside its name.
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct Icon {
74 pub mimetype: String, // e.g. `image/png`
75 pub width: u32, // pixels
76 pub height: u32, // pixels
77 pub depth: u32, // bits per pixel
78 pub url: String,
79}
80
81impl Icon {
82
83 fn write(&self, out: &mut String) {
84 out.push_str("<icon>");
85 out.push_str(&fmt!("<mimetype>{}</mimetype>", escape(&self.mimetype)));
86 out.push_str(&fmt!("<width>{}</width>", self.width));
87 out.push_str(&fmt!("<height>{}</height>", self.height));
88 out.push_str(&fmt!("<depth>{}</depth>", self.depth));
89 out.push_str(&fmt!("<url>{}</url>", escape(&self.url)));
90 out.push_str("</icon>");
91 }
92}
93
94/// A root device, and everything its description document says about it.
95#[derive(Clone, Debug, Default, Eq, PartialEq)]
96pub struct Device {
97 pub device_type: String, // e.g. crate::upnp::DEVICE_MEDIA_SERVER
98 // The name a television puts on the screen, and the one field a person ever
99 // sees, so it is the one worth choosing.
100 pub friendly_name: String,
101 pub manufacturer: String,
102 pub manufacturer_url: Option<String>,
103 pub model_description: Option<String>,
104 pub model_name: String,
105 pub model_number: Option<String>,
106 pub model_url: Option<String>,
107 pub serial_number: Option<String>,
108 // The unique device name, `uuid:...`. It must be the same string SSDP
109 // announces, and must not change between restarts.
110 pub udn: String,
111 pub presentation_url: Option<String>,
112 // The `<dlna:X_DLNADOC>` values, saying which DLNA device classes are claimed;
113 // DLNA_DOC_DMS is the one a media server declares.
114 pub dlna_docs: Vec<String>,
115 pub icons: Vec<Icon>,
116 pub services: Vec<Service>,
117}
118
119impl Device {
120
121 /// Carries the two services a media server must. `base` is the path prefix
122 /// the service URLs sit under, e.g. `/dlna`; `udn` is the full `uuid:...`
123 /// string.
124 pub fn media_server(friendly_name: &str, udn: &str, base: &str) -> Self {
125 Self {
126 device_type: super::DEVICE_MEDIA_SERVER.to_string(),
127 friendly_name: friendly_name.to_string(),
128 manufacturer: String::new(),
129 manufacturer_url: None,
130 model_description: None,
131 model_name: String::new(),
132 model_number: None,
133 model_url: None,
134 serial_number: None,
135 udn: udn.to_string(),
136 presentation_url: None,
137 dlna_docs: vec![DLNA_DOC_DMS.to_string()],
138 icons: Vec::new(),
139 services: vec![
140 Service::under(
141 super::SERVICE_CONTENT_DIRECTORY,
142 super::ID_CONTENT_DIRECTORY,
143 &fmt!("{}/cds", base),
144 ),
145 Service::under(
146 super::SERVICE_CONNECTION_MANAGER,
147 super::ID_CONNECTION_MANAGER,
148 &fmt!("{}/cms", base),
149 ),
150 ],
151 }
152 }
153
154 /// `config_id` is what SSDP announces as `CONFIGID.UPNP.ORG`, and must change
155 /// whenever this document does; a control point that has cached the old one
156 /// otherwise never fetches the new.
157 pub fn description(&self, config_id: u32) -> String {
158 let mut out = String::with_capacity(1536);
159 out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n");
160 out.push_str(&fmt!("<root xmlns=\"{}\" xmlns:dlna=\"{}\" configId=\"{}\">",
161 NS_DEVICE, NS_DLNA_DEVICE, config_id));
162 // The specification version is the UPnP architecture's, not the device's,
163 // and is 1.0 for every device a television speaks to.
164 out.push_str("<specVersion><major>1</major><minor>0</minor></specVersion>");
165 out.push_str("<device>");
166 out.push_str(&fmt!("<deviceType>{}</deviceType>", escape(&self.device_type)));
167 out.push_str(&fmt!("<friendlyName>{}</friendlyName>", escape(&self.friendly_name)));
168 out.push_str(&fmt!("<manufacturer>{}</manufacturer>", escape(&self.manufacturer)));
169 if let Some(url) = &self.manufacturer_url {
170 out.push_str(&fmt!("<manufacturerURL>{}</manufacturerURL>", escape(url)));
171 }
172 if let Some(text) = &self.model_description {
173 out.push_str(&fmt!("<modelDescription>{}</modelDescription>", escape(text)));
174 }
175 out.push_str(&fmt!("<modelName>{}</modelName>", escape(&self.model_name)));
176 if let Some(number) = &self.model_number {
177 out.push_str(&fmt!("<modelNumber>{}</modelNumber>", escape(number)));
178 }
179 if let Some(url) = &self.model_url {
180 out.push_str(&fmt!("<modelURL>{}</modelURL>", escape(url)));
181 }
182 if let Some(serial) = &self.serial_number {
183 out.push_str(&fmt!("<serialNumber>{}</serialNumber>", escape(serial)));
184 }
185 out.push_str(&fmt!("<UDN>{}</UDN>", escape(&self.udn)));
186 for doc in &self.dlna_docs {
187 out.push_str(&fmt!("<dlna:X_DLNADOC xmlns:dlna=\"{}\">{}</dlna:X_DLNADOC>",
188 NS_DLNA_DEVICE, escape(doc)));
189 }
190 if !self.icons.is_empty() {
191 out.push_str("<iconList>");
192 for icon in &self.icons {
193 icon.write(&mut out);
194 }
195 out.push_str("</iconList>");
196 }
197 out.push_str("<serviceList>");
198 for service in &self.services {
199 service.write(&mut out);
200 }
201 out.push_str("</serviceList>");
202 if let Some(url) = &self.presentation_url {
203 out.push_str(&fmt!("<presentationURL>{}</presentationURL>", escape(url)));
204 }
205 out.push_str("</device></root>");
206 out
207 }
208}
209
210/// The four actions a browsable server implements, and no more.
211///
212/// `Search` is deliberately absent: a service that lists an action must implement
213/// it, and a control point that finds `Search` here and gets a 401 back has been
214/// lied to. `SearchCapabilities` answering with an empty string is the correct way
215/// to say a server cannot search.
216pub fn content_directory_scpd() -> String {
217 fmt!("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n\
218<scpd xmlns=\"{}\">\
219<specVersion><major>1</major><minor>0</minor></specVersion>\
220<actionList>\
221<action><name>GetSearchCapabilities</name><argumentList>\
222<argument><name>SearchCaps</name><direction>out</direction>\
223<relatedStateVariable>SearchCapabilities</relatedStateVariable></argument>\
224</argumentList></action>\
225<action><name>GetSortCapabilities</name><argumentList>\
226<argument><name>SortCaps</name><direction>out</direction>\
227<relatedStateVariable>SortCapabilities</relatedStateVariable></argument>\
228</argumentList></action>\
229<action><name>GetSystemUpdateID</name><argumentList>\
230<argument><name>Id</name><direction>out</direction>\
231<relatedStateVariable>SystemUpdateID</relatedStateVariable></argument>\
232</argumentList></action>\
233<action><name>Browse</name><argumentList>\
234<argument><name>ObjectID</name><direction>in</direction>\
235<relatedStateVariable>A_ARG_TYPE_ObjectID</relatedStateVariable></argument>\
236<argument><name>BrowseFlag</name><direction>in</direction>\
237<relatedStateVariable>A_ARG_TYPE_BrowseFlag</relatedStateVariable></argument>\
238<argument><name>Filter</name><direction>in</direction>\
239<relatedStateVariable>A_ARG_TYPE_Filter</relatedStateVariable></argument>\
240<argument><name>StartingIndex</name><direction>in</direction>\
241<relatedStateVariable>A_ARG_TYPE_Index</relatedStateVariable></argument>\
242<argument><name>RequestedCount</name><direction>in</direction>\
243<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument>\
244<argument><name>SortCriteria</name><direction>in</direction>\
245<relatedStateVariable>A_ARG_TYPE_SortCriteria</relatedStateVariable></argument>\
246<argument><name>Result</name><direction>out</direction>\
247<relatedStateVariable>A_ARG_TYPE_Result</relatedStateVariable></argument>\
248<argument><name>NumberReturned</name><direction>out</direction>\
249<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument>\
250<argument><name>TotalMatches</name><direction>out</direction>\
251<relatedStateVariable>A_ARG_TYPE_Count</relatedStateVariable></argument>\
252<argument><name>UpdateID</name><direction>out</direction>\
253<relatedStateVariable>A_ARG_TYPE_UpdateID</relatedStateVariable></argument>\
254</argumentList></action>\
255</actionList>\
256<serviceStateTable>\
257<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_ObjectID</name>\
258<dataType>string</dataType></stateVariable>\
259<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_Result</name>\
260<dataType>string</dataType></stateVariable>\
261<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_BrowseFlag</name>\
262<dataType>string</dataType><allowedValueList>\
263<allowedValue>BrowseMetadata</allowedValue>\
264<allowedValue>BrowseDirectChildren</allowedValue>\
265</allowedValueList></stateVariable>\
266<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_Filter</name>\
267<dataType>string</dataType></stateVariable>\
268<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_SortCriteria</name>\
269<dataType>string</dataType></stateVariable>\
270<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_Index</name>\
271<dataType>ui4</dataType></stateVariable>\
272<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_Count</name>\
273<dataType>ui4</dataType></stateVariable>\
274<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_UpdateID</name>\
275<dataType>ui4</dataType></stateVariable>\
276<stateVariable sendEvents=\"no\"><name>SearchCapabilities</name>\
277<dataType>string</dataType></stateVariable>\
278<stateVariable sendEvents=\"no\"><name>SortCapabilities</name>\
279<dataType>string</dataType></stateVariable>\
280<stateVariable sendEvents=\"yes\"><name>SystemUpdateID</name>\
281<dataType>ui4</dataType></stateVariable>\
282</serviceStateTable>\
283</scpd>", NS_SERVICE)
284}
285
286/// Every action here is answerable with a constant on a server that streams over
287/// HTTP and holds no connections, which is why the service is a stub. It is not
288/// optional: a MediaServer without it is refused by sets that check.
289pub fn connection_manager_scpd() -> String {
290 fmt!("<?xml version=\"1.0\" encoding=\"utf-8\"?>\r\n\
291<scpd xmlns=\"{}\">\
292<specVersion><major>1</major><minor>0</minor></specVersion>\
293<actionList>\
294<action><name>GetProtocolInfo</name><argumentList>\
295<argument><name>Source</name><direction>out</direction>\
296<relatedStateVariable>SourceProtocolInfo</relatedStateVariable></argument>\
297<argument><name>Sink</name><direction>out</direction>\
298<relatedStateVariable>SinkProtocolInfo</relatedStateVariable></argument>\
299</argumentList></action>\
300<action><name>GetCurrentConnectionIDs</name><argumentList>\
301<argument><name>ConnectionIDs</name><direction>out</direction>\
302<relatedStateVariable>CurrentConnectionIDs</relatedStateVariable></argument>\
303</argumentList></action>\
304<action><name>GetCurrentConnectionInfo</name><argumentList>\
305<argument><name>ConnectionID</name><direction>in</direction>\
306<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable></argument>\
307<argument><name>RcsID</name><direction>out</direction>\
308<relatedStateVariable>A_ARG_TYPE_RcsID</relatedStateVariable></argument>\
309<argument><name>AVTransportID</name><direction>out</direction>\
310<relatedStateVariable>A_ARG_TYPE_AVTransportID</relatedStateVariable></argument>\
311<argument><name>ProtocolInfo</name><direction>out</direction>\
312<relatedStateVariable>A_ARG_TYPE_ProtocolInfo</relatedStateVariable></argument>\
313<argument><name>PeerConnectionManager</name><direction>out</direction>\
314<relatedStateVariable>A_ARG_TYPE_ConnectionManager</relatedStateVariable></argument>\
315<argument><name>PeerConnectionID</name><direction>out</direction>\
316<relatedStateVariable>A_ARG_TYPE_ConnectionID</relatedStateVariable></argument>\
317<argument><name>Direction</name><direction>out</direction>\
318<relatedStateVariable>A_ARG_TYPE_Direction</relatedStateVariable></argument>\
319<argument><name>Status</name><direction>out</direction>\
320<relatedStateVariable>A_ARG_TYPE_ConnectionStatus</relatedStateVariable></argument>\
321</argumentList></action>\
322</actionList>\
323<serviceStateTable>\
324<stateVariable sendEvents=\"yes\"><name>SourceProtocolInfo</name>\
325<dataType>string</dataType></stateVariable>\
326<stateVariable sendEvents=\"yes\"><name>SinkProtocolInfo</name>\
327<dataType>string</dataType></stateVariable>\
328<stateVariable sendEvents=\"yes\"><name>CurrentConnectionIDs</name>\
329<dataType>string</dataType></stateVariable>\
330<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_ConnectionStatus</name>\
331<dataType>string</dataType><allowedValueList>\
332<allowedValue>OK</allowedValue>\
333<allowedValue>ContentFormatMismatch</allowedValue>\
334<allowedValue>InsufficientBandwidth</allowedValue>\
335<allowedValue>UnreliableChannel</allowedValue>\
336<allowedValue>Unknown</allowedValue>\
337</allowedValueList></stateVariable>\
338<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_ConnectionManager</name>\
339<dataType>string</dataType></stateVariable>\
340<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_Direction</name>\
341<dataType>string</dataType><allowedValueList>\
342<allowedValue>Input</allowedValue>\
343<allowedValue>Output</allowedValue>\
344</allowedValueList></stateVariable>\
345<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_ProtocolInfo</name>\
346<dataType>string</dataType></stateVariable>\
347<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_ConnectionID</name>\
348<dataType>i4</dataType></stateVariable>\
349<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_AVTransportID</name>\
350<dataType>i4</dataType></stateVariable>\
351<stateVariable sendEvents=\"no\"><name>A_ARG_TYPE_RcsID</name>\
352<dataType>i4</dataType></stateVariable>\
353</serviceStateTable>\
354</scpd>", NS_SERVICE)
355}
356
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn test_a_media_server_carries_both_services_with_their_own_urls() -> Outcome<()> {
364 let device = Device::media_server("Ochre", "uuid:1-2-3", "/dlna");
365 req!(device.services.len(), 2usize);
366 let xml = device.description(1);
367 assert!(xml.contains("<deviceType>urn:schemas-upnp-org:device:MediaServer:1\
368 </deviceType>"), "{}", xml);
369 assert!(xml.contains("<UDN>uuid:1-2-3</UDN>"), "{}", xml);
370 assert!(xml.contains("<controlURL>/dlna/cds/control</controlURL>"), "{}", xml);
371 assert!(xml.contains("<controlURL>/dlna/cms/control</controlURL>"), "{}", xml);
372 assert!(xml.contains("<SCPDURL>/dlna/cds/scpd.xml</SCPDURL>"), "{}", xml);
373 assert!(xml.contains("<X_DLNADOC") || xml.contains("dlna:X_DLNADOC"), "{}", xml);
374 Ok(())
375 }
376
377 /// The type and the identifier are two different strings, and swapping them is
378 /// accepted by some control points and silently ignored by others.
379 #[test]
380 fn test_the_service_type_and_its_identifier_are_not_the_same_string() {
381 let device = Device::media_server("Ochre", "uuid:1-2-3", "/dlna");
382 let xml = device.description(1);
383 assert!(xml.contains("<serviceType>urn:schemas-upnp-org:service:\
384 ContentDirectory:1</serviceType>"), "{}", xml);
385 assert!(xml.contains("<serviceId>urn:upnp-org:serviceId:ContentDirectory\
386 </serviceId>"), "{}", xml);
387 }
388
389 /// A name a person chose may hold an ampersand, and one unescaped makes the
390 /// description unparseable and the device undiscoverable.
391 #[test]
392 fn test_a_friendly_name_is_escaped() {
393 let device = Device::media_server("Ben & Jerry's", "uuid:1", "/dlna");
394 let xml = device.description(1);
395 assert!(xml.contains("<friendlyName>Ben &amp; Jerry&apos;s</friendlyName>"),
396 "{}", xml);
397 }
398
399 /// Every action a service description lists must be one the service answers,
400 /// so `Search` is absent from a server that cannot search.
401 #[test]
402 fn test_the_content_directory_lists_only_what_it_implements() {
403 let scpd = content_directory_scpd();
404 for action in ["Browse", "GetSearchCapabilities", "GetSortCapabilities",
405 "GetSystemUpdateID"] {
406 assert!(scpd.contains(&fmt!("<name>{}</name>", action)),
407 "{} is missing from the description", action);
408 }
409 assert!(!scpd.contains("<name>Search</name>"),
410 "Search is listed by a service that does not implement it");
411 }
412
413 #[test]
414 fn test_the_connection_manager_lists_its_three_actions() {
415 let scpd = connection_manager_scpd();
416 for action in ["GetProtocolInfo", "GetCurrentConnectionIDs",
417 "GetCurrentConnectionInfo"] {
418 assert!(scpd.contains(&fmt!("<name>{}</name>", action)),
419 "{} is missing from the description", action);
420 }
421 }
422}