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        assert!(PhoneBackgroundHttpUrl::new("https://pbx.example/image.png").is_ok());
1068        for invalid in [
1069            "",
1070            "HTTP:Desktops/320x212x16/image.png",
1071            "TFTP://server/Desktops/image.png",
1072            "TFTP:/Desktops/image.png",
1073            "TFTP:Desktops/../image.png",
1074            "TFTP:Desktops/%2e%2e/image.png",
1075            "TFTP:Desktops/%2Fprivate/image.png",
1076            "TFTP:Desktops/%00private.png",
1077            "TFTP:Desktops/%Q0private.png",
1078            "TFTP:Desktops/image.jpg",
1079            "TFTP:Desktops/image.png?token=private",
1080            "TFTP:Desktops/image.png#private",
1081        ] {
1082            let error = PhoneBackgroundTftpUrl::new(invalid).unwrap_err();
1083            if !invalid.is_empty() {
1084                assert!(!error.to_string().contains(invalid));
1085            }
1086        }
1087        for invalid in [
1088            "",
1089            "ftp://pbx.example/private.png",
1090            "TFTP:Desktops/image.png",
1091            "background.png",
1092            "http://user:secret@pbx.example/private.png",
1093            "http://pbx.example/private.png#token",
1094        ] {
1095            let error = PhoneBackgroundHttpUrl::new(invalid).unwrap_err();
1096            if !invalid.is_empty() {
1097                assert!(!error.to_string().contains(invalid));
1098            }
1099        }
1100        assert!(PhoneBackgroundTftpUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1101        assert!(PhoneBackgroundHttpUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1102        assert!(PhoneBackgroundTftpUrl::new("TFTP:Desktops/not\u{1}xml.png").is_err());
1103        assert!(PhoneBackgroundHttpUrl::new("http://pbx.example/not\u{1}xml.png").is_err());
1104        assert!(
1105            !format!(
1106                "{:?}",
1107                PhoneBackgroundHttpUrl::new("http://private.example/secret.png").unwrap()
1108            )
1109            .contains("private.example")
1110        );
1111    }
1112
1113    #[test]
1114    fn background_image_list_enforces_collection_and_document_bounds() {
1115        let maximum = (0..PHONE_BACKGROUND_LIST_MAX_ITEMS)
1116            .map(|index| background_list_item(&format!("image-{index}")))
1117            .collect();
1118        assert!(CiscoIpPhoneImageList::new(maximum).is_ok());
1119
1120        let too_many = (0..=PHONE_BACKGROUND_LIST_MAX_ITEMS)
1121            .map(|index| background_list_item(&format!("image-{index}")))
1122            .collect();
1123        assert!(matches!(
1124            CiscoIpPhoneImageList::new(too_many),
1125            Err(PhoneXmlError::LimitExceeded {
1126                kind: "background image choices",
1127                maximum: PHONE_BACKGROUND_LIST_MAX_ITEMS,
1128                ..
1129            })
1130        ));
1131
1132        let document = CiscoIpPhoneImageList::new(vec![background_list_item("image")]).unwrap();
1133        assert!(matches!(
1134            document.to_xml_with_limit(10),
1135            Err(PhoneXmlError::LimitExceeded { .. })
1136        ));
1137        assert!(matches!(
1138            CiscoIpPhoneImageList::from_xml(&vec![b'x'; PHONE_BACKGROUND_LIST_MAX_BYTES + 1]),
1139            Err(PhoneXmlError::LimitExceeded { .. })
1140        ));
1141        let preview =
1142            PhoneBackgroundControlDocument::Preview(CiscoIpPhoneSetBackgroundPreview::new(
1143                PhoneBackgroundHttpUrl::new("http://pbx.example/image.png").unwrap(),
1144            ));
1145        assert!(matches!(
1146            preview.to_xml_with_limit(10),
1147            Err(PhoneXmlError::LimitExceeded { .. })
1148        ));
1149    }
1150
1151    #[test]
1152    fn background_parser_rejects_wrong_roots_unknowns_malformed_and_unsafe_xml() {
1153        for invalid in [
1154            b"<CiscoIPPhoneMenu/>".as_slice(),
1155            b"<CiscoIPPhoneImageList><ImageItem Image=\"TFTP:Desktops/TN.png\"/></CiscoIPPhoneImageList>".as_slice(),
1156            b"<CiscoIPPhoneImageList><ImageItem Image=\"TFTP:Desktops/TN.png\" URL=\"TFTP:Desktops/image.png\" Unknown=\"yes\"/></CiscoIPPhoneImageList>".as_slice(),
1157            b"<CiscoIPPhoneImageList>".as_slice(),
1158        ] {
1159            assert!(CiscoIpPhoneImageList::from_xml(invalid).is_err());
1160        }
1161        assert!(CiscoIpPhoneSetBackground::from_xml(
1162            b"<setBackgroundPreview><image>http://pbx.example/image.png</image></setBackgroundPreview>"
1163        )
1164        .is_err());
1165        assert!(CiscoIpPhoneSetBackgroundPreview::from_xml(
1166            b"<setBackgroundPreview><image>ftp://pbx.example/image.png</image></setBackgroundPreview>"
1167        )
1168        .is_err());
1169        assert!(PhoneBackgroundControlDocument::from_xml(b"<getDeviceCaps/>").is_err());
1170        assert!(matches!(
1171            CiscoIpPhoneImageList::from_xml(&[0xff]),
1172            Err(PhoneXmlError::InvalidUtf8(_))
1173        ));
1174        assert!(matches!(
1175            CiscoIpPhoneImageList::from_xml(
1176                br#"<!DOCTYPE images [<!ENTITY path "private">]><CiscoIPPhoneImageList><ImageItem Image="TFTP:Desktops/&path;-TN.png" URL="TFTP:Desktops/&path;.png"/></CiscoIPPhoneImageList>"#,
1177            ),
1178            Err(PhoneXmlError::DocumentTypeForbidden)
1179        ));
1180        assert!(CiscoIpPhoneImageList::from_xml(
1181            br#"<CiscoIPPhoneImageList><ImageItem Image="TFTP:Desktops/&unknown;-TN.png" URL="TFTP:Desktops/image.png"/></CiscoIPPhoneImageList>"#,
1182        )
1183        .is_err());
1184        let nested = format!(
1185            "<CiscoIPPhoneImageList>{}<ImageItem Image=\"TFTP:Desktops/TN.png\" URL=\"TFTP:Desktops/image.png\"/>{}</CiscoIPPhoneImageList>",
1186            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1187            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1188        );
1189        assert!(matches!(
1190            CiscoIpPhoneImageList::from_xml(nested.as_bytes()),
1191            Err(PhoneXmlError::NestingTooDeep { .. })
1192        ));
1193
1194        #[derive(Debug)]
1195        struct FailingWriter;
1196        impl fmt::Write for FailingWriter {
1197            fn write_str(&mut self, _value: &str) -> fmt::Result {
1198                Err(fmt::Error)
1199            }
1200        }
1201        assert!(matches!(
1202            to_writer(
1203                FailingWriter,
1204                &CiscoIpPhoneImageList::new(vec![background_list_item("image")]).unwrap(),
1205                PHONE_BACKGROUND_LIST_MAX_BYTES,
1206            ),
1207            Err(PhoneXmlError::Write(_))
1208        ));
1209    }
1210
1211    #[test]
1212    fn ringtone_document_round_trips_exact_root_child_order_and_escaping() {
1213        let url =
1214            PhoneRingtoneUrl::new("http://pbx.example/ringtones/Classic.raw?site=east&set=primary")
1215                .unwrap();
1216        assert_eq!(
1217            url.as_str(),
1218            "http://pbx.example/ringtones/Classic.raw?site=east&set=primary"
1219        );
1220        assert_eq!(
1221            url.clone().into_string(),
1222            "http://pbx.example/ringtones/Classic.raw?site=east&set=primary"
1223        );
1224        let expected = CiscoIpPhoneSetRingTone::new(url);
1225        let xml = expected.to_xml().unwrap();
1226        assert_eq!(
1227            xml,
1228            "<setRingTone><ringTone>http://pbx.example/ringtones/Classic.raw?site=east&amp;set=primary</ringTone></setRingTone>"
1229        );
1230        assert_eq!(
1231            CiscoIpPhoneSetRingTone::from_xml(xml.as_bytes()).unwrap(),
1232            expected
1233        );
1234    }
1235
1236    #[test]
1237    fn ringtone_url_enforces_transport_shape_length_and_secret_safe_errors() {
1238        assert_eq!(
1239            PhoneRingtoneUrl::new("http://[2001:db8::1]:8080/ringtones/Office.raw?locale=sv")
1240                .unwrap()
1241                .as_str(),
1242            "http://[2001:db8::1]:8080/ringtones/Office.raw?locale=sv"
1243        );
1244        for invalid in [
1245            "",
1246            "HTTP://pbx.example/ringtone.raw",
1247            "https://pbx.example/ringtone.raw",
1248            "TFTP:Ringlist.xml",
1249            "ringtone.raw",
1250            "http://user:secret@pbx.example/private.raw",
1251            "http://pbx.example/private.raw#secret",
1252            "http://pbx.example/not allowed.raw",
1253            "http://pbx.example/not\tallowed.raw",
1254            "http://pbx.example/not\\allowed.raw",
1255            "http://pbx.example/not%Q0allowed.raw",
1256        ] {
1257            let error = PhoneRingtoneUrl::new(invalid).unwrap_err();
1258            if !invalid.is_empty() {
1259                assert!(!error.to_string().contains(invalid));
1260            }
1261        }
1262        assert!(PhoneRingtoneUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1263        assert!(PhoneRingtoneUrl::new("http://pbx.example/not\u{1}xml.raw").is_err());
1264        assert!(
1265            !format!(
1266                "{:?}",
1267                PhoneRingtoneUrl::new("http://private.example/secret.raw").unwrap()
1268            )
1269            .contains("private.example")
1270        );
1271    }
1272
1273    #[test]
1274    fn ringtone_parser_rejects_wrong_root_unknown_malformed_unsafe_and_bounded_xml() {
1275        for invalid in [
1276            b"<setBackground><ringTone>http://pbx.example/r.raw</ringTone></setBackground>"
1277                .as_slice(),
1278            b"<setRingTone/>".as_slice(),
1279            b"<setRingTone unknown=\"yes\"><ringTone>http://pbx.example/r.raw</ringTone></setRingTone>"
1280                .as_slice(),
1281            b"<setRingTone><ringTone>http://pbx.example/r.raw</ringTone><Unknown/></setRingTone>"
1282                .as_slice(),
1283            b"<setRingTone><ringTone>https://pbx.example/r.raw</ringTone></setRingTone>"
1284                .as_slice(),
1285            b"<setRingTone><ringTone>".as_slice(),
1286        ] {
1287            assert!(CiscoIpPhoneSetRingTone::from_xml(invalid).is_err());
1288        }
1289        assert!(matches!(
1290            CiscoIpPhoneSetRingTone::from_xml(&[0xff]),
1291            Err(PhoneXmlError::InvalidUtf8(_))
1292        ));
1293        assert!(matches!(
1294            CiscoIpPhoneSetRingTone::from_xml(
1295                br#"<!DOCTYPE ringtone [<!ENTITY host "private.example">]><setRingTone><ringTone>http://&host;/r.raw</ringTone></setRingTone>"#,
1296            ),
1297            Err(PhoneXmlError::DocumentTypeForbidden)
1298        ));
1299        assert!(
1300            CiscoIpPhoneSetRingTone::from_xml(
1301                b"<setRingTone><ringTone>http://&unknown;/r.raw</ringTone></setRingTone>",
1302            )
1303            .is_err()
1304        );
1305        assert!(matches!(
1306            CiscoIpPhoneSetRingTone::from_xml(&vec![b'x'; PHONE_RINGTONE_MAX_BYTES + 1]),
1307            Err(PhoneXmlError::LimitExceeded {
1308                maximum: PHONE_RINGTONE_MAX_BYTES,
1309                ..
1310            })
1311        ));
1312
1313        let nested = format!(
1314            "<setRingTone>{}<ringTone>http://pbx.example/r.raw</ringTone>{}</setRingTone>",
1315            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1316            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1317        );
1318        assert!(matches!(
1319            CiscoIpPhoneSetRingTone::from_xml(nested.as_bytes()),
1320            Err(PhoneXmlError::NestingTooDeep { .. })
1321        ));
1322
1323        let document = CiscoIpPhoneSetRingTone::new(
1324            PhoneRingtoneUrl::new("http://pbx.example/r.raw").unwrap(),
1325        );
1326        assert!(matches!(
1327            document.to_xml_with_limit(10),
1328            Err(PhoneXmlError::LimitExceeded { .. })
1329        ));
1330        #[derive(Debug)]
1331        struct FailingWriter;
1332        impl fmt::Write for FailingWriter {
1333            fn write_str(&mut self, _value: &str) -> fmt::Result {
1334                Err(fmt::Error)
1335            }
1336        }
1337        assert!(matches!(
1338            to_writer(FailingWriter, &document, PHONE_RINGTONE_MAX_BYTES),
1339            Err(PhoneXmlError::Write(_))
1340        ));
1341    }
1342
1343    fn image_soft_keys() -> Vec<CiscoIpPhoneSoftKeyItem> {
1344        vec![CiscoIpPhoneSoftKeyItem {
1345            name: Some("Select & view".into()),
1346            position: PhoneSoftKeyPosition::new(1).unwrap(),
1347            url: Some("SoftKey:Select?view=image&side=west".into()),
1348            url_down: Some("Notify:select?state=down".into()),
1349        }]
1350    }
1351
1352    fn image_key_items() -> Vec<CiscoIpPhoneKeyItem> {
1353        vec![CiscoIpPhoneKeyItem {
1354            key: PhoneXmlKey::NavSelect,
1355            url: Some("Key:Select?view=image&side=west".into()),
1356            url_down: None,
1357        }]
1358    }
1359
1360    fn complete_bitmap_image() -> CiscoIpPhoneImage {
1361        CiscoIpPhoneImage {
1362            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
1363            application_id: Some("image-service".into()),
1364            on_focus_lost: Some("Notify:image?focus=lost".into()),
1365            on_focus_gained: Some("Notify:image?focus=gained".into()),
1366            on_minimized: Some("Notify:image?state=minimized".into()),
1367            on_closed: Some("Notify:image?state=closed".into()),
1368            title: Some("Café <map> & menu".into()),
1369            prompt: Some("Choose & inspect".into()),
1370            soft_keys: image_soft_keys(),
1371            key_items: image_key_items(),
1372            location_x: Some(-1),
1373            location_y: Some(64),
1374            width: 133,
1375            height: 65,
1376            depth: 2,
1377            data: Some(PhoneBitmapData::new(vec![0x00, 0xab, 0xff]).unwrap()),
1378        }
1379    }
1380
1381    fn complete_image_file() -> CiscoIpPhoneImageFile {
1382        CiscoIpPhoneImageFile {
1383            keypad_target: Some(PhoneKeypadTarget::Application),
1384            application_id: Some("image-file-service".into()),
1385            on_focus_lost: None,
1386            on_focus_gained: None,
1387            on_minimized: None,
1388            on_closed: Some("Notify:image-file?state=closed".into()),
1389            title: Some("Image <file>".into()),
1390            prompt: Some("Open & inspect".into()),
1391            soft_keys: image_soft_keys(),
1392            key_items: image_key_items(),
1393            location_x: Some(297),
1394            location_y: Some(-1),
1395            url: PhoneImageUrl::new("https://pbx.example/image.png?id=7&view=full").unwrap(),
1396        }
1397    }
1398
1399    fn complete_graphic_menu() -> CiscoIpPhoneGraphicMenu {
1400        CiscoIpPhoneGraphicMenu {
1401            keypad_target: Some(PhoneKeypadTarget::ActiveCall),
1402            application_id: Some("graphic-menu".into()),
1403            on_focus_lost: None,
1404            on_focus_gained: None,
1405            on_minimized: None,
1406            on_closed: None,
1407            title: Some("Graphic menu".into()),
1408            prompt: Some("Choose a region".into()),
1409            soft_keys: image_soft_keys(),
1410            key_items: image_key_items(),
1411            location_x: Some(132),
1412            location_y: Some(-1),
1413            width: 1,
1414            height: 1,
1415            depth: 1,
1416            data: Some(PhoneBitmapData::new(vec![0x12, 0x34]).unwrap()),
1417            items: vec![CiscoIpPhoneMenuItem {
1418                name: Some("West <wing>".into()),
1419                url: Some("UserData:9095:0:image/west?floor=1&open=true".into()),
1420            }],
1421        }
1422    }
1423
1424    fn complete_graphic_file_menu() -> CiscoIpPhoneGraphicFileMenu {
1425        CiscoIpPhoneGraphicFileMenu {
1426            keypad_target: None,
1427            application_id: Some("graphic-file-menu".into()),
1428            on_focus_lost: None,
1429            on_focus_gained: None,
1430            on_minimized: None,
1431            on_closed: None,
1432            title: Some("Floor plan".into()),
1433            prompt: Some("Touch a room".into()),
1434            soft_keys: image_soft_keys(),
1435            key_items: image_key_items(),
1436            location_x: Some(-1),
1437            location_y: Some(167),
1438            url: PhoneImageUrl::new("https://pbx.example/floor.png?site=east&floor=2").unwrap(),
1439            items: vec![CiscoIpPhoneTouchAreaMenuItem {
1440                name: Some("Room A & B".into()),
1441                url: Some("UserData:9095:0/room/a?mode=open&floor=2".into()),
1442                touch_area: Some(PhoneTouchArea {
1443                    x1: 4,
1444                    y1: 8,
1445                    x2: 90,
1446                    y2: 120,
1447                }),
1448            }],
1449        }
1450    }
1451
1452    #[test]
1453    fn image_documents_round_trip_schema_order_hex_utf8_and_escaping() {
1454        let image = complete_bitmap_image();
1455        let xml = image.to_xml().unwrap();
1456        assert!(xml.contains("Café &lt;map&gt; &amp; menu"));
1457        assert!(xml.contains("<Data>00ABFF</Data>"));
1458        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
1459        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<LocationX>").unwrap());
1460        assert!(xml.find("<Depth>").unwrap() < xml.find("<Data>").unwrap());
1461        assert_eq!(CiscoIpPhoneImage::from_xml(xml.as_bytes()).unwrap(), image);
1462        assert_eq!(
1463            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1464            PhoneImageDocument::Image(image)
1465        );
1466
1467        let spaced_hex = b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>00 ab\nFF</Data></CiscoIPPhoneImage>";
1468        let parsed = CiscoIpPhoneImage::from_xml(spaced_hex).unwrap();
1469        assert_eq!(parsed.data.unwrap().as_bytes(), [0x00, 0xab, 0xff]);
1470
1471        let image_file = complete_image_file();
1472        let xml = image_file.to_xml().unwrap();
1473        assert!(xml.contains("Image &lt;file&gt;"));
1474        assert!(xml.contains("id=7&amp;view=full"));
1475        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<LocationX>").unwrap());
1476        let controls_end = xml.find("</KeyItem>").unwrap();
1477        let image_url = controls_end + xml[controls_end..].find("<URL>").unwrap();
1478        assert!(xml.find("<LocationY>").unwrap() < image_url);
1479        assert_eq!(
1480            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1481            PhoneImageDocument::ImageFile(image_file)
1482        );
1483
1484        let graphic = complete_graphic_menu();
1485        let xml = graphic.to_xml().unwrap();
1486        assert!(xml.contains("West &lt;wing&gt;"));
1487        assert!(xml.find("<Data>").unwrap() < xml.find("<MenuItem>").unwrap());
1488        assert_eq!(
1489            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1490            PhoneImageDocument::GraphicMenu(graphic)
1491        );
1492
1493        let graphic_file = complete_graphic_file_menu();
1494        let xml = graphic_file.to_xml().unwrap();
1495        assert!(xml.contains("Room A &amp; B"));
1496        assert!(xml.contains(r#"<TouchArea X1="4" Y1="8" X2="90" Y2="120"/>"#));
1497        let controls_end = xml.find("</KeyItem>").unwrap();
1498        let image_url = controls_end + xml[controls_end..].find("<URL>").unwrap();
1499        assert!(image_url < xml.find("<MenuItem>").unwrap());
1500        assert_eq!(
1501            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
1502            PhoneImageDocument::GraphicFileMenu(graphic_file)
1503        );
1504    }
1505
1506    #[test]
1507    fn image_documents_enforce_exact_geometry_data_url_and_collection_bounds() {
1508        let mut image = complete_bitmap_image();
1509        assert!(image.validate().is_ok());
1510        image.location_x = Some(-2);
1511        assert!(image.validate().is_err());
1512        image.location_x = Some(133);
1513        assert!(image.validate().is_err());
1514        image.location_x = Some(0);
1515        image.location_y = Some(-2);
1516        assert!(image.validate().is_err());
1517        image.location_y = Some(65);
1518        assert!(image.validate().is_err());
1519        image.location_y = None;
1520        for (width, height, depth) in [
1521            (0, 1, 1),
1522            (134, 1, 1),
1523            (1, 0, 1),
1524            (1, 66, 1),
1525            (1, 1, 0),
1526            (1, 1, 3),
1527        ] {
1528            image.width = width;
1529            image.height = height;
1530            image.depth = depth;
1531            assert!(image.validate().is_err());
1532        }
1533        image.width = 1;
1534        image.height = 1;
1535        image.depth = 1;
1536        image.data = Some(PhoneBitmapData::new(vec![0; PHONE_IMAGE_BITMAP_MAX_BYTES]).unwrap());
1537        assert!(image.validate().is_ok());
1538        assert!(matches!(
1539            PhoneBitmapData::new(vec![0; PHONE_IMAGE_BITMAP_MAX_BYTES + 1]),
1540            Err(PhoneXmlError::LimitExceeded {
1541                kind: "bitmap image data bytes",
1542                maximum: PHONE_IMAGE_BITMAP_MAX_BYTES,
1543                ..
1544            })
1545        ));
1546
1547        let mut image_file = complete_image_file();
1548        for x in [-2, 298] {
1549            image_file.location_x = Some(x);
1550            assert!(image_file.validate().is_err());
1551        }
1552        image_file.location_x = None;
1553        for y in [-2, 168] {
1554            image_file.location_y = Some(y);
1555            assert!(image_file.validate().is_err());
1556        }
1557        assert!(PhoneImageUrl::new("").is_err());
1558        assert!(PhoneImageUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1559        assert!(PhoneImageUrl::new("not\u{1}xml").is_err());
1560
1561        let mut graphic = complete_graphic_menu();
1562        graphic.items = (0..PHONE_GRAPHIC_MENU_MAX_ITEMS)
1563            .map(|_| CiscoIpPhoneMenuItem {
1564                name: Some("x".repeat(64)),
1565                url: Some("x".repeat(PHONE_XML_URL_MAX_CHARS)),
1566            })
1567            .collect();
1568        assert!(graphic.validate().is_ok());
1569        graphic.items.push(CiscoIpPhoneMenuItem {
1570            name: None,
1571            url: None,
1572        });
1573        assert!(graphic.validate().is_err());
1574        graphic.items.truncate(1);
1575        graphic.items[0].name = Some("x".repeat(65));
1576        assert!(graphic.validate().is_err());
1577
1578        let mut graphic_file = complete_graphic_file_menu();
1579        graphic_file.items = (0..PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS)
1580            .map(|_| CiscoIpPhoneTouchAreaMenuItem {
1581                name: Some("x".repeat(32)),
1582                url: Some("x".repeat(PHONE_XML_URL_MAX_CHARS)),
1583                touch_area: Some(PhoneTouchArea {
1584                    x1: u16::MIN,
1585                    y1: u16::MIN,
1586                    x2: u16::MAX,
1587                    y2: u16::MAX,
1588                }),
1589            })
1590            .collect();
1591        assert!(graphic_file.validate().is_ok());
1592        graphic_file.items.push(CiscoIpPhoneTouchAreaMenuItem {
1593            name: None,
1594            url: None,
1595            touch_area: None,
1596        });
1597        assert!(graphic_file.validate().is_err());
1598        graphic_file.items.truncate(1);
1599        graphic_file.items[0].name = Some("x".repeat(33));
1600        assert!(graphic_file.validate().is_err());
1601    }
1602
1603    #[test]
1604    fn image_parsers_reject_wrong_roots_malformed_unsafe_nested_and_oversized_input() {
1605        assert!(
1606            CiscoIpPhoneImage::from_xml(
1607                b"<CiscoIPPhoneImageFile><URL>x</URL></CiscoIPPhoneImageFile>"
1608            )
1609            .is_err()
1610        );
1611        assert!(PhoneImageDocument::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
1612        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Unknown/></CiscoIPPhoneImage>").is_err());
1613        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>123</Data></CiscoIPPhoneImage>").is_err());
1614        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>zz</Data></CiscoIPPhoneImage>").is_err());
1615        assert!(CiscoIpPhoneGraphicFileMenu::from_xml(b"<CiscoIPPhoneGraphicFileMenu><URL>x</URL><MenuItem><TouchArea X1=\"bad\" Y1=\"0\" X2=\"1\" Y2=\"1\"/></MenuItem></CiscoIPPhoneGraphicFileMenu>").is_err());
1616        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage>").is_err());
1617        assert!(matches!(
1618            CiscoIpPhoneImage::from_xml(&[0xff]),
1619            Err(PhoneXmlError::InvalidUtf8(_))
1620        ));
1621        assert!(matches!(
1622            CiscoIpPhoneImage::from_xml(b"<!DOCTYPE image [<!ENTITY bits '00'>]><CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&bits;</Data></CiscoIPPhoneImage>"),
1623            Err(PhoneXmlError::DocumentTypeForbidden)
1624        ));
1625        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&unknown;</Data></CiscoIPPhoneImage>").is_err());
1626
1627        let nested = format!(
1628            "<CiscoIPPhoneImage>{}<Width>1</Width><Height>1</Height><Depth>1</Depth>{}</CiscoIPPhoneImage>",
1629            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1630            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1631        );
1632        assert!(matches!(
1633            CiscoIpPhoneImage::from_xml(nested.as_bytes()),
1634            Err(PhoneXmlError::NestingTooDeep { .. })
1635        ));
1636        assert!(matches!(
1637            PhoneImageDocument::from_xml(&vec![b'x'; PHONE_IMAGE_MAX_BYTES + 1]),
1638            Err(PhoneXmlError::LimitExceeded { .. })
1639        ));
1640        assert!(matches!(
1641            PhoneImageDocument::Image(complete_bitmap_image()).to_xml_with_limit(10),
1642            Err(PhoneXmlError::LimitExceeded { .. })
1643        ));
1644
1645        #[derive(Debug)]
1646        struct FailingWriter;
1647        impl fmt::Write for FailingWriter {
1648            fn write_str(&mut self, _value: &str) -> fmt::Result {
1649                Err(fmt::Error)
1650            }
1651        }
1652        assert!(matches!(
1653            to_writer(
1654                FailingWriter,
1655                &complete_graphic_file_menu(),
1656                PHONE_IMAGE_MAX_BYTES
1657            ),
1658            Err(PhoneXmlError::Write(_))
1659        ));
1660    }
1661
1662    fn complete_bitmap_status() -> CiscoIpPhoneStatus {
1663        CiscoIpPhoneStatus {
1664            text: Some("Café <ready> & active".into()),
1665            timer_seconds: Some(15),
1666            location_x: Some(-1),
1667            location_y: Some(20),
1668            width: 106,
1669            height: 21,
1670            depth: 2,
1671            data: Some(PhoneBitmapData::new(vec![0x00, 0xab, 0xff]).unwrap()),
1672        }
1673    }
1674
1675    fn complete_file_status() -> CiscoIpPhoneStatusFile {
1676        CiscoIpPhoneStatusFile {
1677            text: Some("Status <file> & refresh".into()),
1678            timer_seconds: Some(u16::MAX),
1679            location_x: Some(261),
1680            location_y: Some(-1),
1681            url: PhoneImageUrl::new("https://pbx.example/status.png?id=7&view=compact").unwrap(),
1682        }
1683    }
1684
1685    #[test]
1686    fn status_documents_round_trip_icons_timers_order_utf8_and_escaping() {
1687        let bitmap = complete_bitmap_status();
1688        let xml = bitmap.to_xml().unwrap();
1689        assert!(xml.contains("Café &lt;ready&gt; &amp; active"));
1690        assert!(xml.contains("<Timer>15</Timer>"));
1691        assert!(xml.contains("<Data>00ABFF</Data>"));
1692        assert!(xml.find("<Text>").unwrap() < xml.find("<Timer>").unwrap());
1693        assert!(xml.find("<Timer>").unwrap() < xml.find("<LocationX>").unwrap());
1694        assert!(xml.find("<Depth>").unwrap() < xml.find("<Data>").unwrap());
1695        assert_eq!(
1696            CiscoIpPhoneStatus::from_xml(xml.as_bytes()).unwrap(),
1697            bitmap
1698        );
1699        assert_eq!(
1700            PhoneStatusDocument::from_xml(xml.as_bytes()).unwrap(),
1701            PhoneStatusDocument::Bitmap(bitmap)
1702        );
1703
1704        let file = complete_file_status();
1705        let xml = file.to_xml().unwrap();
1706        assert!(xml.contains("Status &lt;file&gt; &amp; refresh"));
1707        assert!(xml.contains(&format!("<Timer>{}</Timer>", u16::MAX)));
1708        assert!(xml.contains("id=7&amp;view=compact"));
1709        assert!(xml.find("<LocationY>").unwrap() < xml.find("<URL>").unwrap());
1710        assert_eq!(
1711            PhoneStatusDocument::from_xml(xml.as_bytes()).unwrap(),
1712            PhoneStatusDocument::File(file)
1713        );
1714
1715        let zero_timer = CiscoIpPhoneStatus::from_xml(
1716            b"<CiscoIPPhoneStatus><Timer>0</Timer><Width>1</Width><Height>1</Height><Depth>1</Depth><Data></Data></CiscoIPPhoneStatus>",
1717        )
1718        .unwrap();
1719        assert_eq!(zero_timer.timer_seconds, Some(0));
1720        assert_eq!(zero_timer.data.unwrap().as_bytes(), []);
1721        let absent_data = CiscoIpPhoneStatus::from_xml(
1722            b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth></CiscoIPPhoneStatus>",
1723        )
1724        .unwrap();
1725        assert!(absent_data.timer_seconds.is_none());
1726        assert!(absent_data.data.is_none());
1727    }
1728
1729    #[test]
1730    fn status_documents_enforce_exact_text_geometry_icon_and_url_bounds() {
1731        let mut bitmap = complete_bitmap_status();
1732        bitmap.text = Some("x".repeat(32));
1733        assert!(bitmap.validate().is_ok());
1734        bitmap.text = Some("x".repeat(33));
1735        assert!(bitmap.validate().is_err());
1736        bitmap.text = None;
1737        for x in [-2, 106] {
1738            bitmap.location_x = Some(x);
1739            assert!(bitmap.validate().is_err());
1740        }
1741        bitmap.location_x = None;
1742        for y in [-2, 21] {
1743            bitmap.location_y = Some(y);
1744            assert!(bitmap.validate().is_err());
1745        }
1746        bitmap.location_y = None;
1747        for (width, height, depth) in [
1748            (0, 1, 1),
1749            (107, 1, 1),
1750            (1, 0, 1),
1751            (1, 22, 1),
1752            (1, 1, 0),
1753            (1, 1, 3),
1754        ] {
1755            bitmap.width = width;
1756            bitmap.height = height;
1757            bitmap.depth = depth;
1758            assert!(bitmap.validate().is_err());
1759        }
1760        bitmap.width = 1;
1761        bitmap.height = 1;
1762        bitmap.depth = 1;
1763        bitmap.data = Some(PhoneBitmapData::new(vec![0; PHONE_STATUS_BITMAP_MAX_BYTES]).unwrap());
1764        assert!(bitmap.validate().is_ok());
1765        bitmap.data =
1766            Some(PhoneBitmapData::new(vec![0; PHONE_STATUS_BITMAP_MAX_BYTES + 1]).unwrap());
1767        assert!(matches!(
1768            bitmap.validate(),
1769            Err(PhoneXmlError::LimitExceeded {
1770                kind: "phone status bitmap bytes",
1771                maximum: PHONE_STATUS_BITMAP_MAX_BYTES,
1772                ..
1773            })
1774        ));
1775
1776        let mut file = complete_file_status();
1777        for x in [-2, 262] {
1778            file.location_x = Some(x);
1779            assert!(file.validate().is_err());
1780        }
1781        file.location_x = None;
1782        for y in [-2, 50] {
1783            file.location_y = Some(y);
1784            assert!(file.validate().is_err());
1785        }
1786        assert!(PhoneImageUrl::new("").is_err());
1787        assert!(PhoneImageUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
1788    }
1789
1790    #[test]
1791    fn status_parsers_reject_wrong_roots_malformed_unsafe_nested_and_oversized_input() {
1792        assert!(
1793            CiscoIpPhoneStatus::from_xml(
1794                b"<CiscoIPPhoneStatusFile><URL>x</URL></CiscoIPPhoneStatusFile>"
1795            )
1796            .is_err()
1797        );
1798        assert!(PhoneStatusDocument::from_xml(b"<CiscoIPPhoneText/>").is_err());
1799        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Unknown/></CiscoIPPhoneStatus>").is_err());
1800        assert!(
1801            CiscoIpPhoneStatus::from_xml(
1802                b"<CiscoIPPhoneStatus><Height>1</Height><Depth>1</Depth></CiscoIPPhoneStatus>"
1803            )
1804            .is_err()
1805        );
1806        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>f</Data></CiscoIPPhoneStatus>").is_err());
1807        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>zz</Data></CiscoIPPhoneStatus>").is_err());
1808        assert!(
1809            CiscoIpPhoneStatusFile::from_xml(
1810                b"<CiscoIPPhoneStatusFile><URL></URL></CiscoIPPhoneStatusFile>"
1811            )
1812            .is_err()
1813        );
1814        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus>").is_err());
1815        assert!(matches!(
1816            CiscoIpPhoneStatus::from_xml(&[0xff]),
1817            Err(PhoneXmlError::InvalidUtf8(_))
1818        ));
1819        assert!(matches!(
1820            CiscoIpPhoneStatus::from_xml(b"<!DOCTYPE status [<!ENTITY bits '00'>]><CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&bits;</Data></CiscoIPPhoneStatus>"),
1821            Err(PhoneXmlError::DocumentTypeForbidden)
1822        ));
1823        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&unknown;</Data></CiscoIPPhoneStatus>").is_err());
1824
1825        let nested = format!(
1826            "<CiscoIPPhoneStatus>{}<Width>1</Width><Height>1</Height><Depth>1</Depth>{}</CiscoIPPhoneStatus>",
1827            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1828            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
1829        );
1830        assert!(matches!(
1831            CiscoIpPhoneStatus::from_xml(nested.as_bytes()),
1832            Err(PhoneXmlError::NestingTooDeep { .. })
1833        ));
1834        assert!(matches!(
1835            PhoneStatusDocument::from_xml(&vec![b'x'; PHONE_STATUS_MAX_BYTES + 1]),
1836            Err(PhoneXmlError::LimitExceeded { .. })
1837        ));
1838        assert!(matches!(
1839            PhoneStatusDocument::Bitmap(complete_bitmap_status()).to_xml_with_limit(10),
1840            Err(PhoneXmlError::LimitExceeded { .. })
1841        ));
1842
1843        #[derive(Debug)]
1844        struct FailingWriter;
1845        impl fmt::Write for FailingWriter {
1846            fn write_str(&mut self, _value: &str) -> fmt::Result {
1847                Err(fmt::Error)
1848            }
1849        }
1850        assert!(matches!(
1851            to_writer(
1852                FailingWriter,
1853                &complete_file_status(),
1854                PHONE_STATUS_MAX_BYTES,
1855            ),
1856            Err(PhoneXmlError::Write(_))
1857        ));
1858    }
1859
1860    fn complete_alarm() -> CiscoIpPhoneAlarm {
1861        CiscoIpPhoneAlarm {
1862            alarm: CiscoIpPhoneAlarmEntry {
1863                name: LAST_OUT_OF_SERVICE_ALARM.into(),
1864                parameter_list: CiscoIpPhoneAlarmParameterList {
1865                    parameters: vec![
1866                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
1867                            name: "DeviceName".into(),
1868                            value: "SEP001122334455".into(),
1869                        }),
1870                        CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
1871                            name: "DHCPv4Status".into(),
1872                            value: 1,
1873                        }),
1874                        CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
1875                            name: "ReasonForOutOfService".into(),
1876                            value: 25,
1877                        }),
1878                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
1879                            name: "LastProtocolEventSent".into(),
1880                            value: "Sent:REGISTER <call-id> & route".into(),
1881                        }),
1882                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
1883                            name: "LastProtocolEventReceived".into(),
1884                            value: String::new(),
1885                        }),
1886                    ],
1887                },
1888            },
1889        }
1890    }
1891
1892    #[test]
1893    fn alarm_schema_round_trips_ordered_typed_parameters_and_accessors() {
1894        let expected = complete_alarm();
1895        let xml = expected.to_xml().unwrap();
1896        assert!(xml.starts_with(
1897            "<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList>"
1898        ));
1899        assert!(xml.contains("Sent:REGISTER &lt;call-id&gt; &amp; route"));
1900        assert!(xml.find("DeviceName").unwrap() < xml.find("DHCPv4Status").unwrap());
1901        assert!(
1902            xml.find("ReasonForOutOfService").unwrap() < xml.find("LastProtocolEventSent").unwrap()
1903        );
1904        let decoded = CiscoIpPhoneAlarm::from_xml(xml.as_bytes()).unwrap();
1905        assert_eq!(decoded, expected);
1906        assert_eq!(decoded.reason_for_out_of_service(), Some(25));
1907        assert_eq!(decoded.enumeration("DHCPv4Status"), Some(1));
1908        assert_eq!(decoded.string("DeviceName"), Some("SEP001122334455"));
1909        assert_eq!(decoded.string("LastProtocolEventReceived"), Some(""));
1910        assert_eq!(decoded.string("Unknown"), None);
1911        let telemetry = parse_phone_alarm(xml.as_bytes()).unwrap();
1912        assert!(matches!(
1913            &telemetry,
1914            PhoneAlarmTelemetry::LastOutOfService(alarm) if alarm == &expected
1915        ));
1916        assert_eq!(
1917            telemetry.summary(),
1918            Some(PhoneAlarmSummary {
1919                kind: PhoneAlarmKind::LastOutOfService,
1920                reason_for_out_of_service: Some(25),
1921            })
1922        );
1923    }
1924
1925    #[test]
1926    fn unknown_alarm_schemas_remain_bounded_lossless_and_secret_safe() {
1927        for unknown in [
1928            b"<x-cisco-alarm/>".as_slice(),
1929            b"<x-cisco-alarm><Alarm Name=\"DeviceTroubleshootingReport\"><ParameterList><String name=\"Token\">secret-value</String></ParameterList></Alarm></x-cisco-alarm>".as_slice(),
1930            b"<vendor-alarm><Credential>secret-value</Credential></vendor-alarm>".as_slice(),
1931        ] {
1932            let PhoneAlarmTelemetry::Opaque(opaque) = parse_phone_alarm(unknown).unwrap() else {
1933                panic!("unknown alarm schema must remain opaque");
1934            };
1935            assert_eq!(opaque.as_bytes(), unknown);
1936            let debug = format!("{opaque:?}");
1937            assert!(!debug.contains("secret-value"));
1938            assert!(debug.contains(&unknown.len().to_string()));
1939            assert_eq!(opaque.clone().into_bytes(), unknown);
1940        }
1941
1942        let opaque = parse_phone_alarm(b"<vendor-alarm/>").unwrap();
1943        assert!(opaque.is_opaque());
1944        assert_eq!(opaque.summary(), None);
1945
1946        let known = complete_alarm();
1947        let debug = format!("{known:?}");
1948        assert!(!debug.contains("SEP001122334455"));
1949        assert!(!debug.contains("call-id"));
1950        assert!(debug.contains(LAST_OUT_OF_SERVICE_ALARM));
1951        assert_eq!(
1952            format!("{:?}", known.alarm.parameter_list),
1953            "CiscoIpPhoneAlarmParameterList { parameter_count: 5 }"
1954        );
1955    }
1956
1957    #[test]
1958    fn known_alarm_validation_rejects_ambiguity_unsafe_values_and_size_overflow() {
1959        let mut alarm = complete_alarm();
1960        alarm
1961            .alarm
1962            .parameter_list
1963            .parameters
1964            .push(CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
1965                name: "DeviceName".into(),
1966                value: 2,
1967            }));
1968        assert!(matches!(
1969            alarm.validate(),
1970            Err(PhoneXmlError::InvalidField {
1971                field: "phone alarm parameter names",
1972                ..
1973            })
1974        ));
1975
1976        alarm = complete_alarm();
1977        match &mut alarm.alarm.parameter_list.parameters[0] {
1978            CiscoIpPhoneAlarmParameter::String(device) => device.name.clear(),
1979            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
1980        }
1981        assert!(alarm.validate().is_err());
1982        match &mut alarm.alarm.parameter_list.parameters[0] {
1983            CiscoIpPhoneAlarmParameter::String(device) => {
1984                device.name = "DeviceName".into();
1985                device.value = "not\u{1}xml".into();
1986            }
1987            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
1988        }
1989        assert!(alarm.validate().is_err());
1990        match &mut alarm.alarm.parameter_list.parameters[0] {
1991            CiscoIpPhoneAlarmParameter::String(device) => {
1992                device.value = "sensitive-value".repeat(PHONE_ALARM_MAX_BYTES);
1993            }
1994            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
1995        }
1996        let error = alarm.to_xml().unwrap_err();
1997        assert!(!error.to_string().contains("sensitive-value"));
1998
1999        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>";
2000        let error = parse_phone_alarm(duplicate).unwrap_err();
2001        assert!(!error.to_string().contains("first-secret"));
2002        assert!(!error.to_string().contains("second-secret"));
2003    }
2004
2005    #[test]
2006    fn alarm_parser_rejects_malformed_known_unsafe_and_oversized_documents() {
2007        assert!(parse_phone_alarm(b"<x-cisco-alarm>").is_err());
2008        assert!(matches!(
2009            parse_phone_alarm(&[0xff]),
2010            Err(PhoneXmlError::InvalidUtf8(_))
2011        ));
2012        assert!(matches!(
2013            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>"),
2014            Err(PhoneXmlError::DocumentTypeForbidden)
2015        ));
2016        assert!(parse_phone_alarm(b"<x-cisco-alarm><Alarm Name=\"Unknown\"><ParameterList><String name=\"Value\">&unknown;</String></ParameterList></Alarm></x-cisco-alarm>").is_err());
2017        assert!(parse_phone_alarm(b"<vendor-alarm><Value>&#1;</Value></vendor-alarm>").is_err());
2018        assert!(parse_phone_alarm(b"<vendor-alarm value=\"&#1;\"/>").is_err());
2019        assert!(parse_phone_alarm(b"<vendor-alarm>not\x01xml</vendor-alarm>").is_err());
2020        assert!(parse_phone_alarm(b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><Binary name=\"Value\">00</Binary></ParameterList></Alarm></x-cisco-alarm>").is_err());
2021        assert!(
2022            parse_phone_alarm(
2023                b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"/></x-cisco-alarm>"
2024            )
2025            .is_err()
2026        );
2027        let invalid_enum = b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><Enum name=\"ReasonForOutOfService\">secret-enum</Enum></ParameterList></Alarm></x-cisco-alarm>";
2028        let error = parse_phone_alarm(invalid_enum).unwrap_err();
2029        assert!(!error.to_string().contains("secret-enum"));
2030
2031        let nested = format!(
2032            "<x-cisco-alarm>{}<Alarm Name=\"Unknown\"/>{}</x-cisco-alarm>",
2033            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2034            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2035        );
2036        assert!(matches!(
2037            parse_phone_alarm(nested.as_bytes()),
2038            Err(PhoneXmlError::NestingTooDeep { .. })
2039        ));
2040        assert!(matches!(
2041            parse_phone_alarm(&vec![b'x'; PHONE_ALARM_MAX_BYTES + 1]),
2042            Err(PhoneXmlError::LimitExceeded {
2043                maximum: PHONE_ALARM_MAX_BYTES,
2044                ..
2045            })
2046        ));
2047
2048        #[derive(Debug)]
2049        struct FailingWriter;
2050        impl fmt::Write for FailingWriter {
2051            fn write_str(&mut self, _value: &str) -> fmt::Result {
2052                Err(fmt::Error)
2053            }
2054        }
2055        assert!(matches!(
2056            to_writer(FailingWriter, &complete_alarm(), PHONE_ALARM_MAX_BYTES),
2057            Err(PhoneXmlError::Write(_))
2058        ));
2059    }
2060
2061    fn complete_location() -> CiscoIpPhoneLocationInformation {
2062        CiscoIpPhoneLocationInformation {
2063            wifi: CiscoIpPhoneWifiLocation {
2064                bssid: PhoneBssid::parse("e8:ed:f3:10:29:fd").unwrap(),
2065                ssid: "Café <voice> & data".into(),
2066                access_point_name: "West wing <3>".into(),
2067            },
2068            off_premises: Some(CiscoIpPhoneOffPremises::new()),
2069        }
2070    }
2071
2072    #[test]
2073    fn location_schema_round_trips_typed_address_fields_order_and_escaping() {
2074        let expected = complete_location();
2075        let xml = expected.to_xml().unwrap();
2076        assert!(xml.starts_with("<Interface1><wifi><BSSID>E8:ED:F3:10:29:FD</BSSID>"));
2077        assert!(xml.contains("<SSID>Café &lt;voice&gt; &amp; data</SSID>"));
2078        assert!(xml.contains("<APName>West wing &lt;3&gt;</APName>"));
2079        assert!(xml.find("</wifi>").unwrap() < xml.find("<OffPrem").unwrap());
2080        assert_eq!(
2081            CiscoIpPhoneLocationInformation::from_xml(xml.as_bytes()).unwrap(),
2082            expected
2083        );
2084        assert_eq!(
2085            expected.wifi.bssid.octets(),
2086            [0xe8, 0xed, 0xf3, 0x10, 0x29, 0xfd]
2087        );
2088        assert_eq!(expected.wifi.bssid.to_string(), "E8:ED:F3:10:29:FD");
2089        assert!(expected.is_off_premises());
2090
2091        let telemetry = parse_phone_location(xml.as_bytes()).unwrap();
2092        assert_eq!(
2093            telemetry.summary(),
2094            Some(PhoneLocationSummary {
2095                kind: PhoneLocationKind::WirelessInterface,
2096                off_premises: true,
2097            })
2098        );
2099
2100        let on_premises = CiscoIpPhoneLocationInformation::from_xml(
2101            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID></SSID><APName/></wifi></Interface1>",
2102        )
2103        .unwrap();
2104        assert!(!on_premises.is_off_premises());
2105        assert_eq!(on_premises.wifi.ssid, "");
2106        assert_eq!(on_premises.wifi.access_point_name, "");
2107    }
2108
2109    #[test]
2110    fn location_models_enforce_address_marker_text_and_document_bounds() {
2111        for invalid in [
2112            "00:11:22:33:44",
2113            "00:11:22:33:44:555",
2114            "00-11-22-33-44-55",
2115            "00:11:22:33:44:gg",
2116            "private-address",
2117        ] {
2118            let error = PhoneBssid::parse(invalid).unwrap_err();
2119            assert!(!error.to_string().contains(invalid));
2120        }
2121
2122        let mut location = complete_location();
2123        location.wifi.ssid = "é".repeat(16);
2124        assert!(location.validate().is_ok());
2125        location.wifi.ssid.push('é');
2126        assert!(matches!(
2127            location.validate(),
2128            Err(PhoneXmlError::InvalidField {
2129                field: "phone location SSID",
2130                expected: "at most 32 bytes",
2131            })
2132        ));
2133
2134        location = complete_location();
2135        location.wifi.access_point_name = "private-name".repeat(PHONE_LOCATION_MAX_BYTES);
2136        let error = location.to_xml().unwrap_err();
2137        assert!(!error.to_string().contains("private-name"));
2138
2139        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>";
2140        let error = parse_phone_location(nonempty_marker).unwrap_err();
2141        assert!(!error.to_string().contains("private-location"));
2142    }
2143
2144    #[test]
2145    fn unknown_location_schemas_are_bounded_lossless_and_secret_safe() {
2146        for unknown in [
2147            b"<Interface2><wifi><BSSID>00:11:22:33:44:55</BSSID></wifi></Interface2>".as_slice(),
2148            b"<DeviceLocation><CivicAddress>private-building</CivicAddress></DeviceLocation>"
2149                .as_slice(),
2150        ] {
2151            let telemetry = parse_phone_location(unknown).unwrap();
2152            let PhoneLocationTelemetry::Opaque(opaque) = &telemetry else {
2153                panic!("unsupported location schema must remain opaque");
2154            };
2155            assert_eq!(opaque.as_bytes(), unknown);
2156            assert_eq!(opaque.clone().into_bytes(), unknown);
2157            assert_eq!(telemetry.summary(), None);
2158            assert!(telemetry.is_opaque());
2159            let debug = format!("{telemetry:?}");
2160            assert!(!debug.contains("private-building"));
2161            assert!(!debug.contains("00:11:22:33:44:55"));
2162            assert!(debug.contains(&unknown.len().to_string()));
2163        }
2164
2165        let debug = format!("{:?}", complete_location());
2166        assert!(!debug.contains("Café"));
2167        assert!(!debug.contains("West wing"));
2168        assert!(!debug.contains("E8:ED:F3:10:29:FD"));
2169    }
2170
2171    #[test]
2172    fn location_parser_rejects_malformed_known_unsafe_and_oversized_documents() {
2173        for invalid in [
2174            b"<Interface1>".as_slice(),
2175            b"<Interface1><wifi><BSSID>private-address</BSSID><SSID>private-network</SSID><APName>private-access-point</APName></wifi></Interface1>".as_slice(),
2176            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(),
2177            b"<Interface1><OffPrem/></Interface1>".as_slice(),
2178            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID>one</SSID><SSID>two</SSID><APName>west</APName></wifi></Interface1>".as_slice(),
2179        ] {
2180            let error = parse_phone_location(invalid).unwrap_err();
2181            let error = error.to_string();
2182            assert!(!error.contains("private-address"));
2183            assert!(!error.contains("private-network"));
2184            assert!(!error.contains("private-access-point"));
2185            assert!(!error.contains("private-secret"));
2186        }
2187        assert!(matches!(
2188            parse_phone_location(&[0xff]),
2189            Err(PhoneXmlError::InvalidUtf8(_))
2190        ));
2191        assert!(matches!(
2192            parse_phone_location(b"<!DOCTYPE Interface2 [<!ENTITY location 'private'>]><Interface2>&location;</Interface2>"),
2193            Err(PhoneXmlError::DocumentTypeForbidden)
2194        ));
2195        assert!(matches!(
2196            parse_phone_location(b"<Interface2>&undeclared;</Interface2>"),
2197            Err(PhoneXmlError::InvalidEntity)
2198        ));
2199        assert!(parse_phone_location(b"<Interface2>&#1;</Interface2>").is_err());
2200        assert!(parse_phone_location(b"<Interface2>not\x01xml</Interface2>").is_err());
2201
2202        let nested = format!(
2203            "<Interface2>{}{}</Interface2>",
2204            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2205            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
2206        );
2207        assert!(matches!(
2208            parse_phone_location(nested.as_bytes()),
2209            Err(PhoneXmlError::NestingTooDeep { .. })
2210        ));
2211        assert!(matches!(
2212            parse_phone_location(&vec![b'x'; PHONE_LOCATION_MAX_BYTES + 1]),
2213            Err(PhoneXmlError::LimitExceeded {
2214                maximum: PHONE_LOCATION_MAX_BYTES,
2215                ..
2216            })
2217        ));
2218
2219        #[derive(Debug)]
2220        struct FailingWriter;
2221        impl fmt::Write for FailingWriter {
2222            fn write_str(&mut self, _value: &str) -> fmt::Result {
2223                Err(fmt::Error)
2224            }
2225        }
2226        assert!(matches!(
2227            to_writer(
2228                FailingWriter,
2229                &complete_location(),
2230                PHONE_LOCATION_MAX_BYTES,
2231            ),
2232            Err(PhoneXmlError::Write(_))
2233        ));
2234    }
2235
2236    fn complete_menu() -> CiscoIpPhoneMenu {
2237        CiscoIpPhoneMenu {
2238            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
2239            application_id: Some("menu-west".into()),
2240            on_focus_lost: Some("Notify:focus?state=lost&side=west".into()),
2241            on_focus_gained: Some("Notify:focus?state=gained".into()),
2242            on_minimized: Some("Notify:minimized".into()),
2243            on_closed: Some("Notify:closed".into()),
2244            title: Some("Support <East> & West".into()),
2245            prompt: Some("Choose A & B".into()),
2246            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
2247                name: Some("Open & inspect".into()),
2248                position: PhoneSoftKeyPosition::new(1).unwrap(),
2249                url: Some("SoftKey:Select?a=1&b=2".into()),
2250                url_down: Some("SoftKey:SelectDown".into()),
2251            }],
2252            key_items: vec![CiscoIpPhoneKeyItem {
2253                key: PhoneXmlKey::NavBack,
2254                url: Some("SoftKey:Cancel".into()),
2255                url_down: None,
2256            }],
2257            items: vec![CiscoIpPhoneMenuItem {
2258                name: Some("Alice <Admin> & Bob".into()),
2259                url: Some("UserData:7:0:open/a?x=1&y=2".into()),
2260            }],
2261        }
2262    }
2263
2264    #[test]
2265    fn basic_menu_round_trips_complete_display_controls_in_schema_order() {
2266        let expected = complete_menu();
2267        let xml = expected.to_xml().unwrap();
2268        assert!(xml.contains("Support &lt;East&gt; &amp; West"));
2269        assert!(xml.contains("Alice &lt;Admin&gt; &amp; Bob"));
2270        assert!(xml.contains("x=1&amp;y=2"));
2271        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
2272        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<MenuItem>").unwrap());
2273        assert_eq!(
2274            CiscoIpPhoneMenu::from_xml(xml.as_bytes()).unwrap(),
2275            expected
2276        );
2277
2278        let minimal = CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneMenu/>").unwrap();
2279        assert!(minimal.title.is_none());
2280        assert!(minimal.items.is_empty());
2281    }
2282
2283    #[test]
2284    fn bitmap_and_resource_icon_menus_round_trip_exact_icon_families() {
2285        let bitmap = CiscoIpPhoneIconMenu::new(
2286            "Conference & staff",
2287            "Choose <one>",
2288            vec![CiscoIpPhoneIconMenuItem {
2289                name: Some("Taylor & team".into()),
2290                url: Some("UserData:1:0:participant/7?view=a&b=c".into()),
2291                icon_index: Some(2),
2292            }],
2293            vec![CiscoIpPhoneIconItem {
2294                index: 2,
2295                width: 16,
2296                height: 10,
2297                depth: 2,
2298                data: Some("000FF0".into()),
2299            }],
2300        )
2301        .unwrap();
2302        let xml = bitmap.to_xml().unwrap();
2303        assert!(xml.find("<MenuItem>").unwrap() < xml.find("<IconItem>").unwrap());
2304        assert!(xml.find("<Width>").unwrap() < xml.find("<Height>").unwrap());
2305        assert!(xml.contains("Conference &amp; staff"));
2306        assert_eq!(
2307            CiscoIpPhoneIconMenu::from_xml(xml.as_bytes()).unwrap(),
2308            bitmap
2309        );
2310
2311        let resources = CiscoIpPhoneIconFileMenu {
2312            keypad_target: Some(PhoneKeypadTarget::ActiveCall),
2313            application_id: Some("conference-list".into()),
2314            on_focus_lost: None,
2315            on_focus_gained: Some("Notify:focus".into()),
2316            on_minimized: None,
2317            on_closed: Some("SoftKey:Exit".into()),
2318            icon_index: Some(4),
2319            title: Some(CiscoIpPhoneIconTitle {
2320                icon_index: Some(5),
2321                text: "Locked & secure".into(),
2322            }),
2323            prompt: Some("Choose a participant".into()),
2324            soft_keys: Vec::new(),
2325            key_items: Vec::new(),
2326            items: vec![CiscoIpPhoneIconMenuItem {
2327                name: Some("Alex <Host>".into()),
2328                url: Some("UserData:1:0:participant/1".into()),
2329                icon_index: Some(5),
2330            }],
2331            icons: vec![CiscoIpPhoneIconFileItem {
2332                index: 5,
2333                url: "Resource:Icon.SecureCall?shade=dark&size=small".into(),
2334            }],
2335        };
2336        let xml = resources.to_xml().unwrap();
2337        assert!(xml.contains("<Title IconIndex=\"5\">Locked &amp; secure</Title>"));
2338        assert!(xml.contains("shade=dark&amp;size=small"));
2339        assert_eq!(
2340            CiscoIpPhoneIconFileMenu::from_xml(xml.as_bytes()).unwrap(),
2341            resources
2342        );
2343    }
2344
2345    #[test]
2346    fn menu_models_reject_every_collection_text_url_position_and_icon_bound() {
2347        let mut basic = complete_menu();
2348        basic.items = vec![basic.items[0].clone(); PHONE_MENU_MAX_ITEMS + 1];
2349        assert!(matches!(
2350            basic.to_xml(),
2351            Err(PhoneXmlError::LimitExceeded {
2352                kind: "menu items",
2353                ..
2354            })
2355        ));
2356
2357        let mut invalid = complete_menu();
2358        invalid.items[0].name = Some("x".repeat(65));
2359        assert!(matches!(
2360            invalid.to_xml(),
2361            Err(PhoneXmlError::InvalidField { .. })
2362        ));
2363        invalid = complete_menu();
2364        invalid.items[0].url = Some("x".repeat(PHONE_XML_URL_MAX_CHARS + 1));
2365        assert!(matches!(
2366            invalid.to_xml(),
2367            Err(PhoneXmlError::InvalidField { .. })
2368        ));
2369        invalid = complete_menu();
2370        invalid.application_id = Some(String::new());
2371        assert!(matches!(
2372            invalid.to_xml(),
2373            Err(PhoneXmlError::InvalidField { .. })
2374        ));
2375        invalid = complete_menu();
2376        invalid.on_closed = Some(String::new());
2377        assert!(matches!(
2378            invalid.to_xml(),
2379            Err(PhoneXmlError::InvalidField { .. })
2380        ));
2381        invalid = complete_menu();
2382        invalid.soft_keys[0].position = PhoneSoftKeyPosition::new(16).unwrap();
2383        assert!(invalid.to_xml().is_ok());
2384        invalid = complete_menu();
2385        invalid.soft_keys = vec![invalid.soft_keys[0].clone(); 17];
2386        assert!(matches!(
2387            invalid.to_xml(),
2388            Err(PhoneXmlError::LimitExceeded { .. })
2389        ));
2390        invalid = complete_menu();
2391        invalid.key_items = vec![invalid.key_items[0].clone(); 33];
2392        assert!(matches!(
2393            invalid.to_xml(),
2394            Err(PhoneXmlError::LimitExceeded { .. })
2395        ));
2396
2397        let item = CiscoIpPhoneIconMenuItem {
2398            name: Some("Item".into()),
2399            url: Some("SoftKey:Select".into()),
2400            icon_index: Some(0),
2401        };
2402        let icon = CiscoIpPhoneIconItem {
2403            index: 0,
2404            width: 1,
2405            height: 1,
2406            depth: 1,
2407            data: Some("00".into()),
2408        };
2409        let mut icon_menu =
2410            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![item.clone()], vec![icon.clone()])
2411                .unwrap();
2412        icon_menu.items = vec![item.clone(); PHONE_ICON_MENU_MAX_ITEMS + 1];
2413        assert!(matches!(
2414            icon_menu.to_xml(),
2415            Err(PhoneXmlError::LimitExceeded { .. })
2416        ));
2417        icon_menu =
2418            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![item.clone()], vec![icon.clone()])
2419                .unwrap();
2420        icon_menu.icons = vec![icon.clone(); PHONE_ICON_MENU_MAX_ICONS + 1];
2421        assert!(matches!(
2422            icon_menu.to_xml(),
2423            Err(PhoneXmlError::LimitExceeded { .. })
2424        ));
2425
2426        for invalid_icon in [
2427            CiscoIpPhoneIconItem {
2428                width: 0,
2429                ..icon.clone()
2430            },
2431            CiscoIpPhoneIconItem {
2432                height: 11,
2433                ..icon.clone()
2434            },
2435            CiscoIpPhoneIconItem {
2436                depth: 3,
2437                ..icon.clone()
2438            },
2439            CiscoIpPhoneIconItem {
2440                data: Some("0".into()),
2441                ..icon.clone()
2442            },
2443            CiscoIpPhoneIconItem {
2444                data: Some("GG".into()),
2445                ..icon.clone()
2446            },
2447            CiscoIpPhoneIconItem {
2448                data: Some("00".repeat(41)),
2449                ..icon
2450            },
2451        ] {
2452            assert!(
2453                CiscoIpPhoneIconMenu::new(
2454                    "Icons",
2455                    "Choose",
2456                    vec![item.clone()],
2457                    vec![invalid_icon]
2458                )
2459                .is_err()
2460            );
2461        }
2462        let mut invalid_item = item;
2463        invalid_item.icon_index = Some(10);
2464        assert!(
2465            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![invalid_item], vec![icon]).is_err()
2466        );
2467
2468        let mut file_menu = CiscoIpPhoneIconFileMenu {
2469            keypad_target: None,
2470            application_id: None,
2471            on_focus_lost: None,
2472            on_focus_gained: None,
2473            on_minimized: None,
2474            on_closed: None,
2475            icon_index: None,
2476            title: None,
2477            prompt: None,
2478            soft_keys: Vec::new(),
2479            key_items: Vec::new(),
2480            items: Vec::new(),
2481            icons: vec![CiscoIpPhoneIconFileItem {
2482                index: 10,
2483                url: "Resource:Icon.Hold".into(),
2484            }],
2485        };
2486        assert!(matches!(
2487            file_menu.to_xml(),
2488            Err(PhoneXmlError::InvalidField { .. })
2489        ));
2490        file_menu.icons[0].index = 0;
2491        file_menu.icons[0].url.clear();
2492        assert!(matches!(
2493            file_menu.to_xml(),
2494            Err(PhoneXmlError::InvalidField { .. })
2495        ));
2496    }
2497
2498    #[test]
2499    fn menu_parsers_reject_wrong_roots_unknown_fields_malformed_input_and_writer_failure() {
2500        assert!(CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneIconMenu/>").is_err());
2501        assert!(CiscoIpPhoneIconMenu::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
2502        assert!(CiscoIpPhoneIconFileMenu::from_xml(b"<CiscoIPPhoneIconMenu/>").is_err());
2503        assert!(
2504            CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneMenu><Unknown/></CiscoIPPhoneMenu>",)
2505                .is_err()
2506        );
2507        assert!(CiscoIpPhoneIconMenu::from_xml(b"<CiscoIPPhoneIconMenu>").is_err());
2508        assert!(
2509            CiscoIpPhoneIconFileMenu::from_xml(b"<!DOCTYPE menu><CiscoIPPhoneIconFileMenu/>",)
2510                .is_err()
2511        );
2512        assert!(matches!(
2513            CiscoIpPhoneMenu::from_xml(&[0xff]),
2514            Err(PhoneXmlError::InvalidUtf8(_))
2515        ));
2516        assert!(matches!(
2517            complete_menu().to_xml_with_limit(10),
2518            Err(PhoneXmlError::LimitExceeded { .. })
2519        ));
2520
2521        #[derive(Debug)]
2522        struct FailingWriter;
2523        impl fmt::Write for FailingWriter {
2524            fn write_str(&mut self, _value: &str) -> fmt::Result {
2525                Err(fmt::Error)
2526            }
2527        }
2528        assert!(matches!(
2529            to_writer(FailingWriter, &complete_menu(), PHONE_MENU_MAX_BYTES),
2530            Err(PhoneXmlError::Write(_))
2531        ));
2532    }
2533
2534    #[test]
2535    fn conference_lists_round_trip_menu_and_icon_families_with_typed_actions() {
2536        let conference_id = ConferenceId::new(41);
2537        let participants = [
2538            ConferenceListEntry {
2539                participant_id: ParticipantId::new(7),
2540                name: "Alex <Host> & Co".into(),
2541                number: "2100".into(),
2542                moderator: true,
2543                muted: false,
2544            },
2545            ConferenceListEntry {
2546                participant_id: ParticipantId::new(8),
2547                name: String::new(),
2548                number: "2200".into(),
2549                moderator: false,
2550                muted: true,
2551            },
2552            ConferenceListEntry {
2553                participant_id: ParticipantId::new(9),
2554                name: "Casey".into(),
2555                number: "2300".into(),
2556                moderator: false,
2557                muted: false,
2558            },
2559        ];
2560        for family in [ConferenceMenuFamily::Menu, ConferenceMenuFamily::IconMenu] {
2561            let expected =
2562                ConferenceListDocument::new(conference_id, &participants, family).unwrap();
2563            let xml = expected.to_xml().unwrap();
2564            assert!(xml.contains("Alex &lt;Host&gt; &amp; Co"));
2565            let decoded = ConferenceListDocument::from_xml(xml.as_bytes(), family).unwrap();
2566            assert_eq!(decoded, expected);
2567            assert_eq!(
2568                decoded.actions().collect::<Vec<_>>(),
2569                [
2570                    ConferenceListAction::Participant {
2571                        conference_id,
2572                        participant_id: ParticipantId::new(7),
2573                    },
2574                    ConferenceListAction::Participant {
2575                        conference_id,
2576                        participant_id: ParticipantId::new(8),
2577                    },
2578                    ConferenceListAction::Participant {
2579                        conference_id,
2580                        participant_id: ParticipantId::new(9),
2581                    },
2582                    ConferenceListAction::End { conference_id },
2583                ]
2584            );
2585        }
2586    }
2587
2588    #[test]
2589    fn conference_participant_actions_round_trip_both_families_and_removal_policy() {
2590        let conference_id = ConferenceId::new(41);
2591        let mut participant = ConferenceListEntry {
2592            participant_id: ParticipantId::new(8),
2593            name: "Alex <Admin> & Co".into(),
2594            number: "2200".into(),
2595            moderator: false,
2596            muted: false,
2597        };
2598        for family in [ConferenceMenuFamily::Menu, ConferenceMenuFamily::IconMenu] {
2599            let expected = ConferenceParticipantActionsDocument::new(
2600                conference_id,
2601                &participant,
2602                true,
2603                false,
2604                family,
2605            )
2606            .unwrap();
2607            let xml = expected.to_xml().unwrap();
2608            let decoded =
2609                ConferenceParticipantActionsDocument::from_xml(xml.as_bytes(), family).unwrap();
2610            assert_eq!(decoded, expected);
2611            assert_eq!(
2612                decoded.actions().collect::<Vec<_>>(),
2613                [
2614                    ConferenceListAction::Mute {
2615                        conference_id,
2616                        participant_id: participant.participant_id,
2617                    },
2618                    ConferenceListAction::Remove {
2619                        conference_id,
2620                        participant_id: participant.participant_id,
2621                    },
2622                    ConferenceListAction::Promote {
2623                        conference_id,
2624                        participant_id: participant.participant_id,
2625                    },
2626                ]
2627            );
2628
2629            participant.muted = true;
2630            let not_removable = ConferenceParticipantActionsDocument::new(
2631                conference_id,
2632                &participant,
2633                false,
2634                false,
2635                family,
2636            )
2637            .unwrap();
2638            assert_eq!(
2639                not_removable.actions().collect::<Vec<_>>(),
2640                [
2641                    ConferenceListAction::Unmute {
2642                        conference_id,
2643                        participant_id: participant.participant_id,
2644                    },
2645                    ConferenceListAction::Promote {
2646                        conference_id,
2647                        participant_id: participant.participant_id,
2648                    },
2649                ]
2650            );
2651            participant.moderator = true;
2652            let demotable = ConferenceParticipantActionsDocument::new(
2653                conference_id,
2654                &participant,
2655                false,
2656                true,
2657                family,
2658            )
2659            .unwrap();
2660            assert_eq!(
2661                demotable.actions().collect::<Vec<_>>(),
2662                [ConferenceListAction::Demote {
2663                    conference_id,
2664                    participant_id: participant.participant_id,
2665                }]
2666            );
2667            let sole_moderator = ConferenceParticipantActionsDocument::new(
2668                conference_id,
2669                &participant,
2670                false,
2671                false,
2672                family,
2673            )
2674            .unwrap();
2675            assert!(sole_moderator.actions().next().is_none());
2676            participant.moderator = false;
2677            participant.muted = false;
2678        }
2679    }
2680
2681    #[test]
2682    fn conference_lists_reject_limits_malformed_actions_and_wrong_family() {
2683        let participants = vec![
2684            ConferenceListEntry {
2685                participant_id: ParticipantId::new(1),
2686                name: "Participant".into(),
2687                number: String::new(),
2688                moderator: false,
2689                muted: false,
2690            };
2691            CONFERENCE_LIST_MAX_PARTICIPANTS + 1
2692        ];
2693        assert!(matches!(
2694            ConferenceListDocument::new(
2695                ConferenceId::new(1),
2696                &participants,
2697                ConferenceMenuFamily::Menu,
2698            ),
2699            Err(PhoneXmlError::LimitExceeded {
2700                kind: "conference participants",
2701                ..
2702            })
2703        ));
2704        assert!(ConferenceListAction::parse("conference/1/participant/not-a-number").is_none());
2705        assert!(ConferenceListAction::parse("conference/1/remove/7").is_none());
2706        assert_eq!(
2707            ConferenceListAction::parse("conference/1/participant/7/remove"),
2708            Some(ConferenceListAction::Remove {
2709                conference_id: ConferenceId::new(1),
2710                participant_id: ParticipantId::new(7),
2711            })
2712        );
2713        assert_eq!(
2714            ConferenceListAction::from_route(&[
2715                "conference".into(),
2716                "1".into(),
2717                "participant".into(),
2718                "7".into(),
2719                "remove".into(),
2720            ]),
2721            Some(ConferenceListAction::Remove {
2722                conference_id: ConferenceId::new(1),
2723                participant_id: ParticipantId::new(7),
2724            })
2725        );
2726        for (operation, expected) in [
2727            (
2728                "promote",
2729                ConferenceListAction::Promote {
2730                    conference_id: ConferenceId::new(1),
2731                    participant_id: ParticipantId::new(7),
2732                },
2733            ),
2734            (
2735                "demote",
2736                ConferenceListAction::Demote {
2737                    conference_id: ConferenceId::new(1),
2738                    participant_id: ParticipantId::new(7),
2739                },
2740            ),
2741        ] {
2742            let route = [
2743                "conference".into(),
2744                "1".into(),
2745                "participant".into(),
2746                "7".into(),
2747                operation.into(),
2748            ];
2749            assert_eq!(ConferenceListAction::from_route(&route), Some(expected));
2750        }
2751
2752        let menu = ConferenceListDocument::new(
2753            ConferenceId::new(1),
2754            &participants[..1],
2755            ConferenceMenuFamily::Menu,
2756        )
2757        .unwrap()
2758        .to_xml()
2759        .unwrap();
2760        assert!(
2761            ConferenceListDocument::from_xml(menu.as_bytes(), ConferenceMenuFamily::IconMenu)
2762                .is_err()
2763        );
2764        assert!(
2765            ConferenceListDocument::from_xml(
2766                b"<!DOCTYPE menu><CiscoIPPhoneMenu/>",
2767                ConferenceMenuFamily::Menu,
2768            )
2769            .is_err()
2770        );
2771    }
2772
2773    #[test]
2774    fn directory_schema_round_trips_entries_controls_attributes_and_escaping() {
2775        let expected = CiscoIpPhoneDirectory {
2776            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
2777            application_id: Some("directory-west".into()),
2778            on_focus_lost: Some("Notify:focus?state=lost&view=all".into()),
2779            on_focus_gained: None,
2780            on_minimized: None,
2781            on_closed: Some("SoftKey:Exit".into()),
2782            title: Some("R&D <West>".into()),
2783            prompt: Some("Choose A & B".into()),
2784            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
2785                name: Some("Next".into()),
2786                position: PhoneSoftKeyPosition::new(3).unwrap(),
2787                url: Some("http://pbx.test/directory?page=2&query=R%26D".into()),
2788                url_down: None,
2789            }],
2790            key_items: vec![CiscoIpPhoneKeyItem {
2791                key: PhoneXmlKey::NavBack,
2792                url: Some("SoftKey:Cancel".into()),
2793                url_down: None,
2794            }],
2795            entries: vec![CiscoIpPhoneDirectoryEntry {
2796                name: Some("Alice <Admin> & Bob".into()),
2797                telephone: Some("1001&2".into()),
2798            }],
2799        };
2800
2801        let xml = expected.to_xml().unwrap();
2802        assert!(xml.starts_with("<CiscoIPPhoneDirectory"));
2803        assert!(xml.contains("keypadTarget=\"applicationCall\""));
2804        assert!(xml.contains("R&amp;D &lt;West&gt;"));
2805        assert!(xml.contains("Alice &lt;Admin&gt; &amp; Bob"));
2806        assert_eq!(
2807            CiscoIpPhoneDirectory::from_xml(xml.as_bytes()).unwrap(),
2808            expected
2809        );
2810    }
2811
2812    #[test]
2813    fn directory_schema_accepts_the_minimal_document_and_optionally_empty_fields() {
2814        let xml = b"<CiscoIPPhoneDirectory><Title/><Prompt/><DirectoryEntry><Name/><Telephone/></DirectoryEntry></CiscoIPPhoneDirectory>";
2815        let document = CiscoIpPhoneDirectory::from_xml(xml).unwrap();
2816        assert_eq!(document.title.as_deref(), Some(""));
2817        assert_eq!(document.prompt.as_deref(), Some(""));
2818        assert_eq!(document.entries.len(), 1);
2819        assert_eq!(document.entries[0].name.as_deref(), Some(""));
2820        assert_eq!(document.entries[0].telephone.as_deref(), Some(""));
2821    }
2822
2823    #[test]
2824    fn directory_schema_enforces_entry_text_control_and_document_bounds() {
2825        let too_many = vec![
2826            CiscoIpPhoneDirectoryEntry {
2827                name: Some("Name".into()),
2828                telephone: Some("1000".into()),
2829            };
2830            PHONE_DIRECTORY_MAX_ENTRIES + 1
2831        ];
2832        assert!(matches!(
2833            CiscoIpPhoneDirectory::new("Directory", "Choose", too_many),
2834            Err(PhoneXmlError::LimitExceeded {
2835                kind: "directory entries",
2836                ..
2837            })
2838        ));
2839
2840        let invalid = CiscoIpPhoneDirectory::new(
2841            "Directory",
2842            "Choose",
2843            vec![CiscoIpPhoneDirectoryEntry {
2844                name: Some("x".repeat(PHONE_DIRECTORY_TEXT_MAX_CHARS + 1)),
2845                telephone: Some("1000".into()),
2846            }],
2847        )
2848        .unwrap_err();
2849        assert!(matches!(invalid, PhoneXmlError::InvalidField { .. }));
2850
2851        assert!(PhoneSoftKeyPosition::new(0).is_err());
2852        assert!(PhoneSoftKeyPosition::new(-1).is_ok());
2853        assert!(PhoneSoftKeyPosition::new(16).is_ok());
2854        assert!(PhoneSoftKeyPosition::new(17).is_err());
2855
2856        assert!(
2857            CiscoIpPhoneDirectory::from_xml(b"<!DOCTYPE directory><CiscoIPPhoneDirectory/>",)
2858                .is_err()
2859        );
2860        assert!(matches!(
2861            CiscoIpPhoneDirectory::from_xml(&vec![b'x'; PHONE_DIRECTORY_MAX_BYTES + 1]),
2862            Err(PhoneXmlError::LimitExceeded { .. })
2863        ));
2864    }
2865}