Skip to main content

sccp_protocol/phone/
xml.rs

1//! Typed XML documents exchanged with phone services.
2//!
3//! Known document schemas go through this Serde boundary so size, encoding,
4//! and document-type policy is applied consistently before a model is used.
5
6use std::collections::HashSet;
7use std::fmt;
8use std::str::FromStr;
9
10use quick_xml::XmlVersion;
11use quick_xml::events::Event;
12use quick_xml::reader::Reader;
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use thiserror::Error;
16
17use super::validation::{text_length_is_within, validate_count};
18use crate::{ConferenceId, ParticipantId};
19
20mod conference;
21mod display;
22mod document;
23mod image;
24mod menu;
25mod status;
26mod telemetry;
27
28pub use conference::*;
29pub use display::*;
30pub use document::PhoneXmlDocument;
31pub use image::*;
32pub use menu::*;
33pub use status::*;
34pub use telemetry::*;
35
36/// Maximum participants represented by a conference menu document.
37pub const CONFERENCE_LIST_MAX_PARTICIPANTS: usize = 16;
38/// Maximum encoded conference menu size, in bytes.
39pub const CONFERENCE_LIST_MAX_BYTES: usize = 2_000;
40/// Maximum entries accepted in a directory document.
41pub const PHONE_DIRECTORY_MAX_ENTRIES: usize = 32;
42/// Maximum encoded directory document size, in bytes.
43pub const PHONE_DIRECTORY_MAX_BYTES: usize = 8_192;
44/// Maximum selectable entries in a plain menu document.
45pub const PHONE_MENU_MAX_ITEMS: usize = 100;
46/// Maximum selectable entries in an icon menu document.
47pub const PHONE_ICON_MENU_MAX_ITEMS: usize = 32;
48/// Maximum embedded or referenced icons in an icon menu document.
49pub const PHONE_ICON_MENU_MAX_ICONS: usize = 10;
50/// Maximum encoded size shared by menu document families, in bytes.
51pub const PHONE_MENU_MAX_BYTES: usize = 64 * 1_024;
52/// Maximum Unicode character count for a text document body.
53pub const PHONE_TEXT_MAX_CHARS: usize = 4_000;
54/// Maximum encoded text document size, in bytes.
55pub const PHONE_TEXT_MAX_BYTES: usize = 32 * 1_024;
56/// Compatibility character bound for display profiles with smaller text capacity.
57pub const PHONE_TEXT_LEGACY_MAX_CHARS: usize = 1_024;
58/// Reserved application identifier used by text-display workflows.
59pub const PHONE_TEXT_APPLICATION_ID: u32 = 9_089;
60/// Maximum input controls in one input document.
61pub const PHONE_INPUT_MAX_ITEMS: usize = 5;
62/// Maximum encoded input document size, in bytes.
63pub const PHONE_INPUT_MAX_BYTES: usize = 32 * 1_024;
64/// Maximum actions in one execute document.
65pub const PHONE_EXECUTE_MAX_ITEMS: usize = 3;
66/// Maximum encoded execute document size, in bytes.
67pub const PHONE_EXECUTE_MAX_BYTES: usize = 8 * 1_024;
68/// Maximum decoded bitmap data in an inline image, in bytes.
69pub const PHONE_IMAGE_BITMAP_MAX_BYTES: usize = 2_162;
70/// Maximum touch entries in an inline-bitmap graphic menu.
71pub const PHONE_GRAPHIC_MENU_MAX_ITEMS: usize = 12;
72/// Maximum touch entries in an image-file graphic menu.
73pub const PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS: usize = 32;
74/// Maximum encoded size shared by image document families, in bytes.
75pub const PHONE_IMAGE_MAX_BYTES: usize = 64 * 1_024;
76/// Maximum decoded bitmap data in a status document, in bytes.
77pub const PHONE_STATUS_BITMAP_MAX_BYTES: usize = 557;
78/// Maximum encoded size shared by status document families, in bytes.
79pub const PHONE_STATUS_MAX_BYTES: usize = 8 * 1_024;
80/// Maximum encoded alarm telemetry size, in bytes.
81pub const PHONE_ALARM_MAX_BYTES: usize = 2_048;
82/// Maximum encoded location telemetry size, in bytes.
83pub const PHONE_LOCATION_MAX_BYTES: usize = 2_404;
84/// Reserved application identifier used by background-image workflows.
85pub const PHONE_BACKGROUND_APPLICATION_ID: u32 = 9_086;
86/// Maximum choices in a background-image list.
87pub const PHONE_BACKGROUND_LIST_MAX_ITEMS: usize = 50;
88/// Maximum encoded background-image list size, in bytes.
89pub const PHONE_BACKGROUND_LIST_MAX_BYTES: usize = 32 * 1_024;
90/// Maximum encoded background-control document size, in bytes.
91pub const PHONE_BACKGROUND_CONTROL_MAX_BYTES: usize = 2_000;
92/// Reserved application identifier used by ringtone workflows.
93pub const PHONE_RINGTONE_APPLICATION_ID: u32 = 9_087;
94/// Maximum encoded ringtone-control document size, in bytes.
95pub const PHONE_RINGTONE_MAX_BYTES: usize = 2_000;
96/// Maximum element nesting accepted before deserialization.
97pub const PHONE_XML_MAX_NESTING_DEPTH: usize = 32;
98const PHONE_DIRECTORY_TEXT_MAX_CHARS: usize = 32;
99const PHONE_XML_URL_MAX_CHARS: usize = 256;
100
101/// Schema, resource-limit, and serialization failures at the XML boundary.
102#[derive(Debug, Error)]
103pub enum PhoneXmlError {
104    /// A collection or encoded document crossed its explicit resource bound.
105    #[error("{kind} has {actual} entries or bytes; maximum is {maximum}")]
106    LimitExceeded {
107        kind: &'static str,
108        actual: usize,
109        maximum: usize,
110    },
111    /// Undeclared non-UTF-8 input reached a UTF-8-only boundary.
112    #[error("phone XML is not valid UTF-8: {0}")]
113    InvalidUtf8(#[source] std::str::Utf8Error),
114    /// A document-type declaration was rejected before entity expansion.
115    #[error("phone XML document types and entity declarations are not allowed")]
116    DocumentTypeForbidden,
117    /// An entity reference was undeclared or resolved to an invalid XML character.
118    #[error("phone XML contains an invalid or undeclared entity reference")]
119    InvalidEntity,
120    /// A recognized alarm root did not satisfy its typed schema.
121    #[error("supported phone alarm does not match its typed schema")]
122    InvalidAlarmSchema,
123    /// A recognized location root did not satisfy its typed schema.
124    #[error("supported phone location information does not match its typed schema")]
125    InvalidLocationSchema,
126    /// Element depth crossed [`PHONE_XML_MAX_NESTING_DEPTH`].
127    #[error("phone XML nesting exceeds the maximum depth of {maximum}")]
128    NestingTooDeep { maximum: usize },
129    /// Tokenization failed before typed deserialization.
130    #[error("phone XML is malformed: {0}")]
131    Malformed(#[source] quick_xml::Error),
132    /// The XML was well-formed but did not match the selected model.
133    #[error("phone XML does not match its typed schema: {0}")]
134    Deserialize(#[source] quick_xml::DeError),
135    /// The typed model could not be converted to XML.
136    #[error("phone XML could not be serialized: {0}")]
137    Serialize(#[source] quick_xml::SeError),
138    /// A formatting sink failed while receiving a bounded serialized document.
139    #[error("phone XML could not be written: {0}")]
140    Write(#[source] fmt::Error),
141    /// A model value violated a schema invariant beyond its Rust type.
142    #[error("{field} must be {expected}")]
143    InvalidField {
144        field: &'static str,
145        expected: &'static str,
146    },
147}
148
149/// Parses bounded UTF-8, US-ASCII, or ISO-8859-1 XML using the encoding named
150/// in the declaration. The XML reader, rather than an ad-hoc byte scan, owns
151/// decoding, entity policy, and nesting validation.
152pub fn from_bytes<T: DeserializeOwned>(
153    document: &[u8],
154    maximum_bytes: usize,
155) -> Result<T, PhoneXmlError> {
156    if document.len() > maximum_bytes {
157        return Err(PhoneXmlError::LimitExceeded {
158            kind: "phone XML document",
159            actual: document.len(),
160            maximum: maximum_bytes,
161        });
162    }
163    // Without an explicit legacy declaration, raw non-UTF-8 input remains a
164    // typed UTF-8 failure. quick-xml handles the declared ASCII/ISO decoder;
165    // this guard prevents arbitrary malformed bytes from being reported as a
166    // less useful generic deserialization error.
167    if let Err(error) = std::str::from_utf8(document)
168        && !declares_iso_8859_1(document)
169    {
170        return Err(PhoneXmlError::InvalidUtf8(error));
171    }
172    reject_document_type(document)?;
173    quick_xml::de::from_reader(decoding_reader(document)).map_err(PhoneXmlError::Deserialize)
174}
175
176fn decoding_reader(document: &[u8]) -> quick_xml::encoding::DecodingReader<&[u8]> {
177    let mut decoder = quick_xml::encoding::DecodingReader::new(document);
178    let mut declaration_reader = Reader::from_reader(document);
179    if let Ok(Event::Decl(declaration)) = declaration_reader.read_event()
180        && declaration
181            .encoding()
182            .and_then(Result::ok)
183            .is_some_and(|encoding| encoding.eq_ignore_ascii_case("iso-8859-1"))
184        && let Some(encoding) = declaration.encoder()
185    {
186        decoder.set_encoding(encoding);
187    }
188    decoder
189}
190
191fn declares_iso_8859_1(document: &[u8]) -> bool {
192    let mut reader = Reader::from_reader(document);
193    let Ok(Event::Decl(declaration)) = reader.read_event() else {
194        return false;
195    };
196    declaration
197        .encoding()
198        .and_then(Result::ok)
199        .is_some_and(|encoding| encoding.eq_ignore_ascii_case("iso-8859-1"))
200}
201
202/// Serializes a known Serde model and rejects an oversized result.
203pub fn to_string<T: Serialize>(
204    document: &T,
205    maximum_bytes: usize,
206) -> Result<String, PhoneXmlError> {
207    let xml = quick_xml::se::to_string(document).map_err(PhoneXmlError::Serialize)?;
208    if xml.len() > maximum_bytes {
209        return Err(PhoneXmlError::LimitExceeded {
210            kind: "phone XML document",
211            actual: xml.len(),
212            maximum: maximum_bytes,
213        });
214    }
215    Ok(xml)
216}
217
218/// Serializes through the bounded string boundary before touching the writer.
219pub fn to_writer<T: Serialize>(
220    mut writer: impl fmt::Write,
221    document: &T,
222    maximum_bytes: usize,
223) -> Result<(), PhoneXmlError> {
224    let xml = to_string(document, maximum_bytes)?;
225    writer.write_str(&xml).map_err(PhoneXmlError::Write)
226}
227
228fn reject_document_type(document: &[u8]) -> Result<(), PhoneXmlError> {
229    let mut reader = Reader::from_reader(decoding_reader(document));
230    let mut buffer = Vec::new();
231    let mut depth = 0usize;
232    loop {
233        match reader.read_event_into(&mut buffer) {
234            Ok(Event::DocType(_)) => return Err(PhoneXmlError::DocumentTypeForbidden),
235            Ok(Event::Start(element)) => {
236                validate_xml_attributes(&element)?;
237                depth = depth.saturating_add(1);
238                if depth > PHONE_XML_MAX_NESTING_DEPTH {
239                    return Err(PhoneXmlError::NestingTooDeep {
240                        maximum: PHONE_XML_MAX_NESTING_DEPTH,
241                    });
242                }
243            }
244            Ok(Event::Empty(element)) => validate_xml_attributes(&element)?,
245            Ok(Event::GeneralRef(reference)) => {
246                let reference = reference.xml_content(XmlVersion::Implicit1_0);
247                let escaped = format!("&{reference};");
248                let resolved = quick_xml::escape::unescape(&escaped)
249                    .map_err(|_| PhoneXmlError::InvalidEntity)?;
250                if !has_only_xml_characters(&resolved) {
251                    return Err(PhoneXmlError::InvalidEntity);
252                }
253            }
254            Ok(Event::Text(text)) => {
255                let text = text.xml_content(XmlVersion::Implicit1_0);
256                if !has_only_xml_characters(&text) {
257                    return Err(PhoneXmlError::InvalidEntity);
258                }
259            }
260            Ok(Event::CData(text)) => {
261                let text = text.xml_content(XmlVersion::Implicit1_0);
262                if !has_only_xml_characters(&text) {
263                    return Err(PhoneXmlError::InvalidEntity);
264                }
265            }
266            Ok(Event::End(_)) => depth = depth.saturating_sub(1),
267            Ok(Event::Eof) => return Ok(()),
268            Ok(_) => {}
269            Err(error) => return Err(PhoneXmlError::Malformed(error)),
270        }
271        buffer.clear();
272    }
273}
274
275fn validate_xml_attributes(
276    element: &quick_xml::events::BytesStart<'_>,
277) -> Result<(), PhoneXmlError> {
278    for attribute in element.attributes() {
279        let attribute = attribute
280            .map_err(quick_xml::Error::from)
281            .map_err(PhoneXmlError::Malformed)?;
282        let value = attribute
283            .normalized_value(XmlVersion::Implicit1_0)
284            .map_err(|_| PhoneXmlError::InvalidEntity)?;
285        if !has_only_xml_characters(&value) {
286            return Err(PhoneXmlError::InvalidEntity);
287        }
288    }
289    Ok(())
290}
291
292macro_rules! impl_validated_string_value {
293    ($($value:ty),+ $(,)?) => {
294        $(
295            impl AsRef<str> for $value {
296                fn as_ref(&self) -> &str {
297                    self.as_str()
298                }
299            }
300
301            impl TryFrom<String> for $value {
302                type Error = PhoneXmlError;
303
304                fn try_from(value: String) -> Result<Self, Self::Error> {
305                    Self::new(value)
306                }
307            }
308
309            impl FromStr for $value {
310                type Err = PhoneXmlError;
311
312                fn from_str(value: &str) -> Result<Self, Self::Err> {
313                    Self::new(value)
314                }
315            }
316        )+
317    };
318}
319
320impl_validated_string_value!(
321    PhoneInputParameterName,
322    PhoneExecuteUrl,
323    PhoneImageUrl,
324    PhoneBackgroundTftpUrl,
325    PhoneBackgroundHttpUrl,
326    PhoneRingtoneUrl,
327);
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn document_contract_rejects_a_valid_but_wrong_schema_root() {
335        let menu =
336            br#"<CiscoIPPhoneMenu><Title>Menu</Title><Prompt>Choose</Prompt></CiscoIPPhoneMenu>"#;
337
338        assert!(matches!(
339            CiscoIpPhoneText::from_xml(menu),
340            Err(PhoneXmlError::InvalidField {
341                field: "phone XML document root",
342                ..
343            })
344        ));
345    }
346
347    #[test]
348    fn typed_boundary_round_trips_escaped_menu_text() {
349        let expected = CiscoIpPhoneMenu::new(
350            "Support <East> & West",
351            "Choose \"one\"",
352            vec![CiscoIpPhoneMenuItem {
353                name: Some("Alice & Bob".into()),
354                url: Some("UserData:1:0:select/701?lot=east&side=west".into()),
355            }],
356        )
357        .unwrap();
358        let xml = to_string(&expected, 2_000).unwrap();
359        assert!(xml.contains("Support &lt;East&gt; &amp; West"));
360        assert_eq!(
361            from_bytes::<CiscoIpPhoneMenu>(xml.as_bytes(), 2_000).unwrap(),
362            expected
363        );
364    }
365
366    #[test]
367    fn typed_boundary_rejects_size_utf8_doctype_entities_and_malformed_xml() {
368        assert!(matches!(
369            from_bytes::<CiscoIpPhoneMenu>(b"<CiscoIPPhoneMenu/>", 5),
370            Err(PhoneXmlError::LimitExceeded { .. })
371        ));
372        assert!(matches!(
373            from_bytes::<CiscoIpPhoneMenu>(&[0xff], 5),
374            Err(PhoneXmlError::InvalidUtf8(_))
375        ));
376        let dtd = br#"<!DOCTYPE menu [<!ENTITY name "caller">]><CiscoIPPhoneMenu><Title>&name;</Title><Prompt/></CiscoIPPhoneMenu>"#;
377        assert!(matches!(
378            from_bytes::<CiscoIpPhoneMenu>(dtd, 2_000),
379            Err(PhoneXmlError::DocumentTypeForbidden)
380        ));
381        let external = br#"<!DOCTYPE menu SYSTEM "file:///untrusted/menu.dtd"><CiscoIPPhoneMenu><Title/><Prompt/></CiscoIPPhoneMenu>"#;
382        assert!(matches!(
383            from_bytes::<CiscoIpPhoneMenu>(external, 2_000),
384            Err(PhoneXmlError::DocumentTypeForbidden)
385        ));
386        assert!(matches!(
387            from_bytes::<CiscoIpPhoneMenu>(
388                b"<CiscoIPPhoneMenu><Title>&custom;</Title><Prompt/></CiscoIPPhoneMenu>",
389                2_000,
390            ),
391            Err(PhoneXmlError::InvalidEntity)
392        ));
393        assert!(from_bytes::<CiscoIpPhoneMenu>(b"<CiscoIPPhoneMenu>", 2_000).is_err());
394
395        let mut oversized = CiscoIpPhoneMenu::new("Menu", "Choose", Vec::new()).unwrap();
396        oversized.title = Some("x".repeat(100));
397        assert!(matches!(
398            to_string(&oversized, 10),
399            Err(PhoneXmlError::LimitExceeded { .. })
400        ));
401    }
402
403    fn complete_text_document() -> CiscoIpPhoneText {
404        CiscoIpPhoneText {
405            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
406            application_id: Some("text-service".into()),
407            on_focus_lost: Some("Notify:focus?state=lost&view=text".into()),
408            on_focus_gained: Some("Notify:focus?state=gained".into()),
409            on_minimized: Some("Notify:minimized".into()),
410            on_closed: Some("Notify:closed".into()),
411            title: Some("Message <East> & West".into()),
412            prompt: Some("Read & refresh".into()),
413            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
414                name: Some("Refresh".into()),
415                position: PhoneSoftKeyPosition::new(1).unwrap(),
416                url: Some("https://pbx.example/text?id=7&view=full".into()),
417                url_down: Some("SoftKey:Update".into()),
418            }],
419            key_items: vec![CiscoIpPhoneKeyItem {
420                key: PhoneXmlKey::NavBack,
421                url: Some("SoftKey:Exit".into()),
422                url_down: None,
423            }],
424            text: Some("Line one\nCafé <ready> & waiting\t✓".into()),
425        }
426    }
427
428    #[test]
429    fn text_document_round_trips_controls_order_utf8_and_escaping() {
430        let expected = complete_text_document();
431        let xml = expected.to_xml().unwrap();
432        assert!(xml.contains("Message &lt;East&gt; &amp; West"));
433        assert!(xml.contains("Café &lt;ready&gt; &amp; waiting"));
434        assert!(xml.contains("id=7&amp;view=full"));
435        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
436        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<Text>").unwrap());
437        assert_eq!(
438            CiscoIpPhoneText::from_xml(xml.as_bytes()).unwrap(),
439            expected
440        );
441
442        let minimal = CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText/>").unwrap();
443        assert!(minimal.text.is_none());
444        let empty =
445            CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText><Text></Text></CiscoIPPhoneText>")
446                .unwrap();
447        assert_eq!(empty.text.as_deref(), Some(""));
448    }
449
450    #[test]
451    fn text_document_enforces_body_control_soft_key_and_refresh_bounds() {
452        let exact = CiscoIpPhoneText::new("Title", "Prompt", "é".repeat(PHONE_TEXT_MAX_CHARS));
453        assert!(exact.is_ok());
454        assert!(matches!(
455            CiscoIpPhoneText::new("Title", "Prompt", "x".repeat(PHONE_TEXT_MAX_CHARS + 1),),
456            Err(PhoneXmlError::InvalidField {
457                field: "phone text body",
458                ..
459            })
460        ));
461        let mut invalid = complete_text_document();
462        invalid.text = Some("not\u{1} XML".into());
463        assert!(matches!(
464            invalid.to_xml(),
465            Err(PhoneXmlError::InvalidField {
466                field: "phone text body",
467                ..
468            })
469        ));
470        invalid = complete_text_document();
471        invalid.soft_keys[0].position = PhoneSoftKeyPosition::new(16).unwrap();
472        assert!(invalid.to_xml().is_ok());
473        invalid = complete_text_document();
474        invalid.soft_keys[0].url = Some("x".repeat(PHONE_XML_URL_MAX_CHARS + 1));
475        assert!(matches!(
476            invalid.to_xml(),
477            Err(PhoneXmlError::InvalidField { .. })
478        ));
479
480        assert_eq!(PhoneServicePriority::LOW.wire(), 0);
481        assert_eq!(PhoneServicePriority::NORMAL.wire(), 1);
482        assert_eq!(PhoneServicePriority::HIGH.wire(), 2);
483        assert_eq!(
484            PhoneServicePriority::default(),
485            PhoneServicePriority::NORMAL
486        );
487        assert!(PhoneServicePriority::new(3).is_err());
488        let refresh = PhoneXmlRefresh::new(15, "https://pbx.example/text?page=2").unwrap();
489        assert_eq!(refresh.delay_seconds(), 15);
490        assert_eq!(refresh.url(), "https://pbx.example/text?page=2");
491        assert_eq!(
492            refresh.http_header_value(),
493            "15;url=https://pbx.example/text?page=2"
494        );
495        assert!(PhoneXmlRefresh::new(0, "").is_err());
496        assert!(PhoneXmlRefresh::new(0, "x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
497        assert!(PhoneXmlRefresh::new(0, "https://example.test/é").is_err());
498        assert!(PhoneXmlRefresh::new(0, "https://example.test/not encoded").is_err());
499        assert!(PhoneXmlRefresh::new(0, "https://example.test/\r\nInjected: yes").is_err());
500    }
501
502    #[test]
503    fn text_parser_rejects_wrong_root_malformed_oversize_nesting_dtd_and_entities() {
504        assert!(CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
505        assert!(
506            CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText><Unknown/></CiscoIPPhoneText>",)
507                .is_err()
508        );
509        assert!(CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText><Text>").is_err());
510        assert!(matches!(
511            CiscoIpPhoneText::from_xml(&[0xff]),
512            Err(PhoneXmlError::InvalidUtf8(_))
513        ));
514        assert!(matches!(
515            CiscoIpPhoneText::from_xml(
516                b"<!DOCTYPE text [<!ENTITY value 'secret'>]><CiscoIPPhoneText><Text>&value;</Text></CiscoIPPhoneText>",
517            ),
518            Err(PhoneXmlError::DocumentTypeForbidden)
519        ));
520        assert!(
521            CiscoIpPhoneText::from_xml(
522                b"<CiscoIPPhoneText><Text>&unknown;</Text></CiscoIPPhoneText>",
523            )
524            .is_err()
525        );
526        assert!(matches!(
527            complete_text_document().to_xml_with_limit(10),
528            Err(PhoneXmlError::LimitExceeded { .. })
529        ));
530
531        let nested = format!(
532            "<CiscoIPPhoneText>{}<Text>body</Text>{}</CiscoIPPhoneText>",
533            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
534            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
535        );
536        assert!(matches!(
537            CiscoIpPhoneText::from_xml(nested.as_bytes()),
538            Err(PhoneXmlError::NestingTooDeep { .. })
539        ));
540
541        #[derive(Debug)]
542        struct FailingWriter;
543        impl fmt::Write for FailingWriter {
544            fn write_str(&mut self, _value: &str) -> fmt::Result {
545                Err(fmt::Error)
546            }
547        }
548        assert!(matches!(
549            to_writer(
550                FailingWriter,
551                &complete_text_document(),
552                PHONE_TEXT_MAX_BYTES,
553            ),
554            Err(PhoneXmlError::Write(_))
555        ));
556    }
557
558    fn complete_input_document() -> CiscoIpPhoneInput {
559        CiscoIpPhoneInput {
560            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
561            application_id: Some("conference-invite".into()),
562            on_focus_lost: Some("Notify:input?focus=lost&view=invite".into()),
563            on_focus_gained: Some("Notify:input?focus=gained".into()),
564            on_minimized: Some("Notify:input?state=minimized".into()),
565            on_closed: Some("Notify:input?state=closed".into()),
566            title: Some("Invite <guest>".into()),
567            prompt: Some("Enter name & number".into()),
568            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
569                name: Some("Submit".into()),
570                position: PhoneSoftKeyPosition::new(1).unwrap(),
571                url: Some("SoftKey:Submit".into()),
572                url_down: Some("Notify:submit?state=down".into()),
573            }],
574            key_items: vec![CiscoIpPhoneKeyItem {
575                key: PhoneXmlKey::NavBack,
576                url: Some("SoftKey:Exit".into()),
577                url_down: None,
578            }],
579            url: "UserData:9091:0:conference/7/invite?source=phone&mode=full".into(),
580            items: vec![
581                CiscoIpPhoneInputItem {
582                    display_name: Some("Number".into()),
583                    parameter: PhoneInputParameterName::new("NUMBER").unwrap(),
584                    flags: PhoneInputFlags::Telephone,
585                    default_value: Some("+1 555 0100".into()),
586                },
587                CiscoIpPhoneInputItem {
588                    display_name: Some("Name & team".into()),
589                    parameter: PhoneInputParameterName::new("NAME&TEAM").unwrap(),
590                    flags: PhoneInputFlags::AlphabeticPassword,
591                    default_value: Some("Café <guest>".into()),
592                },
593            ],
594        }
595    }
596
597    #[test]
598    fn input_document_round_trips_every_control_in_schema_order_and_escapes_values() {
599        let expected = complete_input_document();
600        let xml = expected.to_xml().unwrap();
601        assert!(xml.contains("Invite &lt;guest&gt;"));
602        assert!(xml.contains("Enter name &amp; number"));
603        assert!(xml.contains("NAME&amp;TEAM"));
604        assert!(xml.contains("Café &lt;guest&gt;"));
605        assert!(xml.contains("source=phone&amp;mode=full"));
606        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
607        let submission = xml.find("<URL>UserData:").unwrap();
608        assert!(xml.find("<KeyItem>").unwrap() < submission);
609        assert!(submission < xml.find("<InputItem>").unwrap());
610        assert_eq!(
611            CiscoIpPhoneInput::from_xml(xml.as_bytes()).unwrap(),
612            expected
613        );
614
615        let minimal = CiscoIpPhoneInput::from_xml(
616            b"<CiscoIPPhoneInput><URL>submit</URL></CiscoIPPhoneInput>",
617        )
618        .unwrap();
619        assert!(minimal.items.is_empty());
620        assert_eq!(minimal.url, "submit");
621    }
622
623    #[test]
624    fn input_flags_round_trip_every_accepted_schema_value() {
625        let codes = [
626            "A", "T", "N", "E", "U", "L", "AP", "TP", "NP", "EP", "UP", "LP", "PA", "PT", "PN",
627            "PE", "PU", "PL",
628        ];
629        for (flags, code) in PhoneInputFlags::ALL.into_iter().zip(codes) {
630            let document = CiscoIpPhoneInput::new(
631                "Input",
632                "Enter value",
633                "submit",
634                vec![CiscoIpPhoneInputItem {
635                    display_name: None,
636                    parameter: PhoneInputParameterName::new("VALUE").unwrap(),
637                    flags,
638                    default_value: Some(String::new()),
639                }],
640            )
641            .unwrap();
642            let xml = document.to_xml().unwrap();
643            assert!(xml.contains(&format!("<InputFlags>{code}</InputFlags>")));
644            assert_eq!(
645                CiscoIpPhoneInput::from_xml(xml.as_bytes()).unwrap(),
646                document
647            );
648        }
649    }
650
651    #[test]
652    fn input_document_enforces_field_collection_and_display_bounds() {
653        assert!(PhoneInputParameterName::new("").is_err());
654        assert!(PhoneInputParameterName::new("x".repeat(33)).is_err());
655        assert!(PhoneInputParameterName::new("not\u{1}xml").is_err());
656
657        let exact = CiscoIpPhoneInput::new(
658            "t".repeat(32),
659            "p".repeat(32),
660            "u".repeat(PHONE_XML_URL_MAX_CHARS),
661            vec![CiscoIpPhoneInputItem {
662                display_name: Some("n".repeat(32)),
663                parameter: PhoneInputParameterName::new("q".repeat(32)).unwrap(),
664                flags: PhoneInputFlags::Numeric,
665                default_value: Some("d".repeat(32)),
666            }],
667        );
668        assert!(exact.is_ok());
669
670        let too_many = (0..=PHONE_INPUT_MAX_ITEMS)
671            .map(|index| CiscoIpPhoneInputItem {
672                display_name: None,
673                parameter: PhoneInputParameterName::new(format!("VALUE{index}")).unwrap(),
674                flags: PhoneInputFlags::Alphabetic,
675                default_value: None,
676            })
677            .collect();
678        assert!(matches!(
679            CiscoIpPhoneInput::new("Input", "Prompt", "submit", too_many),
680            Err(PhoneXmlError::LimitExceeded {
681                kind: "phone input fields",
682                maximum: PHONE_INPUT_MAX_ITEMS,
683                ..
684            })
685        ));
686
687        for invalid in [
688            CiscoIpPhoneInput::new("x".repeat(33), "Prompt", "submit", Vec::new()),
689            CiscoIpPhoneInput::new("Input", "x".repeat(33), "submit", Vec::new()),
690            CiscoIpPhoneInput::new("Input", "Prompt", "", Vec::new()),
691            CiscoIpPhoneInput::new(
692                "Input",
693                "Prompt",
694                "x".repeat(PHONE_XML_URL_MAX_CHARS + 1),
695                Vec::new(),
696            ),
697        ] {
698            assert!(invalid.is_err());
699        }
700
701        let mut invalid = complete_input_document();
702        invalid.items[0].display_name = Some("x".repeat(33));
703        assert!(invalid.to_xml().is_err());
704        invalid = complete_input_document();
705        invalid.items[0].default_value = Some("x".repeat(33));
706        assert!(invalid.to_xml().is_err());
707        assert!(PhoneSoftKeyPosition::new(0).is_err());
708    }
709
710    #[test]
711    fn input_parser_rejects_wrong_root_unknown_flag_malformed_and_unsafe_documents() {
712        assert!(CiscoIpPhoneInput::from_xml(b"<CiscoIPPhoneText/>").is_err());
713        assert!(CiscoIpPhoneInput::from_xml(b"<CiscoIPPhoneInput/>").is_err());
714        assert!(
715            CiscoIpPhoneInput::from_xml(
716                b"<CiscoIPPhoneInput><Unknown/><URL>submit</URL></CiscoIPPhoneInput>"
717            )
718            .is_err()
719        );
720        assert!(CiscoIpPhoneInput::from_xml(
721            b"<CiscoIPPhoneInput><URL>submit</URL><InputItem><QueryStringParam>q</QueryStringParam><InputFlags>Q</InputFlags></InputItem></CiscoIPPhoneInput>"
722        )
723        .is_err());
724        assert!(CiscoIpPhoneInput::from_xml(b"<CiscoIPPhoneInput><URL>").is_err());
725        assert!(matches!(
726            CiscoIpPhoneInput::from_xml(&[0xff]),
727            Err(PhoneXmlError::InvalidUtf8(_))
728        ));
729        assert!(matches!(
730            CiscoIpPhoneInput::from_xml(
731                b"<!DOCTYPE input [<!ENTITY value 'secret'>]><CiscoIPPhoneInput><URL>&value;</URL></CiscoIPPhoneInput>",
732            ),
733            Err(PhoneXmlError::DocumentTypeForbidden)
734        ));
735        assert!(
736            CiscoIpPhoneInput::from_xml(
737                b"<CiscoIPPhoneInput><URL>&unknown;</URL></CiscoIPPhoneInput>"
738            )
739            .is_err()
740        );
741        assert!(matches!(
742            complete_input_document().to_xml_with_limit(10),
743            Err(PhoneXmlError::LimitExceeded { .. })
744        ));
745        let encoded = complete_input_document().to_xml().unwrap();
746        assert!(matches!(
747            CiscoIpPhoneInput::from_xml_with_limit(encoded.as_bytes(), 10),
748            Err(PhoneXmlError::LimitExceeded { .. })
749        ));
750
751        let nested = format!(
752            "<CiscoIPPhoneInput>{}<URL>submit</URL>{}</CiscoIPPhoneInput>",
753            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
754            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
755        );
756        assert!(matches!(
757            CiscoIpPhoneInput::from_xml(nested.as_bytes()),
758            Err(PhoneXmlError::NestingTooDeep { .. })
759        ));
760
761        #[derive(Debug)]
762        struct FailingWriter;
763        impl fmt::Write for FailingWriter {
764            fn write_str(&mut self, _value: &str) -> fmt::Result {
765                Err(fmt::Error)
766            }
767        }
768        assert!(matches!(
769            to_writer(
770                FailingWriter,
771                &complete_input_document(),
772                PHONE_INPUT_MAX_BYTES,
773            ),
774            Err(PhoneXmlError::Write(_))
775        ));
776    }
777
778    fn complete_execute_document() -> CiscoIpPhoneExecute {
779        CiscoIpPhoneExecute::new(vec![
780            CiscoIpPhoneExecuteItem::with_priority(
781                "Key:Directories?name=Café&view=<all>",
782                PhoneExecutePriority::LOW,
783            )
784            .unwrap(),
785            CiscoIpPhoneExecuteItem::with_priority(
786                "Application:PlacedCalls",
787                PhoneExecutePriority::HIGH,
788            )
789            .unwrap(),
790            CiscoIpPhoneExecuteItem::new("Init:Services").unwrap(),
791        ])
792        .unwrap()
793    }
794
795    #[test]
796    fn execute_document_round_trips_order_optional_priority_utf8_and_escaping() {
797        let expected = complete_execute_document();
798        let xml = expected.to_xml().unwrap();
799        assert!(xml.starts_with("<CiscoIPPhoneExecute>"));
800        assert!(xml.contains(
801            r#"<ExecuteItem Priority="0" URL="Key:Directories?name=Café&amp;view=&lt;all&gt;"/>"#
802        ));
803        assert!(xml.contains(r#"<ExecuteItem Priority="2" URL="Application:PlacedCalls"/>"#));
804        assert!(xml.contains(r#"<ExecuteItem URL="Init:Services"/>"#));
805        assert_eq!(
806            CiscoIpPhoneExecute::from_xml(xml.as_bytes()).unwrap(),
807            expected
808        );
809        assert_eq!(
810            expected
811                .items
812                .iter()
813                .map(|item| item.url.as_str())
814                .collect::<Vec<_>>(),
815            [
816                "Key:Directories?name=Café&view=<all>",
817                "Application:PlacedCalls",
818                "Init:Services",
819            ]
820        );
821    }
822
823    #[test]
824    fn execute_document_enforces_action_priority_url_and_collection_bounds() {
825        assert_eq!(PhoneExecutePriority::LOW.wire(), 0);
826        assert_eq!(PhoneExecutePriority::NORMAL.wire(), 1);
827        assert_eq!(PhoneExecutePriority::HIGH.wire(), 2);
828        assert!(PhoneExecutePriority::new(3).is_err());
829        assert!(PhoneExecuteUrl::new("").is_err());
830        assert!(PhoneExecuteUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
831        assert!(PhoneExecuteUrl::new("not\u{1}xml").is_err());
832
833        assert!(matches!(
834            CiscoIpPhoneExecute::new(Vec::new()),
835            Err(PhoneXmlError::InvalidField {
836                field: "phone execute actions",
837                ..
838            })
839        ));
840        let maximum = (0..PHONE_EXECUTE_MAX_ITEMS)
841            .map(|index| CiscoIpPhoneExecuteItem::new(format!("Key:KeyPad{index}")).unwrap())
842            .collect();
843        assert!(CiscoIpPhoneExecute::new(maximum).is_ok());
844        assert!(matches!(
845            CiscoIpPhoneExecute::new(vec![
846                CiscoIpPhoneExecuteItem::new("https://example.test/one").unwrap(),
847                CiscoIpPhoneExecuteItem::new("http://example.test/two").unwrap(),
848            ]),
849            Err(PhoneXmlError::InvalidField {
850                field: "phone execute HTTP actions",
851                ..
852            })
853        ));
854        let too_many = (0..=PHONE_EXECUTE_MAX_ITEMS)
855            .map(|index| CiscoIpPhoneExecuteItem::new(format!("Key:KeyPad{index}")).unwrap())
856            .collect();
857        assert!(matches!(
858            CiscoIpPhoneExecute::new(too_many),
859            Err(PhoneXmlError::LimitExceeded {
860                kind: "phone execute actions",
861                maximum: PHONE_EXECUTE_MAX_ITEMS,
862                ..
863            })
864        ));
865    }
866
867    #[test]
868    fn execute_parser_rejects_wrong_root_malformed_unsafe_and_oversized_documents() {
869        assert!(CiscoIpPhoneExecute::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
870        assert!(CiscoIpPhoneExecute::from_xml(b"<CiscoIPPhoneExecute/>").is_err());
871        assert!(CiscoIpPhoneExecute::from_xml(
872            br#"<CiscoIPPhoneExecute><ExecuteItem Priority="3" URL="Init:Services"/></CiscoIPPhoneExecute>"#
873        )
874        .is_err());
875        assert!(
876            CiscoIpPhoneExecute::from_xml(
877                br#"<CiscoIPPhoneExecute><ExecuteItem Priority="0"/></CiscoIPPhoneExecute>"#
878            )
879            .is_err()
880        );
881        assert!(
882            CiscoIpPhoneExecute::from_xml(
883                br#"<CiscoIPPhoneExecute><ExecuteItem URL=""/></CiscoIPPhoneExecute>"#
884            )
885            .is_err()
886        );
887        let oversized_url = format!(
888            "<CiscoIPPhoneExecute><ExecuteItem URL=\"{}\"/></CiscoIPPhoneExecute>",
889            "x".repeat(PHONE_XML_URL_MAX_CHARS + 1),
890        );
891        assert!(CiscoIpPhoneExecute::from_xml(oversized_url.as_bytes()).is_err());
892        let too_many_actions = format!(
893            "<CiscoIPPhoneExecute>{}</CiscoIPPhoneExecute>",
894            r#"<ExecuteItem URL="Init:Services"/>"#.repeat(PHONE_EXECUTE_MAX_ITEMS + 1),
895        );
896        assert!(matches!(
897            CiscoIpPhoneExecute::from_xml(too_many_actions.as_bytes()),
898            Err(PhoneXmlError::LimitExceeded {
899                kind: "phone execute actions",
900                maximum: PHONE_EXECUTE_MAX_ITEMS,
901                ..
902            })
903        ));
904        assert!(CiscoIpPhoneExecute::from_xml(
905            br#"<CiscoIPPhoneExecute><ExecuteItem Unknown="yes" URL="Init:Services"/></CiscoIPPhoneExecute>"#
906        )
907        .is_err());
908        assert!(
909            CiscoIpPhoneExecute::from_xml(
910                b"<CiscoIPPhoneExecute><ExecuteItem URL=\"Init:Services\"></CiscoIPPhoneExecute>"
911            )
912            .is_err()
913        );
914        assert!(matches!(
915            CiscoIpPhoneExecute::from_xml(&[0xff]),
916            Err(PhoneXmlError::InvalidUtf8(_))
917        ));
918        assert!(matches!(
919            CiscoIpPhoneExecute::from_xml(
920                br#"<!DOCTYPE execute [<!ENTITY action "Init:Services">]><CiscoIPPhoneExecute><ExecuteItem URL="&action;"/></CiscoIPPhoneExecute>"#,
921            ),
922            Err(PhoneXmlError::DocumentTypeForbidden)
923        ));
924        assert!(
925            CiscoIpPhoneExecute::from_xml(
926                br#"<CiscoIPPhoneExecute><ExecuteItem URL="&unknown;"/></CiscoIPPhoneExecute>"#
927            )
928            .is_err()
929        );
930        let encoded = complete_execute_document().to_xml().unwrap();
931        assert!(matches!(
932            CiscoIpPhoneExecute::from_xml_with_limit(encoded.as_bytes(), 10),
933            Err(PhoneXmlError::LimitExceeded { .. })
934        ));
935        assert!(matches!(
936            complete_execute_document().to_xml_with_limit(10),
937            Err(PhoneXmlError::LimitExceeded { .. })
938        ));
939
940        let nested = format!(
941            "<CiscoIPPhoneExecute>{}<ExecuteItem URL=\"Init:Services\"/>{}</CiscoIPPhoneExecute>",
942            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
943            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
944        );
945        assert!(matches!(
946            CiscoIpPhoneExecute::from_xml(nested.as_bytes()),
947            Err(PhoneXmlError::NestingTooDeep { .. })
948        ));
949
950        #[derive(Debug)]
951        struct FailingWriter;
952        impl fmt::Write for FailingWriter {
953            fn write_str(&mut self, _value: &str) -> fmt::Result {
954                Err(fmt::Error)
955            }
956        }
957        assert!(matches!(
958            to_writer(
959                FailingWriter,
960                &complete_execute_document(),
961                PHONE_EXECUTE_MAX_BYTES,
962            ),
963            Err(PhoneXmlError::Write(_))
964        ));
965    }
966
967    #[test]
968    fn declared_iso_8859_1_input_decodes_before_schema_validation() {
969        let mut document = br#"<?xml version="1.0" encoding = 'ISO-8859-1'?><CiscoIPPhoneExecute><ExecuteItem URL="Key:Caf"#
970            .to_vec();
971        document.push(0xe9);
972        document.extend_from_slice(br#""/></CiscoIPPhoneExecute>"#);
973        let parsed = CiscoIpPhoneExecute::from_xml(&document).unwrap();
974        assert_eq!(parsed.items[0].url.as_str(), "Key:Café");
975        assert!(matches!(
976            CiscoIpPhoneExecute::from_xml(&[b'<', 0xe9, b'>']),
977            Err(PhoneXmlError::InvalidUtf8(_))
978        ));
979    }
980
981    fn background_list_item(name: &str) -> CiscoIpPhoneImageListItem {
982        CiscoIpPhoneImageListItem {
983            thumbnail_url: PhoneBackgroundTftpUrl::new(format!(
984                "TFTP:Desktops/320x212x16/TN-{name}.png"
985            ))
986            .unwrap(),
987            image_url: PhoneBackgroundTftpUrl::new(format!("TFTP:Desktops/320x212x16/{name}.png"))
988                .unwrap(),
989        }
990    }
991
992    #[test]
993    fn background_image_list_round_trips_order_attributes_and_escaping() {
994        let expected = CiscoIpPhoneImageList::new(vec![
995            background_list_item("Fountain"),
996            background_list_item("Moon&Stars"),
997        ])
998        .unwrap();
999        let xml = expected.to_xml().unwrap();
1000        assert!(xml.starts_with("<CiscoIPPhoneImageList>"));
1001        assert!(xml.contains(
1002            r#"<ImageItem Image="TFTP:Desktops/320x212x16/TN-Fountain.png" URL="TFTP:Desktops/320x212x16/Fountain.png"/>"#
1003        ));
1004        assert!(xml.contains("TN-Moon&amp;Stars.png"));
1005        assert!(xml.find("Fountain.png").unwrap() < xml.find("Moon&amp;Stars.png").unwrap());
1006        assert_eq!(
1007            CiscoIpPhoneImageList::from_xml(xml.as_bytes()).unwrap(),
1008            expected
1009        );
1010
1011        let empty = CiscoIpPhoneImageList::from_xml(b"<CiscoIPPhoneImageList/>").unwrap();
1012        assert!(empty.items.is_empty());
1013    }
1014
1015    #[test]
1016    fn background_control_documents_round_trip_exact_evidenced_roots_and_order() {
1017        let image =
1018            PhoneBackgroundHttpUrl::new("http://pbx.example/background.png?site=east&screen=main")
1019                .unwrap();
1020        let thumbnail =
1021            PhoneBackgroundHttpUrl::new("http://pbx.example/background-thumb.png").unwrap();
1022        let set = CiscoIpPhoneSetBackground::new(image.clone(), thumbnail);
1023        let xml = set.to_xml().unwrap();
1024        assert_eq!(
1025            xml,
1026            "<setBackground><background><image>http://pbx.example/background.png?site=east&amp;screen=main</image><icon>http://pbx.example/background-thumb.png</icon></background></setBackground>"
1027        );
1028        assert_eq!(
1029            CiscoIpPhoneSetBackground::from_xml(xml.as_bytes()).unwrap(),
1030            set
1031        );
1032        assert_eq!(
1033            PhoneBackgroundControlDocument::from_xml(xml.as_bytes()).unwrap(),
1034            PhoneBackgroundControlDocument::Set(set)
1035        );
1036
1037        let preview = CiscoIpPhoneSetBackgroundPreview::new(image);
1038        let xml = preview.to_xml().unwrap();
1039        assert_eq!(
1040            xml,
1041            "<setBackgroundPreview><image>http://pbx.example/background.png?site=east&amp;screen=main</image></setBackgroundPreview>"
1042        );
1043        assert_eq!(
1044            CiscoIpPhoneSetBackgroundPreview::from_xml(xml.as_bytes()).unwrap(),
1045            preview
1046        );
1047        assert_eq!(
1048            PhoneBackgroundControlDocument::from_xml(xml.as_bytes()).unwrap(),
1049            PhoneBackgroundControlDocument::Preview(preview)
1050        );
1051    }
1052
1053    #[test]
1054    fn background_urls_enforce_transport_shape_length_and_secret_safe_errors() {
1055        assert_eq!(
1056            PhoneBackgroundTftpUrl::new("TFTP:Desktops/800x480x24/Picture.PNG")
1057                .unwrap()
1058                .as_str(),
1059            "TFTP:Desktops/800x480x24/Picture.PNG"
1060        );
1061        assert_eq!(
1062            PhoneBackgroundHttpUrl::new("http://[2001:db8::1]:8080/image.png?size=full")
1063                .unwrap()
1064                .as_str(),
1065            "http://[2001:db8::1]:8080/image.png?size=full"
1066        );
1067        for invalid in [
1068            "",
1069            "HTTP:Desktops/320x212x16/image.png",
1070            "TFTP://server/Desktops/image.png",
1071            "TFTP:/Desktops/image.png",
1072            "TFTP:Desktops/../image.png",
1073            "TFTP:Desktops/%2e%2e/image.png",
1074            "TFTP:Desktops/%2Fprivate/image.png",
1075            "TFTP:Desktops/%00private.png",
1076            "TFTP:Desktops/%Q0private.png",
1077            "TFTP:Desktops/image.jpg",
1078            "TFTP:Desktops/image.png?token=private",
1079            "TFTP:Desktops/image.png#private",
1080        ] {
1081            let error = PhoneBackgroundTftpUrl::new(invalid).unwrap_err();
1082            if !invalid.is_empty() {
1083                assert!(!error.to_string().contains(invalid));
1084            }
1085        }
1086        for invalid in [
1087            "",
1088            "https://pbx.example/private.png",
1089            "TFTP:Desktops/image.png",
1090            "background.png",
1091            "http://user:secret@pbx.example/private.png",
1092            "http://pbx.example/private.png#token",
1093        ] {
1094            let error = PhoneBackgroundHttpUrl::new(invalid).unwrap_err();
1095            if !invalid.is_empty() {
1096                assert!(!error.to_string().contains(invalid));
1097            }
1098        }
1099        assert!(PhoneBackgroundTftpUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1100        assert!(PhoneBackgroundHttpUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1101        assert!(PhoneBackgroundTftpUrl::new("TFTP:Desktops/not\u{1}xml.png").is_err());
1102        assert!(PhoneBackgroundHttpUrl::new("http://pbx.example/not\u{1}xml.png").is_err());
1103        assert!(
1104            !format!(
1105                "{:?}",
1106                PhoneBackgroundHttpUrl::new("http://private.example/secret.png").unwrap()
1107            )
1108            .contains("private.example")
1109        );
1110    }
1111
1112    #[test]
1113    fn background_image_list_enforces_collection_and_document_bounds() {
1114        let maximum = (0..PHONE_BACKGROUND_LIST_MAX_ITEMS)
1115            .map(|index| background_list_item(&format!("image-{index}")))
1116            .collect();
1117        assert!(CiscoIpPhoneImageList::new(maximum).is_ok());
1118
1119        let too_many = (0..=PHONE_BACKGROUND_LIST_MAX_ITEMS)
1120            .map(|index| background_list_item(&format!("image-{index}")))
1121            .collect();
1122        assert!(matches!(
1123            CiscoIpPhoneImageList::new(too_many),
1124            Err(PhoneXmlError::LimitExceeded {
1125                kind: "background image choices",
1126                maximum: PHONE_BACKGROUND_LIST_MAX_ITEMS,
1127                ..
1128            })
1129        ));
1130
1131        let document = CiscoIpPhoneImageList::new(vec![background_list_item("image")]).unwrap();
1132        assert!(matches!(
1133            document.to_xml_with_limit(10),
1134            Err(PhoneXmlError::LimitExceeded { .. })
1135        ));
1136        assert!(matches!(
1137            CiscoIpPhoneImageList::from_xml(&vec![b'x'; PHONE_BACKGROUND_LIST_MAX_BYTES + 1]),
1138            Err(PhoneXmlError::LimitExceeded { .. })
1139        ));
1140        let preview =
1141            PhoneBackgroundControlDocument::Preview(CiscoIpPhoneSetBackgroundPreview::new(
1142                PhoneBackgroundHttpUrl::new("http://pbx.example/image.png").unwrap(),
1143            ));
1144        assert!(matches!(
1145            preview.to_xml_with_limit(10),
1146            Err(PhoneXmlError::LimitExceeded { .. })
1147        ));
1148    }
1149
1150    #[test]
1151    fn background_parser_rejects_wrong_roots_unknowns_malformed_and_unsafe_xml() {
1152        for invalid in [
1153            b"<CiscoIPPhoneMenu/>".as_slice(),
1154            b"<CiscoIPPhoneImageList><ImageItem Image=\"TFTP:Desktops/TN.png\"/></CiscoIPPhoneImageList>".as_slice(),
1155            b"<CiscoIPPhoneImageList><ImageItem Image=\"TFTP:Desktops/TN.png\" URL=\"TFTP:Desktops/image.png\" Unknown=\"yes\"/></CiscoIPPhoneImageList>".as_slice(),
1156            b"<CiscoIPPhoneImageList>".as_slice(),
1157        ] {
1158            assert!(CiscoIpPhoneImageList::from_xml(invalid).is_err());
1159        }
1160        assert!(CiscoIpPhoneSetBackground::from_xml(
1161            b"<setBackgroundPreview><image>http://pbx.example/image.png</image></setBackgroundPreview>"
1162        )
1163        .is_err());
1164        assert!(CiscoIpPhoneSetBackgroundPreview::from_xml(
1165            b"<setBackgroundPreview><image>https://pbx.example/image.png</image></setBackgroundPreview>"
1166        )
1167        .is_err());
1168        assert!(PhoneBackgroundControlDocument::from_xml(b"<getDeviceCaps/>").is_err());
1169        assert!(matches!(
1170            CiscoIpPhoneImageList::from_xml(&[0xff]),
1171            Err(PhoneXmlError::InvalidUtf8(_))
1172        ));
1173        assert!(matches!(
1174            CiscoIpPhoneImageList::from_xml(
1175                br#"<!DOCTYPE images [<!ENTITY path "private">]><CiscoIPPhoneImageList><ImageItem Image="TFTP:Desktops/&path;-TN.png" URL="TFTP:Desktops/&path;.png"/></CiscoIPPhoneImageList>"#,
1176            ),
1177            Err(PhoneXmlError::DocumentTypeForbidden)
1178        ));
1179        assert!(CiscoIpPhoneImageList::from_xml(
1180            br#"<CiscoIPPhoneImageList><ImageItem Image="TFTP:Desktops/&unknown;-TN.png" URL="TFTP:Desktops/image.png"/></CiscoIPPhoneImageList>"#,
1181        )
1182        .is_err());
1183        let nested = format!(
1184            "<CiscoIPPhoneImageList>{}<ImageItem Image=\"TFTP:Desktops/TN.png\" URL=\"TFTP:Desktops/image.png\"/>{}</CiscoIPPhoneImageList>",
1185            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1186            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1187        );
1188        assert!(matches!(
1189            CiscoIpPhoneImageList::from_xml(nested.as_bytes()),
1190            Err(PhoneXmlError::NestingTooDeep { .. })
1191        ));
1192
1193        #[derive(Debug)]
1194        struct FailingWriter;
1195        impl fmt::Write for FailingWriter {
1196            fn write_str(&mut self, _value: &str) -> fmt::Result {
1197                Err(fmt::Error)
1198            }
1199        }
1200        assert!(matches!(
1201            to_writer(
1202                FailingWriter,
1203                &CiscoIpPhoneImageList::new(vec![background_list_item("image")]).unwrap(),
1204                PHONE_BACKGROUND_LIST_MAX_BYTES,
1205            ),
1206            Err(PhoneXmlError::Write(_))
1207        ));
1208    }
1209
1210    #[test]
1211    fn ringtone_document_round_trips_exact_root_child_order_and_escaping() {
1212        let url =
1213            PhoneRingtoneUrl::new("http://pbx.example/ringtones/Classic.raw?site=east&set=primary")
1214                .unwrap();
1215        assert_eq!(
1216            url.as_str(),
1217            "http://pbx.example/ringtones/Classic.raw?site=east&set=primary"
1218        );
1219        assert_eq!(
1220            url.clone().into_string(),
1221            "http://pbx.example/ringtones/Classic.raw?site=east&set=primary"
1222        );
1223        let expected = CiscoIpPhoneSetRingTone::new(url);
1224        let xml = expected.to_xml().unwrap();
1225        assert_eq!(
1226            xml,
1227            "<setRingTone><ringTone>http://pbx.example/ringtones/Classic.raw?site=east&amp;set=primary</ringTone></setRingTone>"
1228        );
1229        assert_eq!(
1230            CiscoIpPhoneSetRingTone::from_xml(xml.as_bytes()).unwrap(),
1231            expected
1232        );
1233    }
1234
1235    #[test]
1236    fn ringtone_url_enforces_transport_shape_length_and_secret_safe_errors() {
1237        assert_eq!(
1238            PhoneRingtoneUrl::new("http://[2001:db8::1]:8080/ringtones/Office.raw?locale=sv")
1239                .unwrap()
1240                .as_str(),
1241            "http://[2001:db8::1]:8080/ringtones/Office.raw?locale=sv"
1242        );
1243        for invalid in [
1244            "",
1245            "HTTP://pbx.example/ringtone.raw",
1246            "https://pbx.example/ringtone.raw",
1247            "TFTP:Ringlist.xml",
1248            "ringtone.raw",
1249            "http://user:secret@pbx.example/private.raw",
1250            "http://pbx.example/private.raw#secret",
1251            "http://pbx.example/not allowed.raw",
1252            "http://pbx.example/not\tallowed.raw",
1253            "http://pbx.example/not\\allowed.raw",
1254            "http://pbx.example/not%Q0allowed.raw",
1255        ] {
1256            let error = PhoneRingtoneUrl::new(invalid).unwrap_err();
1257            if !invalid.is_empty() {
1258                assert!(!error.to_string().contains(invalid));
1259            }
1260        }
1261        assert!(PhoneRingtoneUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1262        assert!(PhoneRingtoneUrl::new("http://pbx.example/not\u{1}xml.raw").is_err());
1263        assert!(
1264            !format!(
1265                "{:?}",
1266                PhoneRingtoneUrl::new("http://private.example/secret.raw").unwrap()
1267            )
1268            .contains("private.example")
1269        );
1270    }
1271
1272    #[test]
1273    fn ringtone_parser_rejects_wrong_root_unknown_malformed_unsafe_and_bounded_xml() {
1274        for invalid in [
1275            b"<setBackground><ringTone>http://pbx.example/r.raw</ringTone></setBackground>"
1276                .as_slice(),
1277            b"<setRingTone/>".as_slice(),
1278            b"<setRingTone unknown=\"yes\"><ringTone>http://pbx.example/r.raw</ringTone></setRingTone>"
1279                .as_slice(),
1280            b"<setRingTone><ringTone>http://pbx.example/r.raw</ringTone><Unknown/></setRingTone>"
1281                .as_slice(),
1282            b"<setRingTone><ringTone>https://pbx.example/r.raw</ringTone></setRingTone>"
1283                .as_slice(),
1284            b"<setRingTone><ringTone>".as_slice(),
1285        ] {
1286            assert!(CiscoIpPhoneSetRingTone::from_xml(invalid).is_err());
1287        }
1288        assert!(matches!(
1289            CiscoIpPhoneSetRingTone::from_xml(&[0xff]),
1290            Err(PhoneXmlError::InvalidUtf8(_))
1291        ));
1292        assert!(matches!(
1293            CiscoIpPhoneSetRingTone::from_xml(
1294                br#"<!DOCTYPE ringtone [<!ENTITY host "private.example">]><setRingTone><ringTone>http://&host;/r.raw</ringTone></setRingTone>"#,
1295            ),
1296            Err(PhoneXmlError::DocumentTypeForbidden)
1297        ));
1298        assert!(
1299            CiscoIpPhoneSetRingTone::from_xml(
1300                b"<setRingTone><ringTone>http://&unknown;/r.raw</ringTone></setRingTone>",
1301            )
1302            .is_err()
1303        );
1304        assert!(matches!(
1305            CiscoIpPhoneSetRingTone::from_xml(&vec![b'x'; PHONE_RINGTONE_MAX_BYTES + 1]),
1306            Err(PhoneXmlError::LimitExceeded {
1307                maximum: PHONE_RINGTONE_MAX_BYTES,
1308                ..
1309            })
1310        ));
1311
1312        let nested = format!(
1313            "<setRingTone>{}<ringTone>http://pbx.example/r.raw</ringTone>{}</setRingTone>",
1314            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1315            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1316        );
1317        assert!(matches!(
1318            CiscoIpPhoneSetRingTone::from_xml(nested.as_bytes()),
1319            Err(PhoneXmlError::NestingTooDeep { .. })
1320        ));
1321
1322        let document = CiscoIpPhoneSetRingTone::new(
1323            PhoneRingtoneUrl::new("http://pbx.example/r.raw").unwrap(),
1324        );
1325        assert!(matches!(
1326            document.to_xml_with_limit(10),
1327            Err(PhoneXmlError::LimitExceeded { .. })
1328        ));
1329        #[derive(Debug)]
1330        struct FailingWriter;
1331        impl fmt::Write for FailingWriter {
1332            fn write_str(&mut self, _value: &str) -> fmt::Result {
1333                Err(fmt::Error)
1334            }
1335        }
1336        assert!(matches!(
1337            to_writer(FailingWriter, &document, PHONE_RINGTONE_MAX_BYTES),
1338            Err(PhoneXmlError::Write(_))
1339        ));
1340    }
1341
1342    fn image_soft_keys() -> Vec<CiscoIpPhoneSoftKeyItem> {
1343        vec![CiscoIpPhoneSoftKeyItem {
1344            name: Some("Select & view".into()),
1345            position: PhoneSoftKeyPosition::new(1).unwrap(),
1346            url: Some("SoftKey:Select?view=image&side=west".into()),
1347            url_down: Some("Notify:select?state=down".into()),
1348        }]
1349    }
1350
1351    fn image_key_items() -> Vec<CiscoIpPhoneKeyItem> {
1352        vec![CiscoIpPhoneKeyItem {
1353            key: PhoneXmlKey::NavSelect,
1354            url: Some("Key:Select?view=image&side=west".into()),
1355            url_down: None,
1356        }]
1357    }
1358
1359    fn complete_bitmap_image() -> CiscoIpPhoneImage {
1360        CiscoIpPhoneImage {
1361            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
1362            application_id: Some("image-service".into()),
1363            on_focus_lost: Some("Notify:image?focus=lost".into()),
1364            on_focus_gained: Some("Notify:image?focus=gained".into()),
1365            on_minimized: Some("Notify:image?state=minimized".into()),
1366            on_closed: Some("Notify:image?state=closed".into()),
1367            title: Some("Café <map> & menu".into()),
1368            prompt: Some("Choose & inspect".into()),
1369            soft_keys: image_soft_keys(),
1370            key_items: image_key_items(),
1371            location_x: Some(-1),
1372            location_y: Some(64),
1373            width: 133,
1374            height: 65,
1375            depth: 2,
1376            data: Some(PhoneBitmapData::new(vec![0x00, 0xab, 0xff]).unwrap()),
1377        }
1378    }
1379
1380    fn complete_image_file() -> CiscoIpPhoneImageFile {
1381        CiscoIpPhoneImageFile {
1382            keypad_target: Some(PhoneKeypadTarget::Application),
1383            application_id: Some("image-file-service".into()),
1384            on_focus_lost: None,
1385            on_focus_gained: None,
1386            on_minimized: None,
1387            on_closed: Some("Notify:image-file?state=closed".into()),
1388            title: Some("Image <file>".into()),
1389            prompt: Some("Open & inspect".into()),
1390            soft_keys: image_soft_keys(),
1391            key_items: image_key_items(),
1392            location_x: Some(297),
1393            location_y: Some(-1),
1394            url: PhoneImageUrl::new("https://pbx.example/image.png?id=7&view=full").unwrap(),
1395        }
1396    }
1397
1398    fn complete_graphic_menu() -> CiscoIpPhoneGraphicMenu {
1399        CiscoIpPhoneGraphicMenu {
1400            keypad_target: Some(PhoneKeypadTarget::ActiveCall),
1401            application_id: Some("graphic-menu".into()),
1402            on_focus_lost: None,
1403            on_focus_gained: None,
1404            on_minimized: None,
1405            on_closed: None,
1406            title: Some("Graphic menu".into()),
1407            prompt: Some("Choose a region".into()),
1408            soft_keys: image_soft_keys(),
1409            key_items: image_key_items(),
1410            location_x: Some(132),
1411            location_y: Some(-1),
1412            width: 1,
1413            height: 1,
1414            depth: 1,
1415            data: Some(PhoneBitmapData::new(vec![0x12, 0x34]).unwrap()),
1416            items: vec![CiscoIpPhoneMenuItem {
1417                name: Some("West <wing>".into()),
1418                url: Some("UserData:9095:0:image/west?floor=1&open=true".into()),
1419            }],
1420        }
1421    }
1422
1423    fn complete_graphic_file_menu() -> CiscoIpPhoneGraphicFileMenu {
1424        CiscoIpPhoneGraphicFileMenu {
1425            keypad_target: None,
1426            application_id: Some("graphic-file-menu".into()),
1427            on_focus_lost: None,
1428            on_focus_gained: None,
1429            on_minimized: None,
1430            on_closed: None,
1431            title: Some("Floor plan".into()),
1432            prompt: Some("Touch a room".into()),
1433            soft_keys: image_soft_keys(),
1434            key_items: image_key_items(),
1435            location_x: Some(-1),
1436            location_y: Some(167),
1437            url: PhoneImageUrl::new("https://pbx.example/floor.png?site=east&floor=2").unwrap(),
1438            items: vec![CiscoIpPhoneTouchAreaMenuItem {
1439                name: Some("Room A & B".into()),
1440                url: Some("UserData:9095:0/room/a?mode=open&floor=2".into()),
1441                touch_area: Some(PhoneTouchArea {
1442                    x1: 4,
1443                    y1: 8,
1444                    x2: 90,
1445                    y2: 120,
1446                }),
1447            }],
1448        }
1449    }
1450
1451    #[test]
1452    fn image_documents_round_trip_schema_order_hex_utf8_and_escaping() {
1453        let image = complete_bitmap_image();
1454        let xml = image.to_xml().unwrap();
1455        assert!(xml.contains("Café &lt;map&gt; &amp; menu"));
1456        assert!(xml.contains("<Data>00ABFF</Data>"));
1457        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
1458        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<LocationX>").unwrap());
1459        assert!(xml.find("<Depth>").unwrap() < xml.find("<Data>").unwrap());
1460        assert_eq!(CiscoIpPhoneImage::from_xml(xml.as_bytes()).unwrap(), image);
1461        assert_eq!(
1462            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1463            PhoneImageDocument::Image(image)
1464        );
1465
1466        let spaced_hex = b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>00 ab\nFF</Data></CiscoIPPhoneImage>";
1467        let parsed = CiscoIpPhoneImage::from_xml(spaced_hex).unwrap();
1468        assert_eq!(parsed.data.unwrap().as_bytes(), [0x00, 0xab, 0xff]);
1469
1470        let image_file = complete_image_file();
1471        let xml = image_file.to_xml().unwrap();
1472        assert!(xml.contains("Image &lt;file&gt;"));
1473        assert!(xml.contains("id=7&amp;view=full"));
1474        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<LocationX>").unwrap());
1475        let controls_end = xml.find("</KeyItem>").unwrap();
1476        let image_url = controls_end + xml[controls_end..].find("<URL>").unwrap();
1477        assert!(xml.find("<LocationY>").unwrap() < image_url);
1478        assert_eq!(
1479            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1480            PhoneImageDocument::ImageFile(image_file)
1481        );
1482
1483        let graphic = complete_graphic_menu();
1484        let xml = graphic.to_xml().unwrap();
1485        assert!(xml.contains("West &lt;wing&gt;"));
1486        assert!(xml.find("<Data>").unwrap() < xml.find("<MenuItem>").unwrap());
1487        assert_eq!(
1488            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1489            PhoneImageDocument::GraphicMenu(graphic)
1490        );
1491
1492        let graphic_file = complete_graphic_file_menu();
1493        let xml = graphic_file.to_xml().unwrap();
1494        assert!(xml.contains("Room A &amp; B"));
1495        assert!(xml.contains(r#"<TouchArea X1="4" Y1="8" X2="90" Y2="120"/>"#));
1496        let controls_end = xml.find("</KeyItem>").unwrap();
1497        let image_url = controls_end + xml[controls_end..].find("<URL>").unwrap();
1498        assert!(image_url < xml.find("<MenuItem>").unwrap());
1499        assert_eq!(
1500            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1501            PhoneImageDocument::GraphicFileMenu(graphic_file)
1502        );
1503    }
1504
1505    #[test]
1506    fn image_documents_enforce_exact_geometry_data_url_and_collection_bounds() {
1507        let mut image = complete_bitmap_image();
1508        assert!(image.validate().is_ok());
1509        image.location_x = Some(-2);
1510        assert!(image.validate().is_err());
1511        image.location_x = Some(133);
1512        assert!(image.validate().is_err());
1513        image.location_x = Some(0);
1514        image.location_y = Some(-2);
1515        assert!(image.validate().is_err());
1516        image.location_y = Some(65);
1517        assert!(image.validate().is_err());
1518        image.location_y = None;
1519        for (width, height, depth) in [
1520            (0, 1, 1),
1521            (134, 1, 1),
1522            (1, 0, 1),
1523            (1, 66, 1),
1524            (1, 1, 0),
1525            (1, 1, 3),
1526        ] {
1527            image.width = width;
1528            image.height = height;
1529            image.depth = depth;
1530            assert!(image.validate().is_err());
1531        }
1532        image.width = 1;
1533        image.height = 1;
1534        image.depth = 1;
1535        image.data = Some(PhoneBitmapData::new(vec![0; PHONE_IMAGE_BITMAP_MAX_BYTES]).unwrap());
1536        assert!(image.validate().is_ok());
1537        assert!(matches!(
1538            PhoneBitmapData::new(vec![0; PHONE_IMAGE_BITMAP_MAX_BYTES + 1]),
1539            Err(PhoneXmlError::LimitExceeded {
1540                kind: "bitmap image data bytes",
1541                maximum: PHONE_IMAGE_BITMAP_MAX_BYTES,
1542                ..
1543            })
1544        ));
1545
1546        let mut image_file = complete_image_file();
1547        for x in [-2, 298] {
1548            image_file.location_x = Some(x);
1549            assert!(image_file.validate().is_err());
1550        }
1551        image_file.location_x = None;
1552        for y in [-2, 168] {
1553            image_file.location_y = Some(y);
1554            assert!(image_file.validate().is_err());
1555        }
1556        assert!(PhoneImageUrl::new("").is_err());
1557        assert!(PhoneImageUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1558        assert!(PhoneImageUrl::new("not\u{1}xml").is_err());
1559
1560        let mut graphic = complete_graphic_menu();
1561        graphic.items = (0..PHONE_GRAPHIC_MENU_MAX_ITEMS)
1562            .map(|_| CiscoIpPhoneMenuItem {
1563                name: Some("x".repeat(64)),
1564                url: Some("x".repeat(PHONE_XML_URL_MAX_CHARS)),
1565            })
1566            .collect();
1567        assert!(graphic.validate().is_ok());
1568        graphic.items.push(CiscoIpPhoneMenuItem {
1569            name: None,
1570            url: None,
1571        });
1572        assert!(graphic.validate().is_err());
1573        graphic.items.truncate(1);
1574        graphic.items[0].name = Some("x".repeat(65));
1575        assert!(graphic.validate().is_err());
1576
1577        let mut graphic_file = complete_graphic_file_menu();
1578        graphic_file.items = (0..PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS)
1579            .map(|_| CiscoIpPhoneTouchAreaMenuItem {
1580                name: Some("x".repeat(32)),
1581                url: Some("x".repeat(PHONE_XML_URL_MAX_CHARS)),
1582                touch_area: Some(PhoneTouchArea {
1583                    x1: u16::MIN,
1584                    y1: u16::MIN,
1585                    x2: u16::MAX,
1586                    y2: u16::MAX,
1587                }),
1588            })
1589            .collect();
1590        assert!(graphic_file.validate().is_ok());
1591        graphic_file.items.push(CiscoIpPhoneTouchAreaMenuItem {
1592            name: None,
1593            url: None,
1594            touch_area: None,
1595        });
1596        assert!(graphic_file.validate().is_err());
1597        graphic_file.items.truncate(1);
1598        graphic_file.items[0].name = Some("x".repeat(33));
1599        assert!(graphic_file.validate().is_err());
1600    }
1601
1602    #[test]
1603    fn image_parsers_reject_wrong_roots_malformed_unsafe_nested_and_oversized_input() {
1604        assert!(
1605            CiscoIpPhoneImage::from_xml(
1606                b"<CiscoIPPhoneImageFile><URL>x</URL></CiscoIPPhoneImageFile>"
1607            )
1608            .is_err()
1609        );
1610        assert!(PhoneImageDocument::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
1611        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Unknown/></CiscoIPPhoneImage>").is_err());
1612        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>123</Data></CiscoIPPhoneImage>").is_err());
1613        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>zz</Data></CiscoIPPhoneImage>").is_err());
1614        assert!(CiscoIpPhoneGraphicFileMenu::from_xml(b"<CiscoIPPhoneGraphicFileMenu><URL>x</URL><MenuItem><TouchArea X1=\"bad\" Y1=\"0\" X2=\"1\" Y2=\"1\"/></MenuItem></CiscoIPPhoneGraphicFileMenu>").is_err());
1615        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage>").is_err());
1616        assert!(matches!(
1617            CiscoIpPhoneImage::from_xml(&[0xff]),
1618            Err(PhoneXmlError::InvalidUtf8(_))
1619        ));
1620        assert!(matches!(
1621            CiscoIpPhoneImage::from_xml(b"<!DOCTYPE image [<!ENTITY bits '00'>]><CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&bits;</Data></CiscoIPPhoneImage>"),
1622            Err(PhoneXmlError::DocumentTypeForbidden)
1623        ));
1624        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&unknown;</Data></CiscoIPPhoneImage>").is_err());
1625
1626        let nested = format!(
1627            "<CiscoIPPhoneImage>{}<Width>1</Width><Height>1</Height><Depth>1</Depth>{}</CiscoIPPhoneImage>",
1628            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1629            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1630        );
1631        assert!(matches!(
1632            CiscoIpPhoneImage::from_xml(nested.as_bytes()),
1633            Err(PhoneXmlError::NestingTooDeep { .. })
1634        ));
1635        assert!(matches!(
1636            PhoneImageDocument::from_xml(&vec![b'x'; PHONE_IMAGE_MAX_BYTES + 1]),
1637            Err(PhoneXmlError::LimitExceeded { .. })
1638        ));
1639        assert!(matches!(
1640            PhoneImageDocument::Image(complete_bitmap_image()).to_xml_with_limit(10),
1641            Err(PhoneXmlError::LimitExceeded { .. })
1642        ));
1643
1644        #[derive(Debug)]
1645        struct FailingWriter;
1646        impl fmt::Write for FailingWriter {
1647            fn write_str(&mut self, _value: &str) -> fmt::Result {
1648                Err(fmt::Error)
1649            }
1650        }
1651        assert!(matches!(
1652            to_writer(
1653                FailingWriter,
1654                &complete_graphic_file_menu(),
1655                PHONE_IMAGE_MAX_BYTES
1656            ),
1657            Err(PhoneXmlError::Write(_))
1658        ));
1659    }
1660
1661    fn complete_bitmap_status() -> CiscoIpPhoneStatus {
1662        CiscoIpPhoneStatus {
1663            text: Some("Café <ready> & active".into()),
1664            timer_seconds: Some(15),
1665            location_x: Some(-1),
1666            location_y: Some(20),
1667            width: 106,
1668            height: 21,
1669            depth: 2,
1670            data: Some(PhoneBitmapData::new(vec![0x00, 0xab, 0xff]).unwrap()),
1671        }
1672    }
1673
1674    fn complete_file_status() -> CiscoIpPhoneStatusFile {
1675        CiscoIpPhoneStatusFile {
1676            text: Some("Status <file> & refresh".into()),
1677            timer_seconds: Some(u16::MAX),
1678            location_x: Some(261),
1679            location_y: Some(-1),
1680            url: PhoneImageUrl::new("https://pbx.example/status.png?id=7&view=compact").unwrap(),
1681        }
1682    }
1683
1684    #[test]
1685    fn status_documents_round_trip_icons_timers_order_utf8_and_escaping() {
1686        let bitmap = complete_bitmap_status();
1687        let xml = bitmap.to_xml().unwrap();
1688        assert!(xml.contains("Café &lt;ready&gt; &amp; active"));
1689        assert!(xml.contains("<Timer>15</Timer>"));
1690        assert!(xml.contains("<Data>00ABFF</Data>"));
1691        assert!(xml.find("<Text>").unwrap() < xml.find("<Timer>").unwrap());
1692        assert!(xml.find("<Timer>").unwrap() < xml.find("<LocationX>").unwrap());
1693        assert!(xml.find("<Depth>").unwrap() < xml.find("<Data>").unwrap());
1694        assert_eq!(
1695            CiscoIpPhoneStatus::from_xml(xml.as_bytes()).unwrap(),
1696            bitmap
1697        );
1698        assert_eq!(
1699            PhoneStatusDocument::from_xml(xml.as_bytes()).unwrap(),
1700            PhoneStatusDocument::Bitmap(bitmap)
1701        );
1702
1703        let file = complete_file_status();
1704        let xml = file.to_xml().unwrap();
1705        assert!(xml.contains("Status &lt;file&gt; &amp; refresh"));
1706        assert!(xml.contains(&format!("<Timer>{}</Timer>", u16::MAX)));
1707        assert!(xml.contains("id=7&amp;view=compact"));
1708        assert!(xml.find("<LocationY>").unwrap() < xml.find("<URL>").unwrap());
1709        assert_eq!(
1710            PhoneStatusDocument::from_xml(xml.as_bytes()).unwrap(),
1711            PhoneStatusDocument::File(file)
1712        );
1713
1714        let zero_timer = CiscoIpPhoneStatus::from_xml(
1715            b"<CiscoIPPhoneStatus><Timer>0</Timer><Width>1</Width><Height>1</Height><Depth>1</Depth><Data></Data></CiscoIPPhoneStatus>",
1716        )
1717        .unwrap();
1718        assert_eq!(zero_timer.timer_seconds, Some(0));
1719        assert_eq!(zero_timer.data.unwrap().as_bytes(), []);
1720        let absent_data = CiscoIpPhoneStatus::from_xml(
1721            b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth></CiscoIPPhoneStatus>",
1722        )
1723        .unwrap();
1724        assert!(absent_data.timer_seconds.is_none());
1725        assert!(absent_data.data.is_none());
1726    }
1727
1728    #[test]
1729    fn status_documents_enforce_exact_text_geometry_icon_and_url_bounds() {
1730        let mut bitmap = complete_bitmap_status();
1731        bitmap.text = Some("x".repeat(32));
1732        assert!(bitmap.validate().is_ok());
1733        bitmap.text = Some("x".repeat(33));
1734        assert!(bitmap.validate().is_err());
1735        bitmap.text = None;
1736        for x in [-2, 106] {
1737            bitmap.location_x = Some(x);
1738            assert!(bitmap.validate().is_err());
1739        }
1740        bitmap.location_x = None;
1741        for y in [-2, 21] {
1742            bitmap.location_y = Some(y);
1743            assert!(bitmap.validate().is_err());
1744        }
1745        bitmap.location_y = None;
1746        for (width, height, depth) in [
1747            (0, 1, 1),
1748            (107, 1, 1),
1749            (1, 0, 1),
1750            (1, 22, 1),
1751            (1, 1, 0),
1752            (1, 1, 3),
1753        ] {
1754            bitmap.width = width;
1755            bitmap.height = height;
1756            bitmap.depth = depth;
1757            assert!(bitmap.validate().is_err());
1758        }
1759        bitmap.width = 1;
1760        bitmap.height = 1;
1761        bitmap.depth = 1;
1762        bitmap.data = Some(PhoneBitmapData::new(vec![0; PHONE_STATUS_BITMAP_MAX_BYTES]).unwrap());
1763        assert!(bitmap.validate().is_ok());
1764        bitmap.data =
1765            Some(PhoneBitmapData::new(vec![0; PHONE_STATUS_BITMAP_MAX_BYTES + 1]).unwrap());
1766        assert!(matches!(
1767            bitmap.validate(),
1768            Err(PhoneXmlError::LimitExceeded {
1769                kind: "phone status bitmap bytes",
1770                maximum: PHONE_STATUS_BITMAP_MAX_BYTES,
1771                ..
1772            })
1773        ));
1774
1775        let mut file = complete_file_status();
1776        for x in [-2, 262] {
1777            file.location_x = Some(x);
1778            assert!(file.validate().is_err());
1779        }
1780        file.location_x = None;
1781        for y in [-2, 50] {
1782            file.location_y = Some(y);
1783            assert!(file.validate().is_err());
1784        }
1785        assert!(PhoneImageUrl::new("").is_err());
1786        assert!(PhoneImageUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1787    }
1788
1789    #[test]
1790    fn status_parsers_reject_wrong_roots_malformed_unsafe_nested_and_oversized_input() {
1791        assert!(
1792            CiscoIpPhoneStatus::from_xml(
1793                b"<CiscoIPPhoneStatusFile><URL>x</URL></CiscoIPPhoneStatusFile>"
1794            )
1795            .is_err()
1796        );
1797        assert!(PhoneStatusDocument::from_xml(b"<CiscoIPPhoneText/>").is_err());
1798        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Unknown/></CiscoIPPhoneStatus>").is_err());
1799        assert!(
1800            CiscoIpPhoneStatus::from_xml(
1801                b"<CiscoIPPhoneStatus><Height>1</Height><Depth>1</Depth></CiscoIPPhoneStatus>"
1802            )
1803            .is_err()
1804        );
1805        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>f</Data></CiscoIPPhoneStatus>").is_err());
1806        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>zz</Data></CiscoIPPhoneStatus>").is_err());
1807        assert!(
1808            CiscoIpPhoneStatusFile::from_xml(
1809                b"<CiscoIPPhoneStatusFile><URL></URL></CiscoIPPhoneStatusFile>"
1810            )
1811            .is_err()
1812        );
1813        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus>").is_err());
1814        assert!(matches!(
1815            CiscoIpPhoneStatus::from_xml(&[0xff]),
1816            Err(PhoneXmlError::InvalidUtf8(_))
1817        ));
1818        assert!(matches!(
1819            CiscoIpPhoneStatus::from_xml(b"<!DOCTYPE status [<!ENTITY bits '00'>]><CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&bits;</Data></CiscoIPPhoneStatus>"),
1820            Err(PhoneXmlError::DocumentTypeForbidden)
1821        ));
1822        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&unknown;</Data></CiscoIPPhoneStatus>").is_err());
1823
1824        let nested = format!(
1825            "<CiscoIPPhoneStatus>{}<Width>1</Width><Height>1</Height><Depth>1</Depth>{}</CiscoIPPhoneStatus>",
1826            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1827            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1828        );
1829        assert!(matches!(
1830            CiscoIpPhoneStatus::from_xml(nested.as_bytes()),
1831            Err(PhoneXmlError::NestingTooDeep { .. })
1832        ));
1833        assert!(matches!(
1834            PhoneStatusDocument::from_xml(&vec![b'x'; PHONE_STATUS_MAX_BYTES + 1]),
1835            Err(PhoneXmlError::LimitExceeded { .. })
1836        ));
1837        assert!(matches!(
1838            PhoneStatusDocument::Bitmap(complete_bitmap_status()).to_xml_with_limit(10),
1839            Err(PhoneXmlError::LimitExceeded { .. })
1840        ));
1841
1842        #[derive(Debug)]
1843        struct FailingWriter;
1844        impl fmt::Write for FailingWriter {
1845            fn write_str(&mut self, _value: &str) -> fmt::Result {
1846                Err(fmt::Error)
1847            }
1848        }
1849        assert!(matches!(
1850            to_writer(
1851                FailingWriter,
1852                &complete_file_status(),
1853                PHONE_STATUS_MAX_BYTES,
1854            ),
1855            Err(PhoneXmlError::Write(_))
1856        ));
1857    }
1858
1859    fn complete_alarm() -> CiscoIpPhoneAlarm {
1860        CiscoIpPhoneAlarm {
1861            alarm: CiscoIpPhoneAlarmEntry {
1862                name: LAST_OUT_OF_SERVICE_ALARM.into(),
1863                parameter_list: CiscoIpPhoneAlarmParameterList {
1864                    parameters: vec![
1865                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
1866                            name: "DeviceName".into(),
1867                            value: "SEP001122334455".into(),
1868                        }),
1869                        CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
1870                            name: "DHCPv4Status".into(),
1871                            value: 1,
1872                        }),
1873                        CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
1874                            name: "ReasonForOutOfService".into(),
1875                            value: 25,
1876                        }),
1877                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
1878                            name: "LastProtocolEventSent".into(),
1879                            value: "Sent:REGISTER <call-id> & route".into(),
1880                        }),
1881                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
1882                            name: "LastProtocolEventReceived".into(),
1883                            value: String::new(),
1884                        }),
1885                    ],
1886                },
1887            },
1888        }
1889    }
1890
1891    #[test]
1892    fn alarm_schema_round_trips_ordered_typed_parameters_and_accessors() {
1893        let expected = complete_alarm();
1894        let xml = expected.to_xml().unwrap();
1895        assert!(xml.starts_with(
1896            "<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList>"
1897        ));
1898        assert!(xml.contains("Sent:REGISTER &lt;call-id&gt; &amp; route"));
1899        assert!(xml.find("DeviceName").unwrap() < xml.find("DHCPv4Status").unwrap());
1900        assert!(
1901            xml.find("ReasonForOutOfService").unwrap() < xml.find("LastProtocolEventSent").unwrap()
1902        );
1903        let decoded = CiscoIpPhoneAlarm::from_xml(xml.as_bytes()).unwrap();
1904        assert_eq!(decoded, expected);
1905        assert_eq!(decoded.reason_for_out_of_service(), Some(25));
1906        assert_eq!(decoded.enumeration("DHCPv4Status"), Some(1));
1907        assert_eq!(decoded.string("DeviceName"), Some("SEP001122334455"));
1908        assert_eq!(decoded.string("LastProtocolEventReceived"), Some(""));
1909        assert_eq!(decoded.string("Unknown"), None);
1910        let telemetry = parse_phone_alarm(xml.as_bytes()).unwrap();
1911        assert!(matches!(
1912            &telemetry,
1913            PhoneAlarmTelemetry::LastOutOfService(alarm) if alarm == &expected
1914        ));
1915        assert_eq!(
1916            telemetry.summary(),
1917            Some(PhoneAlarmSummary {
1918                kind: PhoneAlarmKind::LastOutOfService,
1919                reason_for_out_of_service: Some(25),
1920            })
1921        );
1922    }
1923
1924    #[test]
1925    fn unknown_alarm_schemas_remain_bounded_lossless_and_secret_safe() {
1926        for unknown in [
1927            b"<x-cisco-alarm/>".as_slice(),
1928            b"<x-cisco-alarm><Alarm Name=\"DeviceTroubleshootingReport\"><ParameterList><String name=\"Token\">secret-value</String></ParameterList></Alarm></x-cisco-alarm>".as_slice(),
1929            b"<vendor-alarm><Credential>secret-value</Credential></vendor-alarm>".as_slice(),
1930        ] {
1931            let PhoneAlarmTelemetry::Opaque(opaque) = parse_phone_alarm(unknown).unwrap() else {
1932                panic!("unknown alarm schema must remain opaque");
1933            };
1934            assert_eq!(opaque.as_bytes(), unknown);
1935            let debug = format!("{opaque:?}");
1936            assert!(!debug.contains("secret-value"));
1937            assert!(debug.contains(&unknown.len().to_string()));
1938            assert_eq!(opaque.clone().into_bytes(), unknown);
1939        }
1940
1941        let opaque = parse_phone_alarm(b"<vendor-alarm/>").unwrap();
1942        assert!(opaque.is_opaque());
1943        assert_eq!(opaque.summary(), None);
1944
1945        let known = complete_alarm();
1946        let debug = format!("{known:?}");
1947        assert!(!debug.contains("SEP001122334455"));
1948        assert!(!debug.contains("call-id"));
1949        assert!(debug.contains(LAST_OUT_OF_SERVICE_ALARM));
1950        assert_eq!(
1951            format!("{:?}", known.alarm.parameter_list),
1952            "CiscoIpPhoneAlarmParameterList { parameter_count: 5 }"
1953        );
1954    }
1955
1956    #[test]
1957    fn known_alarm_validation_rejects_ambiguity_unsafe_values_and_size_overflow() {
1958        let mut alarm = complete_alarm();
1959        alarm
1960            .alarm
1961            .parameter_list
1962            .parameters
1963            .push(CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
1964                name: "DeviceName".into(),
1965                value: 2,
1966            }));
1967        assert!(matches!(
1968            alarm.validate(),
1969            Err(PhoneXmlError::InvalidField {
1970                field: "phone alarm parameter names",
1971                ..
1972            })
1973        ));
1974
1975        alarm = complete_alarm();
1976        match &mut alarm.alarm.parameter_list.parameters[0] {
1977            CiscoIpPhoneAlarmParameter::String(device) => device.name.clear(),
1978            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
1979        }
1980        assert!(alarm.validate().is_err());
1981        match &mut alarm.alarm.parameter_list.parameters[0] {
1982            CiscoIpPhoneAlarmParameter::String(device) => {
1983                device.name = "DeviceName".into();
1984                device.value = "not\u{1}xml".into();
1985            }
1986            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
1987        }
1988        assert!(alarm.validate().is_err());
1989        match &mut alarm.alarm.parameter_list.parameters[0] {
1990            CiscoIpPhoneAlarmParameter::String(device) => {
1991                device.value = "sensitive-value".repeat(PHONE_ALARM_MAX_BYTES);
1992            }
1993            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
1994        }
1995        let error = alarm.to_xml().unwrap_err();
1996        assert!(!error.to_string().contains("sensitive-value"));
1997
1998        let duplicate = b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><String name=\"DeviceName\">first-secret</String><String name=\"DeviceName\">second-secret</String></ParameterList></Alarm></x-cisco-alarm>";
1999        let error = parse_phone_alarm(duplicate).unwrap_err();
2000        assert!(!error.to_string().contains("first-secret"));
2001        assert!(!error.to_string().contains("second-secret"));
2002    }
2003
2004    #[test]
2005    fn alarm_parser_rejects_malformed_known_unsafe_and_oversized_documents() {
2006        assert!(parse_phone_alarm(b"<x-cisco-alarm>").is_err());
2007        assert!(matches!(
2008            parse_phone_alarm(&[0xff]),
2009            Err(PhoneXmlError::InvalidUtf8(_))
2010        ));
2011        assert!(matches!(
2012            parse_phone_alarm(b"<!DOCTYPE alarm [<!ENTITY value 'secret'>]><x-cisco-alarm><Alarm Name=\"Unknown\"><ParameterList><String name=\"Value\">&value;</String></ParameterList></Alarm></x-cisco-alarm>"),
2013            Err(PhoneXmlError::DocumentTypeForbidden)
2014        ));
2015        assert!(parse_phone_alarm(b"<x-cisco-alarm><Alarm Name=\"Unknown\"><ParameterList><String name=\"Value\">&unknown;</String></ParameterList></Alarm></x-cisco-alarm>").is_err());
2016        assert!(parse_phone_alarm(b"<vendor-alarm><Value>&#1;</Value></vendor-alarm>").is_err());
2017        assert!(parse_phone_alarm(b"<vendor-alarm value=\"&#1;\"/>").is_err());
2018        assert!(parse_phone_alarm(b"<vendor-alarm>not\x01xml</vendor-alarm>").is_err());
2019        assert!(parse_phone_alarm(b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><Binary name=\"Value\">00</Binary></ParameterList></Alarm></x-cisco-alarm>").is_err());
2020        assert!(
2021            parse_phone_alarm(
2022                b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"/></x-cisco-alarm>"
2023            )
2024            .is_err()
2025        );
2026        let invalid_enum = b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><Enum name=\"ReasonForOutOfService\">secret-enum</Enum></ParameterList></Alarm></x-cisco-alarm>";
2027        let error = parse_phone_alarm(invalid_enum).unwrap_err();
2028        assert!(!error.to_string().contains("secret-enum"));
2029
2030        let nested = format!(
2031            "<x-cisco-alarm>{}<Alarm Name=\"Unknown\"/>{}</x-cisco-alarm>",
2032            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2033            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2034        );
2035        assert!(matches!(
2036            parse_phone_alarm(nested.as_bytes()),
2037            Err(PhoneXmlError::NestingTooDeep { .. })
2038        ));
2039        assert!(matches!(
2040            parse_phone_alarm(&vec![b'x'; PHONE_ALARM_MAX_BYTES + 1]),
2041            Err(PhoneXmlError::LimitExceeded {
2042                maximum: PHONE_ALARM_MAX_BYTES,
2043                ..
2044            })
2045        ));
2046
2047        #[derive(Debug)]
2048        struct FailingWriter;
2049        impl fmt::Write for FailingWriter {
2050            fn write_str(&mut self, _value: &str) -> fmt::Result {
2051                Err(fmt::Error)
2052            }
2053        }
2054        assert!(matches!(
2055            to_writer(FailingWriter, &complete_alarm(), PHONE_ALARM_MAX_BYTES),
2056            Err(PhoneXmlError::Write(_))
2057        ));
2058    }
2059
2060    fn complete_location() -> CiscoIpPhoneLocationInformation {
2061        CiscoIpPhoneLocationInformation {
2062            wifi: CiscoIpPhoneWifiLocation {
2063                bssid: PhoneBssid::parse("e8:ed:f3:10:29:fd").unwrap(),
2064                ssid: "Café <voice> & data".into(),
2065                access_point_name: "West wing <3>".into(),
2066            },
2067            off_premises: Some(CiscoIpPhoneOffPremises::new()),
2068        }
2069    }
2070
2071    #[test]
2072    fn location_schema_round_trips_typed_address_fields_order_and_escaping() {
2073        let expected = complete_location();
2074        let xml = expected.to_xml().unwrap();
2075        assert!(xml.starts_with("<Interface1><wifi><BSSID>E8:ED:F3:10:29:FD</BSSID>"));
2076        assert!(xml.contains("<SSID>Café &lt;voice&gt; &amp; data</SSID>"));
2077        assert!(xml.contains("<APName>West wing &lt;3&gt;</APName>"));
2078        assert!(xml.find("</wifi>").unwrap() < xml.find("<OffPrem").unwrap());
2079        assert_eq!(
2080            CiscoIpPhoneLocationInformation::from_xml(xml.as_bytes()).unwrap(),
2081            expected
2082        );
2083        assert_eq!(
2084            expected.wifi.bssid.octets(),
2085            [0xe8, 0xed, 0xf3, 0x10, 0x29, 0xfd]
2086        );
2087        assert_eq!(expected.wifi.bssid.to_string(), "E8:ED:F3:10:29:FD");
2088        assert!(expected.is_off_premises());
2089
2090        let telemetry = parse_phone_location(xml.as_bytes()).unwrap();
2091        assert_eq!(
2092            telemetry.summary(),
2093            Some(PhoneLocationSummary {
2094                kind: PhoneLocationKind::WirelessInterface,
2095                off_premises: true,
2096            })
2097        );
2098
2099        let on_premises = CiscoIpPhoneLocationInformation::from_xml(
2100            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID></SSID><APName/></wifi></Interface1>",
2101        )
2102        .unwrap();
2103        assert!(!on_premises.is_off_premises());
2104        assert_eq!(on_premises.wifi.ssid, "");
2105        assert_eq!(on_premises.wifi.access_point_name, "");
2106    }
2107
2108    #[test]
2109    fn location_models_enforce_address_marker_text_and_document_bounds() {
2110        for invalid in [
2111            "00:11:22:33:44",
2112            "00:11:22:33:44:555",
2113            "00-11-22-33-44-55",
2114            "00:11:22:33:44:gg",
2115            "private-address",
2116        ] {
2117            let error = PhoneBssid::parse(invalid).unwrap_err();
2118            assert!(!error.to_string().contains(invalid));
2119        }
2120
2121        let mut location = complete_location();
2122        location.wifi.ssid = "é".repeat(16);
2123        assert!(location.validate().is_ok());
2124        location.wifi.ssid.push('é');
2125        assert!(matches!(
2126            location.validate(),
2127            Err(PhoneXmlError::InvalidField {
2128                field: "phone location SSID",
2129                expected: "at most 32 bytes",
2130            })
2131        ));
2132
2133        location = complete_location();
2134        location.wifi.access_point_name = "private-name".repeat(PHONE_LOCATION_MAX_BYTES);
2135        let error = location.to_xml().unwrap_err();
2136        assert!(!error.to_string().contains("private-name"));
2137
2138        let nonempty_marker = b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID>voice</SSID><APName>west</APName></wifi><OffPrem>private-location</OffPrem></Interface1>";
2139        let error = parse_phone_location(nonempty_marker).unwrap_err();
2140        assert!(!error.to_string().contains("private-location"));
2141    }
2142
2143    #[test]
2144    fn unknown_location_schemas_are_bounded_lossless_and_secret_safe() {
2145        for unknown in [
2146            b"<Interface2><wifi><BSSID>00:11:22:33:44:55</BSSID></wifi></Interface2>".as_slice(),
2147            b"<DeviceLocation><CivicAddress>private-building</CivicAddress></DeviceLocation>"
2148                .as_slice(),
2149        ] {
2150            let telemetry = parse_phone_location(unknown).unwrap();
2151            let PhoneLocationTelemetry::Opaque(opaque) = &telemetry else {
2152                panic!("unsupported location schema must remain opaque");
2153            };
2154            assert_eq!(opaque.as_bytes(), unknown);
2155            assert_eq!(opaque.clone().into_bytes(), unknown);
2156            assert_eq!(telemetry.summary(), None);
2157            assert!(telemetry.is_opaque());
2158            let debug = format!("{telemetry:?}");
2159            assert!(!debug.contains("private-building"));
2160            assert!(!debug.contains("00:11:22:33:44:55"));
2161            assert!(debug.contains(&unknown.len().to_string()));
2162        }
2163
2164        let debug = format!("{:?}", complete_location());
2165        assert!(!debug.contains("Café"));
2166        assert!(!debug.contains("West wing"));
2167        assert!(!debug.contains("E8:ED:F3:10:29:FD"));
2168    }
2169
2170    #[test]
2171    fn location_parser_rejects_malformed_known_unsafe_and_oversized_documents() {
2172        for invalid in [
2173            b"<Interface1>".as_slice(),
2174            b"<Interface1><wifi><BSSID>private-address</BSSID><SSID>private-network</SSID><APName>private-access-point</APName></wifi></Interface1>".as_slice(),
2175            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID>voice</SSID><APName>west</APName><Credential>private-secret</Credential></wifi></Interface1>".as_slice(),
2176            b"<Interface1><OffPrem/></Interface1>".as_slice(),
2177            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID>one</SSID><SSID>two</SSID><APName>west</APName></wifi></Interface1>".as_slice(),
2178        ] {
2179            let error = parse_phone_location(invalid).unwrap_err();
2180            let error = error.to_string();
2181            assert!(!error.contains("private-address"));
2182            assert!(!error.contains("private-network"));
2183            assert!(!error.contains("private-access-point"));
2184            assert!(!error.contains("private-secret"));
2185        }
2186        assert!(matches!(
2187            parse_phone_location(&[0xff]),
2188            Err(PhoneXmlError::InvalidUtf8(_))
2189        ));
2190        assert!(matches!(
2191            parse_phone_location(b"<!DOCTYPE Interface2 [<!ENTITY location 'private'>]><Interface2>&location;</Interface2>"),
2192            Err(PhoneXmlError::DocumentTypeForbidden)
2193        ));
2194        assert!(matches!(
2195            parse_phone_location(b"<Interface2>&undeclared;</Interface2>"),
2196            Err(PhoneXmlError::InvalidEntity)
2197        ));
2198        assert!(parse_phone_location(b"<Interface2>&#1;</Interface2>").is_err());
2199        assert!(parse_phone_location(b"<Interface2>not\x01xml</Interface2>").is_err());
2200
2201        let nested = format!(
2202            "<Interface2>{}{}</Interface2>",
2203            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2204            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2205        );
2206        assert!(matches!(
2207            parse_phone_location(nested.as_bytes()),
2208            Err(PhoneXmlError::NestingTooDeep { .. })
2209        ));
2210        assert!(matches!(
2211            parse_phone_location(&vec![b'x'; PHONE_LOCATION_MAX_BYTES + 1]),
2212            Err(PhoneXmlError::LimitExceeded {
2213                maximum: PHONE_LOCATION_MAX_BYTES,
2214                ..
2215            })
2216        ));
2217
2218        #[derive(Debug)]
2219        struct FailingWriter;
2220        impl fmt::Write for FailingWriter {
2221            fn write_str(&mut self, _value: &str) -> fmt::Result {
2222                Err(fmt::Error)
2223            }
2224        }
2225        assert!(matches!(
2226            to_writer(
2227                FailingWriter,
2228                &complete_location(),
2229                PHONE_LOCATION_MAX_BYTES,
2230            ),
2231            Err(PhoneXmlError::Write(_))
2232        ));
2233    }
2234
2235    fn complete_menu() -> CiscoIpPhoneMenu {
2236        CiscoIpPhoneMenu {
2237            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
2238            application_id: Some("menu-west".into()),
2239            on_focus_lost: Some("Notify:focus?state=lost&side=west".into()),
2240            on_focus_gained: Some("Notify:focus?state=gained".into()),
2241            on_minimized: Some("Notify:minimized".into()),
2242            on_closed: Some("Notify:closed".into()),
2243            title: Some("Support <East> & West".into()),
2244            prompt: Some("Choose A & B".into()),
2245            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
2246                name: Some("Open & inspect".into()),
2247                position: PhoneSoftKeyPosition::new(1).unwrap(),
2248                url: Some("SoftKey:Select?a=1&b=2".into()),
2249                url_down: Some("SoftKey:SelectDown".into()),
2250            }],
2251            key_items: vec![CiscoIpPhoneKeyItem {
2252                key: PhoneXmlKey::NavBack,
2253                url: Some("SoftKey:Cancel".into()),
2254                url_down: None,
2255            }],
2256            items: vec![CiscoIpPhoneMenuItem {
2257                name: Some("Alice <Admin> & Bob".into()),
2258                url: Some("UserData:7:0:open/a?x=1&y=2".into()),
2259            }],
2260        }
2261    }
2262
2263    #[test]
2264    fn basic_menu_round_trips_complete_display_controls_in_schema_order() {
2265        let expected = complete_menu();
2266        let xml = expected.to_xml().unwrap();
2267        assert!(xml.contains("Support &lt;East&gt; &amp; West"));
2268        assert!(xml.contains("Alice &lt;Admin&gt; &amp; Bob"));
2269        assert!(xml.contains("x=1&amp;y=2"));
2270        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
2271        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<MenuItem>").unwrap());
2272        assert_eq!(
2273            CiscoIpPhoneMenu::from_xml(xml.as_bytes()).unwrap(),
2274            expected
2275        );
2276
2277        let minimal = CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneMenu/>").unwrap();
2278        assert!(minimal.title.is_none());
2279        assert!(minimal.items.is_empty());
2280    }
2281
2282    #[test]
2283    fn bitmap_and_resource_icon_menus_round_trip_exact_icon_families() {
2284        let bitmap = CiscoIpPhoneIconMenu::new(
2285            "Conference & staff",
2286            "Choose <one>",
2287            vec![CiscoIpPhoneIconMenuItem {
2288                name: Some("Taylor & team".into()),
2289                url: Some("UserData:1:0:participant/7?view=a&b=c".into()),
2290                icon_index: Some(2),
2291            }],
2292            vec![CiscoIpPhoneIconItem {
2293                index: 2,
2294                width: 16,
2295                height: 10,
2296                depth: 2,
2297                data: Some("000FF0".into()),
2298            }],
2299        )
2300        .unwrap();
2301        let xml = bitmap.to_xml().unwrap();
2302        assert!(xml.find("<MenuItem>").unwrap() < xml.find("<IconItem>").unwrap());
2303        assert!(xml.find("<Width>").unwrap() < xml.find("<Height>").unwrap());
2304        assert!(xml.contains("Conference &amp; staff"));
2305        assert_eq!(
2306            CiscoIpPhoneIconMenu::from_xml(xml.as_bytes()).unwrap(),
2307            bitmap
2308        );
2309
2310        let resources = CiscoIpPhoneIconFileMenu {
2311            keypad_target: Some(PhoneKeypadTarget::ActiveCall),
2312            application_id: Some("conference-list".into()),
2313            on_focus_lost: None,
2314            on_focus_gained: Some("Notify:focus".into()),
2315            on_minimized: None,
2316            on_closed: Some("SoftKey:Exit".into()),
2317            icon_index: Some(4),
2318            title: Some(CiscoIpPhoneIconTitle {
2319                icon_index: Some(5),
2320                text: "Locked & secure".into(),
2321            }),
2322            prompt: Some("Choose a participant".into()),
2323            soft_keys: Vec::new(),
2324            key_items: Vec::new(),
2325            items: vec![CiscoIpPhoneIconMenuItem {
2326                name: Some("Alex <Host>".into()),
2327                url: Some("UserData:1:0:participant/1".into()),
2328                icon_index: Some(5),
2329            }],
2330            icons: vec![CiscoIpPhoneIconFileItem {
2331                index: 5,
2332                url: "Resource:Icon.SecureCall?shade=dark&size=small".into(),
2333            }],
2334        };
2335        let xml = resources.to_xml().unwrap();
2336        assert!(xml.contains("<Title IconIndex=\"5\">Locked &amp; secure</Title>"));
2337        assert!(xml.contains("shade=dark&amp;size=small"));
2338        assert_eq!(
2339            CiscoIpPhoneIconFileMenu::from_xml(xml.as_bytes()).unwrap(),
2340            resources
2341        );
2342    }
2343
2344    #[test]
2345    fn menu_models_reject_every_collection_text_url_position_and_icon_bound() {
2346        let mut basic = complete_menu();
2347        basic.items = vec![basic.items[0].clone(); PHONE_MENU_MAX_ITEMS + 1];
2348        assert!(matches!(
2349            basic.to_xml(),
2350            Err(PhoneXmlError::LimitExceeded {
2351                kind: "menu items",
2352                ..
2353            })
2354        ));
2355
2356        let mut invalid = complete_menu();
2357        invalid.items[0].name = Some("x".repeat(65));
2358        assert!(matches!(
2359            invalid.to_xml(),
2360            Err(PhoneXmlError::InvalidField { .. })
2361        ));
2362        invalid = complete_menu();
2363        invalid.items[0].url = Some("x".repeat(PHONE_XML_URL_MAX_CHARS + 1));
2364        assert!(matches!(
2365            invalid.to_xml(),
2366            Err(PhoneXmlError::InvalidField { .. })
2367        ));
2368        invalid = complete_menu();
2369        invalid.application_id = Some(String::new());
2370        assert!(matches!(
2371            invalid.to_xml(),
2372            Err(PhoneXmlError::InvalidField { .. })
2373        ));
2374        invalid = complete_menu();
2375        invalid.on_closed = Some(String::new());
2376        assert!(matches!(
2377            invalid.to_xml(),
2378            Err(PhoneXmlError::InvalidField { .. })
2379        ));
2380        invalid = complete_menu();
2381        invalid.soft_keys[0].position = PhoneSoftKeyPosition::new(16).unwrap();
2382        assert!(invalid.to_xml().is_ok());
2383        invalid = complete_menu();
2384        invalid.soft_keys = vec![invalid.soft_keys[0].clone(); 17];
2385        assert!(matches!(
2386            invalid.to_xml(),
2387            Err(PhoneXmlError::LimitExceeded { .. })
2388        ));
2389        invalid = complete_menu();
2390        invalid.key_items = vec![invalid.key_items[0].clone(); 33];
2391        assert!(matches!(
2392            invalid.to_xml(),
2393            Err(PhoneXmlError::LimitExceeded { .. })
2394        ));
2395
2396        let item = CiscoIpPhoneIconMenuItem {
2397            name: Some("Item".into()),
2398            url: Some("SoftKey:Select".into()),
2399            icon_index: Some(0),
2400        };
2401        let icon = CiscoIpPhoneIconItem {
2402            index: 0,
2403            width: 1,
2404            height: 1,
2405            depth: 1,
2406            data: Some("00".into()),
2407        };
2408        let mut icon_menu =
2409            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![item.clone()], vec![icon.clone()])
2410                .unwrap();
2411        icon_menu.items = vec![item.clone(); PHONE_ICON_MENU_MAX_ITEMS + 1];
2412        assert!(matches!(
2413            icon_menu.to_xml(),
2414            Err(PhoneXmlError::LimitExceeded { .. })
2415        ));
2416        icon_menu =
2417            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![item.clone()], vec![icon.clone()])
2418                .unwrap();
2419        icon_menu.icons = vec![icon.clone(); PHONE_ICON_MENU_MAX_ICONS + 1];
2420        assert!(matches!(
2421            icon_menu.to_xml(),
2422            Err(PhoneXmlError::LimitExceeded { .. })
2423        ));
2424
2425        for invalid_icon in [
2426            CiscoIpPhoneIconItem {
2427                width: 0,
2428                ..icon.clone()
2429            },
2430            CiscoIpPhoneIconItem {
2431                height: 11,
2432                ..icon.clone()
2433            },
2434            CiscoIpPhoneIconItem {
2435                depth: 3,
2436                ..icon.clone()
2437            },
2438            CiscoIpPhoneIconItem {
2439                data: Some("0".into()),
2440                ..icon.clone()
2441            },
2442            CiscoIpPhoneIconItem {
2443                data: Some("GG".into()),
2444                ..icon.clone()
2445            },
2446            CiscoIpPhoneIconItem {
2447                data: Some("00".repeat(41)),
2448                ..icon
2449            },
2450        ] {
2451            assert!(
2452                CiscoIpPhoneIconMenu::new(
2453                    "Icons",
2454                    "Choose",
2455                    vec![item.clone()],
2456                    vec![invalid_icon]
2457                )
2458                .is_err()
2459            );
2460        }
2461        let mut invalid_item = item;
2462        invalid_item.icon_index = Some(10);
2463        assert!(
2464            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![invalid_item], vec![icon]).is_err()
2465        );
2466
2467        let mut file_menu = CiscoIpPhoneIconFileMenu {
2468            keypad_target: None,
2469            application_id: None,
2470            on_focus_lost: None,
2471            on_focus_gained: None,
2472            on_minimized: None,
2473            on_closed: None,
2474            icon_index: None,
2475            title: None,
2476            prompt: None,
2477            soft_keys: Vec::new(),
2478            key_items: Vec::new(),
2479            items: Vec::new(),
2480            icons: vec![CiscoIpPhoneIconFileItem {
2481                index: 10,
2482                url: "Resource:Icon.Hold".into(),
2483            }],
2484        };
2485        assert!(matches!(
2486            file_menu.to_xml(),
2487            Err(PhoneXmlError::InvalidField { .. })
2488        ));
2489        file_menu.icons[0].index = 0;
2490        file_menu.icons[0].url.clear();
2491        assert!(matches!(
2492            file_menu.to_xml(),
2493            Err(PhoneXmlError::InvalidField { .. })
2494        ));
2495    }
2496
2497    #[test]
2498    fn menu_parsers_reject_wrong_roots_unknown_fields_malformed_input_and_writer_failure() {
2499        assert!(CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneIconMenu/>").is_err());
2500        assert!(CiscoIpPhoneIconMenu::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
2501        assert!(CiscoIpPhoneIconFileMenu::from_xml(b"<CiscoIPPhoneIconMenu/>").is_err());
2502        assert!(
2503            CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneMenu><Unknown/></CiscoIPPhoneMenu>",)
2504                .is_err()
2505        );
2506        assert!(CiscoIpPhoneIconMenu::from_xml(b"<CiscoIPPhoneIconMenu>").is_err());
2507        assert!(
2508            CiscoIpPhoneIconFileMenu::from_xml(b"<!DOCTYPE menu><CiscoIPPhoneIconFileMenu/>",)
2509                .is_err()
2510        );
2511        assert!(matches!(
2512            CiscoIpPhoneMenu::from_xml(&[0xff]),
2513            Err(PhoneXmlError::InvalidUtf8(_))
2514        ));
2515        assert!(matches!(
2516            complete_menu().to_xml_with_limit(10),
2517            Err(PhoneXmlError::LimitExceeded { .. })
2518        ));
2519
2520        #[derive(Debug)]
2521        struct FailingWriter;
2522        impl fmt::Write for FailingWriter {
2523            fn write_str(&mut self, _value: &str) -> fmt::Result {
2524                Err(fmt::Error)
2525            }
2526        }
2527        assert!(matches!(
2528            to_writer(FailingWriter, &complete_menu(), PHONE_MENU_MAX_BYTES),
2529            Err(PhoneXmlError::Write(_))
2530        ));
2531    }
2532
2533    #[test]
2534    fn conference_lists_round_trip_menu_and_icon_families_with_typed_actions() {
2535        let conference_id = ConferenceId::new(41);
2536        let participants = [
2537            ConferenceListEntry {
2538                participant_id: ParticipantId::new(7),
2539                name: "Alex <Host> & Co".into(),
2540                number: "2100".into(),
2541                moderator: true,
2542                muted: false,
2543            },
2544            ConferenceListEntry {
2545                participant_id: ParticipantId::new(8),
2546                name: String::new(),
2547                number: "2200".into(),
2548                moderator: false,
2549                muted: true,
2550            },
2551            ConferenceListEntry {
2552                participant_id: ParticipantId::new(9),
2553                name: "Casey".into(),
2554                number: "2300".into(),
2555                moderator: false,
2556                muted: false,
2557            },
2558        ];
2559        for family in [ConferenceMenuFamily::Menu, ConferenceMenuFamily::IconMenu] {
2560            let expected =
2561                ConferenceListDocument::new(conference_id, &participants, family).unwrap();
2562            let xml = expected.to_xml().unwrap();
2563            assert!(xml.contains("Alex &lt;Host&gt; &amp; Co"));
2564            let decoded = ConferenceListDocument::from_xml(xml.as_bytes(), family).unwrap();
2565            assert_eq!(decoded, expected);
2566            assert_eq!(
2567                decoded.actions().collect::<Vec<_>>(),
2568                [
2569                    ConferenceListAction::Participant {
2570                        conference_id,
2571                        participant_id: ParticipantId::new(7),
2572                    },
2573                    ConferenceListAction::Participant {
2574                        conference_id,
2575                        participant_id: ParticipantId::new(8),
2576                    },
2577                    ConferenceListAction::Participant {
2578                        conference_id,
2579                        participant_id: ParticipantId::new(9),
2580                    },
2581                    ConferenceListAction::End { conference_id },
2582                ]
2583            );
2584        }
2585    }
2586
2587    #[test]
2588    fn conference_participant_actions_round_trip_both_families_and_removal_policy() {
2589        let conference_id = ConferenceId::new(41);
2590        let mut participant = ConferenceListEntry {
2591            participant_id: ParticipantId::new(8),
2592            name: "Alex <Admin> & Co".into(),
2593            number: "2200".into(),
2594            moderator: false,
2595            muted: false,
2596        };
2597        for family in [ConferenceMenuFamily::Menu, ConferenceMenuFamily::IconMenu] {
2598            let expected = ConferenceParticipantActionsDocument::new(
2599                conference_id,
2600                &participant,
2601                true,
2602                false,
2603                family,
2604            )
2605            .unwrap();
2606            let xml = expected.to_xml().unwrap();
2607            let decoded =
2608                ConferenceParticipantActionsDocument::from_xml(xml.as_bytes(), family).unwrap();
2609            assert_eq!(decoded, expected);
2610            assert_eq!(
2611                decoded.actions().collect::<Vec<_>>(),
2612                [
2613                    ConferenceListAction::Mute {
2614                        conference_id,
2615                        participant_id: participant.participant_id,
2616                    },
2617                    ConferenceListAction::Remove {
2618                        conference_id,
2619                        participant_id: participant.participant_id,
2620                    },
2621                    ConferenceListAction::Promote {
2622                        conference_id,
2623                        participant_id: participant.participant_id,
2624                    },
2625                ]
2626            );
2627
2628            participant.muted = true;
2629            let not_removable = ConferenceParticipantActionsDocument::new(
2630                conference_id,
2631                &participant,
2632                false,
2633                false,
2634                family,
2635            )
2636            .unwrap();
2637            assert_eq!(
2638                not_removable.actions().collect::<Vec<_>>(),
2639                [
2640                    ConferenceListAction::Unmute {
2641                        conference_id,
2642                        participant_id: participant.participant_id,
2643                    },
2644                    ConferenceListAction::Promote {
2645                        conference_id,
2646                        participant_id: participant.participant_id,
2647                    },
2648                ]
2649            );
2650            participant.moderator = true;
2651            let demotable = ConferenceParticipantActionsDocument::new(
2652                conference_id,
2653                &participant,
2654                false,
2655                true,
2656                family,
2657            )
2658            .unwrap();
2659            assert_eq!(
2660                demotable.actions().collect::<Vec<_>>(),
2661                [ConferenceListAction::Demote {
2662                    conference_id,
2663                    participant_id: participant.participant_id,
2664                }]
2665            );
2666            let sole_moderator = ConferenceParticipantActionsDocument::new(
2667                conference_id,
2668                &participant,
2669                false,
2670                false,
2671                family,
2672            )
2673            .unwrap();
2674            assert!(sole_moderator.actions().next().is_none());
2675            participant.moderator = false;
2676            participant.muted = false;
2677        }
2678    }
2679
2680    #[test]
2681    fn conference_lists_reject_limits_malformed_actions_and_wrong_family() {
2682        let participants = vec![
2683            ConferenceListEntry {
2684                participant_id: ParticipantId::new(1),
2685                name: "Participant".into(),
2686                number: String::new(),
2687                moderator: false,
2688                muted: false,
2689            };
2690            CONFERENCE_LIST_MAX_PARTICIPANTS + 1
2691        ];
2692        assert!(matches!(
2693            ConferenceListDocument::new(
2694                ConferenceId::new(1),
2695                &participants,
2696                ConferenceMenuFamily::Menu,
2697            ),
2698            Err(PhoneXmlError::LimitExceeded {
2699                kind: "conference participants",
2700                ..
2701            })
2702        ));
2703        assert!(ConferenceListAction::parse("conference/1/participant/not-a-number").is_none());
2704        assert!(ConferenceListAction::parse("conference/1/remove/7").is_none());
2705        assert_eq!(
2706            ConferenceListAction::parse("conference/1/participant/7/remove"),
2707            Some(ConferenceListAction::Remove {
2708                conference_id: ConferenceId::new(1),
2709                participant_id: ParticipantId::new(7),
2710            })
2711        );
2712        assert_eq!(
2713            ConferenceListAction::from_route(&[
2714                "conference".into(),
2715                "1".into(),
2716                "participant".into(),
2717                "7".into(),
2718                "remove".into(),
2719            ]),
2720            Some(ConferenceListAction::Remove {
2721                conference_id: ConferenceId::new(1),
2722                participant_id: ParticipantId::new(7),
2723            })
2724        );
2725        for (operation, expected) in [
2726            (
2727                "promote",
2728                ConferenceListAction::Promote {
2729                    conference_id: ConferenceId::new(1),
2730                    participant_id: ParticipantId::new(7),
2731                },
2732            ),
2733            (
2734                "demote",
2735                ConferenceListAction::Demote {
2736                    conference_id: ConferenceId::new(1),
2737                    participant_id: ParticipantId::new(7),
2738                },
2739            ),
2740        ] {
2741            let route = [
2742                "conference".into(),
2743                "1".into(),
2744                "participant".into(),
2745                "7".into(),
2746                operation.into(),
2747            ];
2748            assert_eq!(ConferenceListAction::from_route(&route), Some(expected));
2749        }
2750
2751        let menu = ConferenceListDocument::new(
2752            ConferenceId::new(1),
2753            &participants[..1],
2754            ConferenceMenuFamily::Menu,
2755        )
2756        .unwrap()
2757        .to_xml()
2758        .unwrap();
2759        assert!(
2760            ConferenceListDocument::from_xml(menu.as_bytes(), ConferenceMenuFamily::IconMenu)
2761                .is_err()
2762        );
2763        assert!(
2764            ConferenceListDocument::from_xml(
2765                b"<!DOCTYPE menu><CiscoIPPhoneMenu/>",
2766                ConferenceMenuFamily::Menu,
2767            )
2768            .is_err()
2769        );
2770    }
2771
2772    #[test]
2773    fn directory_schema_round_trips_entries_controls_attributes_and_escaping() {
2774        let expected = CiscoIpPhoneDirectory {
2775            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
2776            application_id: Some("directory-west".into()),
2777            on_focus_lost: Some("Notify:focus?state=lost&view=all".into()),
2778            on_focus_gained: None,
2779            on_minimized: None,
2780            on_closed: Some("SoftKey:Exit".into()),
2781            title: Some("R&D <West>".into()),
2782            prompt: Some("Choose A & B".into()),
2783            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
2784                name: Some("Next".into()),
2785                position: PhoneSoftKeyPosition::new(3).unwrap(),
2786                url: Some("http://pbx.test/directory?page=2&query=R%26D".into()),
2787                url_down: None,
2788            }],
2789            key_items: vec![CiscoIpPhoneKeyItem {
2790                key: PhoneXmlKey::NavBack,
2791                url: Some("SoftKey:Cancel".into()),
2792                url_down: None,
2793            }],
2794            entries: vec![CiscoIpPhoneDirectoryEntry {
2795                name: Some("Alice <Admin> & Bob".into()),
2796                telephone: Some("1001&2".into()),
2797            }],
2798        };
2799
2800        let xml = expected.to_xml().unwrap();
2801        assert!(xml.starts_with("<CiscoIPPhoneDirectory"));
2802        assert!(xml.contains("keypadTarget=\"applicationCall\""));
2803        assert!(xml.contains("R&amp;D &lt;West&gt;"));
2804        assert!(xml.contains("Alice &lt;Admin&gt; &amp; Bob"));
2805        assert_eq!(
2806            CiscoIpPhoneDirectory::from_xml(xml.as_bytes()).unwrap(),
2807            expected
2808        );
2809    }
2810
2811    #[test]
2812    fn directory_schema_accepts_the_minimal_document_and_optionally_empty_fields() {
2813        let xml = b"<CiscoIPPhoneDirectory><Title/><Prompt/><DirectoryEntry><Name/><Telephone/></DirectoryEntry></CiscoIPPhoneDirectory>";
2814        let document = CiscoIpPhoneDirectory::from_xml(xml).unwrap();
2815        assert_eq!(document.title.as_deref(), Some(""));
2816        assert_eq!(document.prompt.as_deref(), Some(""));
2817        assert_eq!(document.entries.len(), 1);
2818        assert_eq!(document.entries[0].name.as_deref(), Some(""));
2819        assert_eq!(document.entries[0].telephone.as_deref(), Some(""));
2820    }
2821
2822    #[test]
2823    fn directory_schema_enforces_entry_text_control_and_document_bounds() {
2824        let too_many = vec![
2825            CiscoIpPhoneDirectoryEntry {
2826                name: Some("Name".into()),
2827                telephone: Some("1000".into()),
2828            };
2829            PHONE_DIRECTORY_MAX_ENTRIES + 1
2830        ];
2831        assert!(matches!(
2832            CiscoIpPhoneDirectory::new("Directory", "Choose", too_many),
2833            Err(PhoneXmlError::LimitExceeded {
2834                kind: "directory entries",
2835                ..
2836            })
2837        ));
2838
2839        let invalid = CiscoIpPhoneDirectory::new(
2840            "Directory",
2841            "Choose",
2842            vec![CiscoIpPhoneDirectoryEntry {
2843                name: Some("x".repeat(PHONE_DIRECTORY_TEXT_MAX_CHARS + 1)),
2844                telephone: Some("1000".into()),
2845            }],
2846        )
2847        .unwrap_err();
2848        assert!(matches!(invalid, PhoneXmlError::InvalidField { .. }));
2849
2850        assert!(PhoneSoftKeyPosition::new(0).is_err());
2851        assert!(PhoneSoftKeyPosition::new(-1).is_ok());
2852        assert!(PhoneSoftKeyPosition::new(16).is_ok());
2853        assert!(PhoneSoftKeyPosition::new(17).is_err());
2854
2855        assert!(
2856            CiscoIpPhoneDirectory::from_xml(b"<!DOCTYPE directory><CiscoIPPhoneDirectory/>",)
2857                .is_err()
2858        );
2859        assert!(matches!(
2860            CiscoIpPhoneDirectory::from_xml(&vec![b'x'; PHONE_DIRECTORY_MAX_BYTES + 1]),
2861            Err(PhoneXmlError::LimitExceeded { .. })
2862        ));
2863    }
2864}