oxedyne/fe2o3/fe2o3_net/src/upnp/didl.rs
18.1 KiB, 96 runs
created by r1870400018:19713, 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 | //! DIDL-Lite: what a ContentDirectory `Browse` actually answers with |
| 2 | //! (ContentDirectory:1 §2.8, and DLNA guidelines part 1 §7.3 for the profiles). |
| 3 | //! |
| 4 | //! The answer to a browse is not the XML a control point sees first. It is a |
| 5 | //! *string* carried inside the `<Result>` argument of a SOAP response, so the |
| 6 | //! whole DIDL-Lite document is escaped once on the way out. That double layer is |
| 7 | //! the commonest way a media server's output is subtly wrong: an unescaped |
| 8 | //! ampersand in a photograph's file name breaks the outer document, and a |
| 9 | //! doubly-escaped one shows up on the television as `&`. |
| 10 | //! |
| 11 | //! # `protocolInfo`, and why it decides everything |
| 12 | //! |
| 13 | //! Each `<res>` element carries a `protocolInfo` of four colon-separated fields: |
| 14 | //! `http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_LRG;DLNA.ORG_OP=01;...`. The fourth |
| 15 | //! field is where a television decides whether it will play something at all. A |
| 16 | //! set that finds no `DLNA.ORG_PN` it recognises may show the item and refuse to |
| 17 | //! open it, and a set given a `DLNA.ORG_PN` that does not match the bytes behind |
| 18 | //! it fails in stranger ways still. [`ProtocolInfo`] therefore holds the pieces |
| 19 | //! apart, so that a caller trying profile strings against a real television |
| 20 | //! changes one table rather than a dozen format strings. |
| 21 | //! |
| 22 | //! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\ |
| 23 | //! Anthropic Claude |
| 24 | |
| 25 | use crate::upnp::{ |
| 26 | escape, |
| 27 | NS_DC, |
| 28 | NS_DIDL, |
| 29 | NS_DLNA_METADATA, |
| 30 | NS_UPNP, |
| 31 | }; |
| 32 | |
| 33 | use oxedyne_fe2o3_core::prelude::*; |
| 34 | |
| 35 | use std::fmt; |
| 36 | |
| 37 | |
| 38 | /// A control point decides how to *treat* an object from its `upnp:class`, not |
| 39 | /// from what is in it: a set showing a slideshow looks for `imageItem`, and one |
| 40 | /// browsing for something to play looks for `videoItem`. Held as an enum so that |
| 41 | /// a class cannot be misspelled at one call site out of six. |
| 42 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] |
| 43 | pub enum Class { |
| 44 | Container, // nothing more specific is true |
| 45 | StorageFolder, // what a filesystem tree becomes |
| 46 | PhotoAlbum, // what an album becomes |
| 47 | Photo, |
| 48 | Movie, |
| 49 | Other(&'static str), // a class this crate does not model |
| 50 | } |
| 51 | |
| 52 | impl fmt::Display for Class { |
| 53 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 54 | write!(f, "{}", match self { |
| 55 | Self::Container => "object.container", |
| 56 | Self::StorageFolder => "object.container.storageFolder", |
| 57 | Self::PhotoAlbum => "object.container.album.photoAlbum", |
| 58 | Self::Photo => "object.item.imageItem.photo", |
| 59 | Self::Movie => "object.item.videoItem.movie", |
| 60 | Self::Other(s) => s, |
| 61 | }) |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | /// The fourth field of a `protocolInfo`, which is where DLNA lives. |
| 66 | /// |
| 67 | /// Every part is optional, because a resource whose profile is not confidently |
| 68 | /// known is better described by `*` than by a guess: a television shown a profile |
| 69 | /// that does not match the bytes fails in a way that is hard to read, whereas one |
| 70 | /// shown no profile at all either plays the file or does not. |
| 71 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 72 | pub struct DlnaExtras { |
| 73 | pub profile: Option<String>, // `DLNA.ORG_PN`, e.g. `JPEG_LRG` |
| 74 | pub operations: Option<&'static str>, // `DLNA.ORG_OP`, time-seek then byte-seek |
| 75 | pub converted: Option<bool>, // `DLNA.ORG_CI`, 1 where the server made the bytes |
| 76 | pub flags: Option<&'static str>, // `DLNA.ORG_FLAGS`, see FLAGS_IMAGE and FLAGS_STREAMING |
| 77 | } |
| 78 | |
| 79 | //// `DLNA.ORG_OP` values. |
| 80 | // Two flags, time-seek then byte-seek. A server answering HTTP byte ranges and |
| 81 | // nothing else advertises the first of these. |
| 82 | pub const OP_BYTE_RANGE: &str = "01"; |
| 83 | pub const OP_NONE: &str = "00"; |
| 84 | |
| 85 | //// `DLNA.ORG_FLAGS` values. |
| 86 | // One thirty-two digit hexadecimal number whose meaning is all in its first |
| 87 | // eight digits; the rest are reserved and are zero. Both declare DLNA v1.5 and |
| 88 | // HTTP stalling; the image value is interactive transfer, and the film value |
| 89 | // adds streaming and background transfer. |
| 90 | pub const FLAGS_IMAGE: &str = "00D00000000000000000000000000000"; |
| 91 | pub const FLAGS_STREAMING: &str = "01700000000000000000000000000000"; |
| 92 | |
| 93 | impl DlnaExtras { |
| 94 | |
| 95 | /// The extras a rendition the server made itself carries. |
| 96 | pub fn rendition(profile: &str) -> Self { |
| 97 | Self { |
| 98 | profile: Some(profile.to_string()), |
| 99 | operations: Some(OP_BYTE_RANGE), |
| 100 | converted: Some(true), |
| 101 | flags: Some(FLAGS_IMAGE), |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | /// The extras an original served as it lies carries. |
| 106 | pub fn original(profile: Option<String>, streaming: bool) -> Self { |
| 107 | Self { |
| 108 | profile, |
| 109 | operations: Some(OP_BYTE_RANGE), |
| 110 | converted: Some(false), |
| 111 | flags: Some(if streaming { FLAGS_STREAMING } else { FLAGS_IMAGE }), |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | impl fmt::Display for DlnaExtras { |
| 117 | /// In the order the DLNA guidelines write it. An entirely empty set of |
| 118 | /// extras goes out as `*`, the field's way of saying nothing is claimed. |
| 119 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 120 | let mut parts: Vec<String> = Vec::with_capacity(4); |
| 121 | if let Some(pn) = &self.profile { |
| 122 | parts.push(fmt!("DLNA.ORG_PN={}", pn)); |
| 123 | } |
| 124 | if let Some(op) = self.operations { |
| 125 | parts.push(fmt!("DLNA.ORG_OP={}", op)); |
| 126 | } |
| 127 | if let Some(ci) = self.converted { |
| 128 | parts.push(fmt!("DLNA.ORG_CI={}", if ci { 1 } else { 0 })); |
| 129 | } |
| 130 | if let Some(flags) = self.flags { |
| 131 | parts.push(fmt!("DLNA.ORG_FLAGS={}", flags)); |
| 132 | } |
| 133 | if parts.is_empty() { |
| 134 | return write!(f, "*"); |
| 135 | } |
| 136 | write!(f, "{}", parts.join(";")) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | /// The whole `protocolInfo` attribute: how to fetch it, from where, what it is, |
| 141 | /// and what DLNA says about it. |
| 142 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 143 | pub struct ProtocolInfo { |
| 144 | pub protocol: String, // `http-get` for everything served over HTTP |
| 145 | pub network: String, // `*` everywhere, a field only because the syntax has four |
| 146 | pub content: String, // content type, e.g. `image/jpeg` |
| 147 | pub extras: DlnaExtras, |
| 148 | } |
| 149 | |
| 150 | impl ProtocolInfo { |
| 151 | |
| 152 | pub fn http_get<S: Into<String>>(content: S, extras: DlnaExtras) -> Self { |
| 153 | Self { |
| 154 | protocol: "http-get".to_string(), |
| 155 | network: "*".to_string(), |
| 156 | content: content.into(), |
| 157 | extras, |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | impl fmt::Display for ProtocolInfo { |
| 163 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 164 | write!(f, "{}:{}:{}:{}", self.protocol, self.network, self.content, self.extras) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /// One `<res>`. An object may carry several, and a control point picks whichever |
| 169 | /// it likes the look of, which is why a thumbnail and a full-size rendition of |
| 170 | /// the same photograph are two resources on one item rather than two items. |
| 171 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 172 | pub struct Resource { |
| 173 | pub uri: String, |
| 174 | pub info: ProtocolInfo, |
| 175 | pub size: Option<u64>, // where it is known without reading the bytes |
| 176 | pub resolution: Option<(u32, u32)>, // width then height, in pixels |
| 177 | pub duration: Option<String>, // running time, as `H:MM:SS.mmm` |
| 178 | pub depth: Option<u32>, // bits per pixel, which a few sets read and none require |
| 179 | } |
| 180 | |
| 181 | impl Resource { |
| 182 | |
| 183 | /// The `uri` and the `info` are the two things every resource must have. |
| 184 | pub fn new<S: Into<String>>(uri: S, info: ProtocolInfo) -> Self { |
| 185 | Self { |
| 186 | uri: uri.into(), |
| 187 | info, |
| 188 | size: None, |
| 189 | resolution: None, |
| 190 | duration: None, |
| 191 | depth: None, |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | pub fn sized(mut self, bytes: u64) -> Self { |
| 196 | self.size = Some(bytes); |
| 197 | self |
| 198 | } |
| 199 | |
| 200 | pub fn at(mut self, w: u32, h: u32) -> Self { |
| 201 | self.resolution = Some((w, h)); |
| 202 | self |
| 203 | } |
| 204 | |
| 205 | /// Every value written here is escaped, attributes included. |
| 206 | fn write(&self, out: &mut String) { |
| 207 | out.push_str(&fmt!("<res protocolInfo=\"{}\"", escape(&fmt!("{}", self.info)))); |
| 208 | if let Some(size) = self.size { |
| 209 | out.push_str(&fmt!(" size=\"{}\"", size)); |
| 210 | } |
| 211 | if let Some((w, h)) = self.resolution { |
| 212 | out.push_str(&fmt!(" resolution=\"{}x{}\"", w, h)); |
| 213 | } |
| 214 | if let Some(duration) = &self.duration { |
| 215 | out.push_str(&fmt!(" duration=\"{}\"", escape(duration))); |
| 216 | } |
| 217 | if let Some(depth) = self.depth { |
| 218 | out.push_str(&fmt!(" colorDepth=\"{}\"", depth)); |
| 219 | } |
| 220 | out.push_str(&fmt!(">{}</res>", escape(&self.uri))); |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /// Something a control point can browse into. |
| 225 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 226 | pub struct Container { |
| 227 | pub id: String, |
| 228 | pub parent: String, // the root's parent is `-1` |
| 229 | pub title: String, |
| 230 | pub class: Class, |
| 231 | // A control point draws a count from this, and some will not descend without one. |
| 232 | pub children: Option<u64>, // direct children, where that is cheap to know |
| 233 | pub restricted: bool, // a served library is restricted, going out as `1` |
| 234 | pub searchable: bool, // whether `Search` may be run against it |
| 235 | } |
| 236 | |
| 237 | impl Container { |
| 238 | |
| 239 | /// A restricted, unsearchable container, which is what a served library is |
| 240 | /// made of. |
| 241 | pub fn new<I, P, T>(id: I, parent: P, title: T, class: Class) -> Self |
| 242 | where |
| 243 | I: Into<String>, |
| 244 | P: Into<String>, |
| 245 | T: Into<String>, |
| 246 | { |
| 247 | Self { |
| 248 | id: id.into(), |
| 249 | parent: parent.into(), |
| 250 | title: title.into(), |
| 251 | class, |
| 252 | children: None, |
| 253 | restricted: true, |
| 254 | searchable: false, |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | pub fn holding(mut self, n: u64) -> Self { |
| 259 | self.children = Some(n); |
| 260 | self |
| 261 | } |
| 262 | |
| 263 | fn write(&self, out: &mut String) { |
| 264 | out.push_str(&fmt!("<container id=\"{}\" parentID=\"{}\" restricted=\"{}\"", |
| 265 | escape(&self.id), escape(&self.parent), if self.restricted { 1 } else { 0 })); |
| 266 | if let Some(n) = self.children { |
| 267 | out.push_str(&fmt!(" childCount=\"{}\"", n)); |
| 268 | } |
| 269 | out.push_str(&fmt!(" searchable=\"{}\">", if self.searchable { 1 } else { 0 })); |
| 270 | out.push_str(&fmt!("<dc:title>{}</dc:title>", escape(&self.title))); |
| 271 | out.push_str(&fmt!("<upnp:class>{}</upnp:class>", self.class)); |
| 272 | out.push_str("</container>"); |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | /// Something a control point can play or show. |
| 277 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 278 | pub struct Item { |
| 279 | pub id: String, |
| 280 | pub parent: String, // the container it was browsed from |
| 281 | pub title: String, |
| 282 | pub class: Class, |
| 283 | // A television that groups or sorts by date reads this and nothing else. |
| 284 | pub date: Option<String>, // `YYYY-MM-DDTHH:MM:SS` |
| 285 | pub art: Option<String>, // thumbnail, which most sets use and none need |
| 286 | pub art_profile: Option<String>, // goes out as `dlna:profileID` |
| 287 | pub restricted: bool, |
| 288 | pub resources: Vec<Resource>, // best first |
| 289 | } |
| 290 | |
| 291 | impl Item { |
| 292 | |
| 293 | /// A restricted item with no resources yet. |
| 294 | pub fn new<I, P, T>(id: I, parent: P, title: T, class: Class) -> Self |
| 295 | where |
| 296 | I: Into<String>, |
| 297 | P: Into<String>, |
| 298 | T: Into<String>, |
| 299 | { |
| 300 | Self { |
| 301 | id: id.into(), |
| 302 | parent: parent.into(), |
| 303 | title: title.into(), |
| 304 | class, |
| 305 | date: None, |
| 306 | art: None, |
| 307 | art_profile: None, |
| 308 | restricted: true, |
| 309 | resources: Vec::new(), |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | pub fn with(mut self, res: Resource) -> Self { |
| 314 | self.resources.push(res); |
| 315 | self |
| 316 | } |
| 317 | |
| 318 | pub fn taken<S: Into<String>>(mut self, when: S) -> Self { |
| 319 | self.date = Some(when.into()); |
| 320 | self |
| 321 | } |
| 322 | |
| 323 | pub fn thumbnail<S: Into<String>>(mut self, uri: S, profile: &str) -> Self { |
| 324 | self.art = Some(uri.into()); |
| 325 | self.art_profile = Some(profile.to_string()); |
| 326 | self |
| 327 | } |
| 328 | |
| 329 | fn write(&self, out: &mut String) { |
| 330 | out.push_str(&fmt!("<item id=\"{}\" parentID=\"{}\" restricted=\"{}\">", |
| 331 | escape(&self.id), escape(&self.parent), if self.restricted { 1 } else { 0 })); |
| 332 | out.push_str(&fmt!("<dc:title>{}</dc:title>", escape(&self.title))); |
| 333 | out.push_str(&fmt!("<upnp:class>{}</upnp:class>", self.class)); |
| 334 | if let Some(date) = &self.date { |
| 335 | out.push_str(&fmt!("<dc:date>{}</dc:date>", escape(date))); |
| 336 | } |
| 337 | if let Some(art) = &self.art { |
| 338 | match &self.art_profile { |
| 339 | Some(profile) => out.push_str(&fmt!( |
| 340 | "<upnp:albumArtURI dlna:profileID=\"{}\">{}</upnp:albumArtURI>", |
| 341 | escape(profile), escape(art))), |
| 342 | None => out.push_str(&fmt!( |
| 343 | "<upnp:albumArtURI>{}</upnp:albumArtURI>", escape(art))), |
| 344 | } |
| 345 | } |
| 346 | for res in &self.resources { |
| 347 | res.write(out); |
| 348 | } |
| 349 | out.push_str("</item>"); |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | #[derive(Clone, Debug, Eq, PartialEq)] |
| 354 | pub enum Object { |
| 355 | Container(Container), |
| 356 | Item(Item), |
| 357 | } |
| 358 | |
| 359 | /// A DIDL-Lite document: the objects, and the four namespaces they are spelled in. |
| 360 | #[derive(Clone, Debug, Default, Eq, PartialEq)] |
| 361 | pub struct Didl { |
| 362 | pub objects: Vec<Object>, // in the order they are to be shown |
| 363 | } |
| 364 | |
| 365 | impl Didl { |
| 366 | |
| 367 | /// An empty document, which is a perfectly good answer to a browse. |
| 368 | pub fn new() -> Self { |
| 369 | Self { objects: Vec::new() } |
| 370 | } |
| 371 | |
| 372 | pub fn container(&mut self, c: Container) { |
| 373 | self.objects.push(Object::Container(c)); |
| 374 | } |
| 375 | |
| 376 | pub fn item(&mut self, i: Item) { |
| 377 | self.objects.push(Object::Item(i)); |
| 378 | } |
| 379 | |
| 380 | /// A browse's `NumberReturned`. |
| 381 | pub fn len(&self) -> usize { |
| 382 | self.objects.len() |
| 383 | } |
| 384 | |
| 385 | pub fn is_empty(&self) -> bool { |
| 386 | self.objects.is_empty() |
| 387 | } |
| 388 | |
| 389 | /// No XML declaration: the string goes inside a SOAP argument, where a second |
| 390 | /// declaration would be in the middle of a document and make it unparseable. |
| 391 | pub fn to_xml(&self) -> String { |
| 392 | let mut out = String::with_capacity(256 + self.objects.len() * 400); |
| 393 | out.push_str(&fmt!( |
| 394 | "<DIDL-Lite xmlns=\"{}\" xmlns:dc=\"{}\" xmlns:upnp=\"{}\" xmlns:dlna=\"{}\">", |
| 395 | NS_DIDL, NS_DC, NS_UPNP, NS_DLNA_METADATA)); |
| 396 | for object in &self.objects { |
| 397 | match object { |
| 398 | Object::Container(c) => c.write(&mut out), |
| 399 | Object::Item(i) => i.write(&mut out), |
| 400 | } |
| 401 | } |
| 402 | out.push_str("</DIDL-Lite>"); |
| 403 | out |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | |
| 408 | #[cfg(test)] |
| 409 | mod tests { |
| 410 | use super::*; |
| 411 | |
| 412 | #[test] |
| 413 | fn test_a_protocol_info_has_its_four_fields_in_order() { |
| 414 | let info = ProtocolInfo::http_get("image/jpeg", DlnaExtras::rendition("JPEG_LRG")); |
| 415 | assert_eq!(fmt!("{}", info), |
| 416 | "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_LRG;DLNA.ORG_OP=01;\ |
| 417 | DLNA.ORG_CI=1;DLNA.ORG_FLAGS=00D00000000000000000000000000000"); |
| 418 | } |
| 419 | |
| 420 | /// A resource claiming nothing says so with a star, rather than with an empty |
| 421 | /// fourth field that some sets read as a malformed one. |
| 422 | #[test] |
| 423 | fn test_a_resource_that_claims_no_profile_says_star() { |
| 424 | let info = ProtocolInfo::http_get("video/quicktime", DlnaExtras::default()); |
| 425 | assert_eq!(fmt!("{}", info), "http-get:*:video/quicktime:*"); |
| 426 | } |
| 427 | |
| 428 | #[test] |
| 429 | fn test_a_container_carries_its_title_class_and_count() { |
| 430 | let mut didl = Didl::new(); |
| 431 | didl.container(Container::new("0$A", "0", "Albums", Class::StorageFolder).holding(7)); |
| 432 | let xml = didl.to_xml(); |
| 433 | assert!(xml.contains("<container id=\"0$A\" parentID=\"0\" restricted=\"1\" \ |
| 434 | childCount=\"7\" searchable=\"0\">"), "{}", xml); |
| 435 | assert!(xml.contains("<dc:title>Albums</dc:title>"), "{}", xml); |
| 436 | assert!(xml.contains("<upnp:class>object.container.storageFolder</upnp:class>"), |
| 437 | "{}", xml); |
| 438 | } |
| 439 | |
| 440 | #[test] |
| 441 | fn test_an_item_carries_its_resources_in_the_order_given() { |
| 442 | let item = Item::new("0$D$2016$03$abc", "0$D$2016$03", "IMG_0079", Class::Photo) |
| 443 | .taken("2016-03-04T10:22:31") |
| 444 | .thumbnail("http://h/t/abc.jpg", "JPEG_TN") |
| 445 | .with(Resource::new( |
| 446 | "http://h/r/abc.jpg", |
| 447 | ProtocolInfo::http_get("image/jpeg", DlnaExtras::rendition("JPEG_LRG")), |
| 448 | ).sized(482_113).at(1920, 1440)); |
| 449 | let mut didl = Didl::new(); |
| 450 | didl.item(item); |
| 451 | let xml = didl.to_xml(); |
| 452 | assert!(xml.contains("<dc:date>2016-03-04T10:22:31</dc:date>"), "{}", xml); |
| 453 | assert!(xml.contains("resolution=\"1920x1440\""), "{}", xml); |
| 454 | assert!(xml.contains("size=\"482113\""), "{}", xml); |
| 455 | assert!(xml.contains("<upnp:albumArtURI dlna:profileID=\"JPEG_TN\">"), "{}", xml); |
| 456 | } |
| 457 | |
| 458 | /// The name of a photograph is a file name, and a file name may hold any of |
| 459 | /// the five characters XML reserves. One of them unescaped breaks the whole |
| 460 | /// answer, not merely one item. |
| 461 | #[test] |
| 462 | fn test_a_title_with_reserved_characters_does_not_break_the_document() { |
| 463 | let mut didl = Didl::new(); |
| 464 | didl.container(Container::new( |
| 465 | "0$F$1", "0$F", "Rosie & Chloe <\"2016\">", Class::StorageFolder)); |
| 466 | let xml = didl.to_xml(); |
| 467 | assert!(xml.contains("Rosie & Chloe <"2016">"), "{}", xml); |
| 468 | // And exactly one unescaped angle bracket pair per element. |
| 469 | assert_eq!(xml.matches("<dc:title>").count(), 1); |
| 470 | } |
| 471 | |
| 472 | /// A URI is a string in an element body and its ampersands are escaped there |
| 473 | /// too, which is what a query string in a resource URL makes necessary. |
| 474 | #[test] |
| 475 | fn test_a_resource_uri_is_escaped() { |
| 476 | let mut didl = Didl::new(); |
| 477 | didl.item(Item::new("i", "0", "t", Class::Photo).with(Resource::new( |
| 478 | "http://h/r?a=1&b=2", |
| 479 | ProtocolInfo::http_get("image/jpeg", DlnaExtras::default()), |
| 480 | ))); |
| 481 | let xml = didl.to_xml(); |
| 482 | assert!(xml.contains("http://h/r?a=1&b=2"), "{}", xml); |
| 483 | } |
| 484 | |
| 485 | /// The document goes inside a SOAP string argument, so it must not carry an |
| 486 | /// XML declaration of its own. |
| 487 | #[test] |
| 488 | fn test_the_document_has_no_declaration() { |
| 489 | assert!(!Didl::new().to_xml().starts_with("<?xml")); |
| 490 | assert!(Didl::new().to_xml().starts_with("<DIDL-Lite xmlns=")); |
| 491 | } |
| 492 | } |