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 crate::{ConferenceId, ParticipantId};
18
19mod document;
20pub use document::PhoneXmlDocument;
21
22/// Maximum participants represented by a conference menu document.
23pub const CONFERENCE_LIST_MAX_PARTICIPANTS: usize = 16;
24/// Maximum encoded conference menu size, in bytes.
25pub const CONFERENCE_LIST_MAX_BYTES: usize = 2_000;
26/// Maximum entries accepted in a directory document.
27pub const PHONE_DIRECTORY_MAX_ENTRIES: usize = 32;
28/// Maximum encoded directory document size, in bytes.
29pub const PHONE_DIRECTORY_MAX_BYTES: usize = 8_192;
30/// Maximum selectable entries in a plain menu document.
31pub const PHONE_MENU_MAX_ITEMS: usize = 100;
32/// Maximum selectable entries in an icon menu document.
33pub const PHONE_ICON_MENU_MAX_ITEMS: usize = 32;
34/// Maximum embedded or referenced icons in an icon menu document.
35pub const PHONE_ICON_MENU_MAX_ICONS: usize = 10;
36/// Maximum encoded size shared by menu document families, in bytes.
37pub const PHONE_MENU_MAX_BYTES: usize = 64 * 1_024;
38/// Maximum Unicode character count for a text document body.
39pub const PHONE_TEXT_MAX_CHARS: usize = 4_000;
40/// Maximum encoded text document size, in bytes.
41pub const PHONE_TEXT_MAX_BYTES: usize = 32 * 1_024;
42/// Compatibility character bound for display profiles with smaller text capacity.
43pub const PHONE_TEXT_LEGACY_MAX_CHARS: usize = 1_024;
44/// Reserved application identifier used by text-display workflows.
45pub const PHONE_TEXT_APPLICATION_ID: u32 = 9_089;
46/// Maximum input controls in one input document.
47pub const PHONE_INPUT_MAX_ITEMS: usize = 5;
48/// Maximum encoded input document size, in bytes.
49pub const PHONE_INPUT_MAX_BYTES: usize = 32 * 1_024;
50/// Maximum actions in one execute document.
51pub const PHONE_EXECUTE_MAX_ITEMS: usize = 3;
52/// Maximum encoded execute document size, in bytes.
53pub const PHONE_EXECUTE_MAX_BYTES: usize = 8 * 1_024;
54/// Maximum decoded bitmap data in an inline image, in bytes.
55pub const PHONE_IMAGE_BITMAP_MAX_BYTES: usize = 2_162;
56/// Maximum touch entries in an inline-bitmap graphic menu.
57pub const PHONE_GRAPHIC_MENU_MAX_ITEMS: usize = 12;
58/// Maximum touch entries in an image-file graphic menu.
59pub const PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS: usize = 32;
60/// Maximum encoded size shared by image document families, in bytes.
61pub const PHONE_IMAGE_MAX_BYTES: usize = 64 * 1_024;
62/// Maximum decoded bitmap data in a status document, in bytes.
63pub const PHONE_STATUS_BITMAP_MAX_BYTES: usize = 557;
64/// Maximum encoded size shared by status document families, in bytes.
65pub const PHONE_STATUS_MAX_BYTES: usize = 8 * 1_024;
66/// Maximum encoded alarm telemetry size, in bytes.
67pub const PHONE_ALARM_MAX_BYTES: usize = 2_048;
68/// Maximum encoded location telemetry size, in bytes.
69pub const PHONE_LOCATION_MAX_BYTES: usize = 2_404;
70/// Reserved application identifier used by background-image workflows.
71pub const PHONE_BACKGROUND_APPLICATION_ID: u32 = 9_086;
72/// Maximum choices in a background-image list.
73pub const PHONE_BACKGROUND_LIST_MAX_ITEMS: usize = 50;
74/// Maximum encoded background-image list size, in bytes.
75pub const PHONE_BACKGROUND_LIST_MAX_BYTES: usize = 32 * 1_024;
76/// Maximum encoded background-control document size, in bytes.
77pub const PHONE_BACKGROUND_CONTROL_MAX_BYTES: usize = 2_000;
78/// Reserved application identifier used by ringtone workflows.
79pub const PHONE_RINGTONE_APPLICATION_ID: u32 = 9_087;
80/// Maximum encoded ringtone-control document size, in bytes.
81pub const PHONE_RINGTONE_MAX_BYTES: usize = 2_000;
82/// Maximum element nesting accepted before deserialization.
83pub const PHONE_XML_MAX_NESTING_DEPTH: usize = 32;
84const PHONE_DIRECTORY_TEXT_MAX_CHARS: usize = 32;
85const PHONE_XML_URL_MAX_CHARS: usize = 256;
86
87/// Schema, resource-limit, and serialization failures at the XML boundary.
88#[derive(Debug, Error)]
89pub enum PhoneXmlError {
90    /// A collection or encoded document crossed its explicit resource bound.
91    #[error("{kind} has {actual} entries or bytes; maximum is {maximum}")]
92    LimitExceeded {
93        kind: &'static str,
94        actual: usize,
95        maximum: usize,
96    },
97    /// Undeclared non-UTF-8 input reached a UTF-8-only boundary.
98    #[error("phone XML is not valid UTF-8: {0}")]
99    InvalidUtf8(#[source] std::str::Utf8Error),
100    /// A document-type declaration was rejected before entity expansion.
101    #[error("phone XML document types and entity declarations are not allowed")]
102    DocumentTypeForbidden,
103    /// An entity reference was undeclared or resolved to an invalid XML character.
104    #[error("phone XML contains an invalid or undeclared entity reference")]
105    InvalidEntity,
106    /// A recognized alarm root did not satisfy its typed schema.
107    #[error("supported phone alarm does not match its typed schema")]
108    InvalidAlarmSchema,
109    /// A recognized location root did not satisfy its typed schema.
110    #[error("supported phone location information does not match its typed schema")]
111    InvalidLocationSchema,
112    /// Element depth crossed [`PHONE_XML_MAX_NESTING_DEPTH`].
113    #[error("phone XML nesting exceeds the maximum depth of {maximum}")]
114    NestingTooDeep { maximum: usize },
115    /// Tokenization failed before typed deserialization.
116    #[error("phone XML is malformed: {0}")]
117    Malformed(#[source] quick_xml::Error),
118    /// The XML was well-formed but did not match the selected model.
119    #[error("phone XML does not match its typed schema: {0}")]
120    Deserialize(#[source] quick_xml::DeError),
121    /// The typed model could not be converted to XML.
122    #[error("phone XML could not be serialized: {0}")]
123    Serialize(#[source] quick_xml::SeError),
124    /// A formatting sink failed while receiving a bounded serialized document.
125    #[error("phone XML could not be written: {0}")]
126    Write(#[source] fmt::Error),
127    /// A model value violated a schema invariant beyond its Rust type.
128    #[error("{field} must be {expected}")]
129    InvalidField {
130        field: &'static str,
131        expected: &'static str,
132    },
133}
134
135/// The application focus that receives keypad events for a displayable phone
136/// service document.
137#[derive(Clone, Copy, Debug, Default, serde::Deserialize, Eq, PartialEq, Serialize)]
138pub enum PhoneKeypadTarget {
139    #[default]
140    #[serde(rename = "application")]
141    Application,
142    #[serde(rename = "applicationCall")]
143    ApplicationCall,
144    #[serde(rename = "activeCall")]
145    ActiveCall,
146}
147
148/// A physical key event routed by a displayable phone service document.
149#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
150pub enum PhoneXmlKey {
151    KeyPad0,
152    KeyPad1,
153    KeyPad2,
154    KeyPad3,
155    KeyPad4,
156    KeyPad5,
157    KeyPad6,
158    KeyPad7,
159    KeyPad8,
160    KeyPad9,
161    KeyPadStar,
162    KeyPadPound,
163    NavUp,
164    NavDown,
165    NavLeft,
166    NavRight,
167    NavSelect,
168    NavBack,
169    PushToTalk,
170}
171
172/// Optional label and press/release actions assigned to one soft-key slot.
173#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
174#[serde(deny_unknown_fields)]
175pub struct CiscoIpPhoneSoftKeyItem {
176    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
177    pub name: Option<String>,
178    #[serde(rename = "Position")]
179    pub position: PhoneSoftKeyPosition,
180    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
181    pub url: Option<String>,
182    #[serde(rename = "URLDown", default, skip_serializing_if = "Option::is_none")]
183    pub url_down: Option<String>,
184}
185
186/// A phone soft-key slot. `-1` is the documented application/settings slot;
187/// physical rows use positions 1 through 16. Device-profile code may impose a
188/// smaller maximum without weakening the shared XML model.
189#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
190pub struct PhoneSoftKeyPosition(i8);
191
192impl PhoneSoftKeyPosition {
193    /// Sentinel slot used for the application or settings action.
194    pub const APPLICATION: Self = Self(-1);
195
196    /// Accepts the application sentinel or a physical slot in `1..=16`.
197    pub fn new(value: i16) -> Result<Self, PhoneXmlError> {
198        if value == -1 || (1..=16).contains(&value) {
199            i8::try_from(value)
200                .map(Self)
201                .map_err(|_| PhoneXmlError::InvalidField {
202                    field: "phone soft-key position",
203                    expected: "-1 or between 1 and 16",
204                })
205        } else {
206            Err(PhoneXmlError::InvalidField {
207                field: "phone soft-key position",
208                expected: "-1 or between 1 and 16",
209            })
210        }
211    }
212
213    /// Returns the signed wire value, including `-1` for [`Self::APPLICATION`].
214    pub const fn get(self) -> i8 {
215        self.0
216    }
217}
218
219impl Serialize for PhoneSoftKeyPosition {
220    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
221    where
222        S: serde::Serializer,
223    {
224        serializer.serialize_i8(self.0)
225    }
226}
227
228impl<'de> serde::Deserialize<'de> for PhoneSoftKeyPosition {
229    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
230    where
231        D: serde::Deserializer<'de>,
232    {
233        let value = <i16 as serde::Deserialize>::deserialize(deserializer)?;
234        Self::new(value).map_err(serde::de::Error::custom)
235    }
236}
237
238/// Press and optional release actions bound to one physical key.
239#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
240#[serde(deny_unknown_fields)]
241pub struct CiscoIpPhoneKeyItem {
242    #[serde(rename = "Key")]
243    pub key: PhoneXmlKey,
244    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
245    pub url: Option<String>,
246    #[serde(rename = "URLDown", default, skip_serializing_if = "Option::is_none")]
247    pub url_down: Option<String>,
248}
249
250/// Valid display priority for a phone-service application-data envelope.
251#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
252pub struct PhoneServicePriority(u8);
253
254impl PhoneServicePriority {
255    pub const LOW: Self = Self(0);
256    pub const NORMAL: Self = Self(1);
257    pub const HIGH: Self = Self(2);
258
259    /// Validates a numeric envelope priority in the inclusive range `0..=2`.
260    pub fn new(value: u32) -> Result<Self, PhoneXmlError> {
261        if value > u32::from(Self::HIGH.0) {
262            return Err(PhoneXmlError::InvalidField {
263                field: "phone-service display priority",
264                expected: "between 0 and 2",
265            });
266        }
267        u8::try_from(value)
268            .map(Self)
269            .map_err(|_| PhoneXmlError::InvalidField {
270                field: "phone-service display priority",
271                expected: "between 0 and 2",
272            })
273    }
274
275    pub fn wire(self) -> u32 {
276        u32::from(self.0)
277    }
278}
279
280impl Default for PhoneServicePriority {
281    fn default() -> Self {
282        Self::NORMAL
283    }
284}
285
286/// Typed HTTP refresh metadata for a pulled phone-service document.
287#[derive(Clone, Debug, Eq, PartialEq)]
288pub struct PhoneXmlRefresh {
289    delay_seconds: u32,
290    url: String,
291}
292
293impl PhoneXmlRefresh {
294    /// Validates an ASCII refresh URL and associates its delay in seconds.
295    pub fn new(delay_seconds: u32, url: impl Into<String>) -> Result<Self, PhoneXmlError> {
296        let refresh = Self {
297            delay_seconds,
298            url: url.into(),
299        };
300        validate_optional_text(
301            "phone XML refresh URL",
302            Some(&refresh.url),
303            1,
304            PHONE_XML_URL_MAX_CHARS,
305        )?;
306        if !refresh.url.is_ascii()
307            || refresh
308                .url
309                .chars()
310                .any(|character| character.is_ascii_whitespace() || character.is_ascii_control())
311        {
312            return Err(PhoneXmlError::InvalidField {
313                field: "phone XML refresh URL",
314                expected: "an ASCII URL between 1 and 256 characters",
315            });
316        }
317        Ok(refresh)
318    }
319
320    pub const fn delay_seconds(&self) -> u32 {
321        self.delay_seconds
322    }
323
324    pub fn url(&self) -> &str {
325        &self.url
326    }
327
328    /// Formats the value for an HTTP `Refresh` response header.
329    pub fn http_header_value(&self) -> String {
330        format!("{};url={}", self.delay_seconds, self.url)
331    }
332}
333
334/// A complete, schema-ordered text display document.
335#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
336#[serde(rename = "CiscoIPPhoneText", deny_unknown_fields)]
337pub struct CiscoIpPhoneText {
338    #[serde(
339        rename = "@keypadTarget",
340        default,
341        skip_serializing_if = "Option::is_none"
342    )]
343    pub keypad_target: Option<PhoneKeypadTarget>,
344    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
345    pub application_id: Option<String>,
346    #[serde(
347        rename = "@onAppFocusLost",
348        default,
349        skip_serializing_if = "Option::is_none"
350    )]
351    pub on_focus_lost: Option<String>,
352    #[serde(
353        rename = "@onAppFocusGained",
354        default,
355        skip_serializing_if = "Option::is_none"
356    )]
357    pub on_focus_gained: Option<String>,
358    #[serde(
359        rename = "@onAppMinimized",
360        default,
361        skip_serializing_if = "Option::is_none"
362    )]
363    pub on_minimized: Option<String>,
364    #[serde(
365        rename = "@onAppClosed",
366        default,
367        skip_serializing_if = "Option::is_none"
368    )]
369    pub on_closed: Option<String>,
370    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
371    pub title: Option<String>,
372    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
373    pub prompt: Option<String>,
374    #[serde(rename = "SoftKeyItem", default)]
375    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
376    #[serde(rename = "KeyItem", default)]
377    pub key_items: Vec<CiscoIpPhoneKeyItem>,
378    #[serde(rename = "Text", default, skip_serializing_if = "Option::is_none")]
379    pub text: Option<String>,
380}
381
382impl CiscoIpPhoneText {
383    /// Builds and validates a text page with no optional lifecycle actions.
384    pub fn new(
385        title: impl Into<String>,
386        prompt: impl Into<String>,
387        text: impl Into<String>,
388    ) -> Result<Self, PhoneXmlError> {
389        let document = Self {
390            keypad_target: None,
391            application_id: None,
392            on_focus_lost: None,
393            on_focus_gained: None,
394            on_minimized: None,
395            on_closed: None,
396            title: Some(title.into()),
397            prompt: Some(prompt.into()),
398            soft_keys: Vec::new(),
399            key_items: Vec::new(),
400            text: Some(text.into()),
401        };
402        document.validate()?;
403        Ok(document)
404    }
405
406    /// Validates display metadata and the text-body character bound.
407    pub fn validate(&self) -> Result<(), PhoneXmlError> {
408        validate_displayable(
409            self.title.as_deref(),
410            self.prompt.as_deref(),
411            self.application_id.as_deref(),
412            [
413                self.on_focus_lost.as_deref(),
414                self.on_focus_gained.as_deref(),
415                self.on_minimized.as_deref(),
416                self.on_closed.as_deref(),
417            ],
418            &self.soft_keys,
419            &self.key_items,
420        )?;
421        validate_optional_text(
422            "phone text body",
423            self.text.as_deref(),
424            0,
425            PHONE_TEXT_MAX_CHARS,
426        )
427    }
428
429    /// Parses a complete text page using [`PHONE_TEXT_MAX_BYTES`].
430    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
431        <Self as PhoneXmlDocument>::parse_xml(document)
432    }
433
434    /// Parses a text page with an additional caller-selected byte limit.
435    pub fn from_xml_with_limit(
436        document: &[u8],
437        maximum_bytes: usize,
438    ) -> Result<Self, PhoneXmlError> {
439        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
440    }
441
442    /// Validates and serializes a text page using [`PHONE_TEXT_MAX_BYTES`].
443    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
444        <Self as PhoneXmlDocument>::serialize_xml(self)
445    }
446
447    /// Validates and serializes a text page within a caller-selected byte limit.
448    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
449        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
450    }
451}
452
453/// One of the input modes accepted by a phone input-service field.
454///
455/// The password-modified variants preserve whether `P` precedes or follows
456/// the base mode because both forms are distinct accepted schema values.
457#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
458pub enum PhoneInputFlags {
459    #[serde(rename = "A")]
460    Alphabetic,
461    #[serde(rename = "T")]
462    Telephone,
463    #[serde(rename = "N")]
464    Numeric,
465    #[serde(rename = "E")]
466    Equation,
467    #[serde(rename = "U")]
468    Uppercase,
469    #[serde(rename = "L")]
470    Lowercase,
471    #[serde(rename = "AP")]
472    AlphabeticPassword,
473    #[serde(rename = "TP")]
474    TelephonePassword,
475    #[serde(rename = "NP")]
476    NumericPassword,
477    #[serde(rename = "EP")]
478    EquationPassword,
479    #[serde(rename = "UP")]
480    UppercasePassword,
481    #[serde(rename = "LP")]
482    LowercasePassword,
483    #[serde(rename = "PA")]
484    PasswordAlphabetic,
485    #[serde(rename = "PT")]
486    PasswordTelephone,
487    #[serde(rename = "PN")]
488    PasswordNumeric,
489    #[serde(rename = "PE")]
490    PasswordEquation,
491    #[serde(rename = "PU")]
492    PasswordUppercase,
493    #[serde(rename = "PL")]
494    PasswordLowercase,
495}
496
497impl PhoneInputFlags {
498    /// Every accepted schema spelling, including both password modifier orders.
499    pub const ALL: [Self; 18] = [
500        Self::Alphabetic,
501        Self::Telephone,
502        Self::Numeric,
503        Self::Equation,
504        Self::Uppercase,
505        Self::Lowercase,
506        Self::AlphabeticPassword,
507        Self::TelephonePassword,
508        Self::NumericPassword,
509        Self::EquationPassword,
510        Self::UppercasePassword,
511        Self::LowercasePassword,
512        Self::PasswordAlphabetic,
513        Self::PasswordTelephone,
514        Self::PasswordNumeric,
515        Self::PasswordEquation,
516        Self::PasswordUppercase,
517        Self::PasswordLowercase,
518    ];
519}
520
521/// A schema-bounded name used for one submitted query parameter.
522#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
523#[serde(transparent)]
524pub struct PhoneInputParameterName(String);
525
526impl PhoneInputParameterName {
527    /// Validates a non-empty parameter name of at most 32 characters.
528    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
529        let value = value.into();
530        validate_optional_text("phone input parameter name", Some(&value), 1, 32)?;
531        Ok(Self(value))
532    }
533
534    pub fn as_str(&self) -> &str {
535        &self.0
536    }
537}
538
539impl<'de> serde::Deserialize<'de> for PhoneInputParameterName {
540    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
541    where
542        D: serde::Deserializer<'de>,
543    {
544        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
545        Self::new(value).map_err(serde::de::Error::custom)
546    }
547}
548
549/// One field in a phone input-service document.
550#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
551#[serde(deny_unknown_fields)]
552pub struct CiscoIpPhoneInputItem {
553    #[serde(
554        rename = "DisplayName",
555        default,
556        skip_serializing_if = "Option::is_none"
557    )]
558    pub display_name: Option<String>,
559    #[serde(rename = "QueryStringParam")]
560    pub parameter: PhoneInputParameterName,
561    #[serde(rename = "InputFlags")]
562    pub flags: PhoneInputFlags,
563    #[serde(
564        rename = "DefaultValue",
565        default,
566        skip_serializing_if = "Option::is_none"
567    )]
568    pub default_value: Option<String>,
569}
570
571/// A complete, schema-ordered interactive input document.
572#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
573#[serde(rename = "CiscoIPPhoneInput", deny_unknown_fields)]
574pub struct CiscoIpPhoneInput {
575    #[serde(
576        rename = "@keypadTarget",
577        default,
578        skip_serializing_if = "Option::is_none"
579    )]
580    pub keypad_target: Option<PhoneKeypadTarget>,
581    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
582    pub application_id: Option<String>,
583    #[serde(
584        rename = "@onAppFocusLost",
585        default,
586        skip_serializing_if = "Option::is_none"
587    )]
588    pub on_focus_lost: Option<String>,
589    #[serde(
590        rename = "@onAppFocusGained",
591        default,
592        skip_serializing_if = "Option::is_none"
593    )]
594    pub on_focus_gained: Option<String>,
595    #[serde(
596        rename = "@onAppMinimized",
597        default,
598        skip_serializing_if = "Option::is_none"
599    )]
600    pub on_minimized: Option<String>,
601    #[serde(
602        rename = "@onAppClosed",
603        default,
604        skip_serializing_if = "Option::is_none"
605    )]
606    pub on_closed: Option<String>,
607    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
608    pub title: Option<String>,
609    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
610    pub prompt: Option<String>,
611    #[serde(rename = "SoftKeyItem", default)]
612    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
613    #[serde(rename = "KeyItem", default)]
614    pub key_items: Vec<CiscoIpPhoneKeyItem>,
615    #[serde(rename = "URL")]
616    pub url: String,
617    #[serde(rename = "InputItem", default)]
618    pub items: Vec<CiscoIpPhoneInputItem>,
619}
620
621impl CiscoIpPhoneInput {
622    /// Builds and validates an input form with no optional lifecycle actions.
623    pub fn new(
624        title: impl Into<String>,
625        prompt: impl Into<String>,
626        url: impl Into<String>,
627        items: Vec<CiscoIpPhoneInputItem>,
628    ) -> Result<Self, PhoneXmlError> {
629        let document = Self {
630            keypad_target: None,
631            application_id: None,
632            on_focus_lost: None,
633            on_focus_gained: None,
634            on_minimized: None,
635            on_closed: None,
636            title: Some(title.into()),
637            prompt: Some(prompt.into()),
638            soft_keys: Vec::new(),
639            key_items: Vec::new(),
640            url: url.into(),
641            items,
642        };
643        document.validate()?;
644        Ok(document)
645    }
646
647    /// Validates display metadata, submission URL, field count, and field text.
648    pub fn validate(&self) -> Result<(), PhoneXmlError> {
649        validate_displayable(
650            self.title.as_deref(),
651            self.prompt.as_deref(),
652            self.application_id.as_deref(),
653            [
654                self.on_focus_lost.as_deref(),
655                self.on_focus_gained.as_deref(),
656                self.on_minimized.as_deref(),
657                self.on_closed.as_deref(),
658            ],
659            &self.soft_keys,
660            &self.key_items,
661        )?;
662        validate_optional_text(
663            "phone input submission URL",
664            Some(&self.url),
665            1,
666            PHONE_XML_URL_MAX_CHARS,
667        )?;
668        validate_count(
669            "phone input fields",
670            self.items.len(),
671            PHONE_INPUT_MAX_ITEMS,
672        )?;
673        for item in &self.items {
674            validate_optional_text(
675                "phone input display name",
676                item.display_name.as_deref(),
677                0,
678                32,
679            )?;
680            validate_optional_text(
681                "phone input default value",
682                item.default_value.as_deref(),
683                0,
684                32,
685            )?;
686        }
687        Ok(())
688    }
689
690    /// Parses a complete input form using [`PHONE_INPUT_MAX_BYTES`].
691    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
692        <Self as PhoneXmlDocument>::parse_xml(document)
693    }
694
695    /// Parses an input form with an additional caller-selected byte limit.
696    pub fn from_xml_with_limit(
697        document: &[u8],
698        maximum_bytes: usize,
699    ) -> Result<Self, PhoneXmlError> {
700        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
701    }
702
703    /// Validates and serializes an input form using [`PHONE_INPUT_MAX_BYTES`].
704    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
705        <Self as PhoneXmlDocument>::serialize_xml(self)
706    }
707
708    /// Validates and serializes an input form within a caller-selected byte limit.
709    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
710        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
711    }
712}
713
714/// Valid priority for one phone execute action.
715#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
716pub struct PhoneExecutePriority(u8);
717
718impl PhoneExecutePriority {
719    pub const LOW: Self = Self(0);
720    pub const NORMAL: Self = Self(1);
721    pub const HIGH: Self = Self(2);
722
723    /// Validates a numeric action priority in the inclusive range `0..=2`.
724    pub fn new(value: u8) -> Result<Self, PhoneXmlError> {
725        if value > Self::HIGH.0 {
726            return Err(PhoneXmlError::InvalidField {
727                field: "phone execute priority",
728                expected: "between 0 and 2",
729            });
730        }
731        Ok(Self(value))
732    }
733
734    pub const fn wire(self) -> u8 {
735        self.0
736    }
737}
738
739impl Serialize for PhoneExecutePriority {
740    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
741    where
742        S: serde::Serializer,
743    {
744        serializer.serialize_u8(self.0)
745    }
746}
747
748impl<'de> serde::Deserialize<'de> for PhoneExecutePriority {
749    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
750    where
751        D: serde::Deserializer<'de>,
752    {
753        let value = <u8 as serde::Deserialize>::deserialize(deserializer)?;
754        Self::new(value).map_err(serde::de::Error::custom)
755    }
756}
757
758/// The two execution domains understood by phone services.
759#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
760pub enum PhoneActionKind {
761    /// An HTTP or HTTPS action; only one is permitted per execute document.
762    Http,
763    /// A device-local action using a non-HTTP scheme.
764    Internal,
765}
766
767/// A schema-bounded action in a phone execute document.
768#[derive(Clone, Debug, Eq, Hash, PartialEq)]
769pub struct PhoneExecuteUrl {
770    value: String,
771    kind: PhoneActionKind,
772}
773
774impl PhoneExecuteUrl {
775    /// Validates the URL length and classifies its execution domain.
776    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
777        let value = value.into();
778        validate_optional_text(
779            "phone execute URL",
780            Some(&value),
781            1,
782            PHONE_XML_URL_MAX_CHARS,
783        )?;
784        let kind = action_kind(&value);
785        Ok(Self { value, kind })
786    }
787
788    pub fn as_str(&self) -> &str {
789        &self.value
790    }
791
792    pub const fn kind(&self) -> PhoneActionKind {
793        self.kind
794    }
795}
796
797impl Serialize for PhoneExecuteUrl {
798    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
799    where
800        S: serde::Serializer,
801    {
802        serializer.serialize_str(&self.value)
803    }
804}
805
806impl<'de> serde::Deserialize<'de> for PhoneExecuteUrl {
807    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
808    where
809        D: serde::Deserializer<'de>,
810    {
811        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
812        Self::new(value).map_err(serde::de::Error::custom)
813    }
814}
815
816/// One ordered URL action in an execute document.
817#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
818#[serde(deny_unknown_fields)]
819pub struct CiscoIpPhoneExecuteItem {
820    #[serde(rename = "@Priority", default, skip_serializing_if = "Option::is_none")]
821    pub priority: Option<PhoneExecutePriority>,
822    #[serde(rename = "@URL")]
823    pub url: PhoneExecuteUrl,
824}
825
826impl CiscoIpPhoneExecuteItem {
827    /// Creates a validated action with no explicit priority.
828    pub fn new(url: impl Into<String>) -> Result<Self, PhoneXmlError> {
829        Ok(Self {
830            priority: None,
831            url: PhoneExecuteUrl::new(url)?,
832        })
833    }
834
835    /// Creates a validated action with an explicit scheduling priority.
836    pub fn with_priority(
837        url: impl Into<String>,
838        priority: PhoneExecutePriority,
839    ) -> Result<Self, PhoneXmlError> {
840        Ok(Self {
841            priority: Some(priority),
842            url: PhoneExecuteUrl::new(url)?,
843        })
844    }
845}
846
847/// A complete, schema-ordered execute document.
848#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
849#[serde(rename = "CiscoIPPhoneExecute", deny_unknown_fields)]
850pub struct CiscoIpPhoneExecute {
851    #[serde(rename = "ExecuteItem", default)]
852    pub items: Vec<CiscoIpPhoneExecuteItem>,
853}
854
855impl CiscoIpPhoneExecute {
856    /// Builds a document containing one to [`PHONE_EXECUTE_MAX_ITEMS`] actions.
857    pub fn new(items: Vec<CiscoIpPhoneExecuteItem>) -> Result<Self, PhoneXmlError> {
858        let document = Self { items };
859        document.validate()?;
860        Ok(document)
861    }
862
863    /// Checks action count and the single-HTTP-action invariant.
864    pub fn validate(&self) -> Result<(), PhoneXmlError> {
865        if self.items.is_empty() {
866            return Err(PhoneXmlError::InvalidField {
867                field: "phone execute actions",
868                expected: "between 1 and 3 entries",
869            });
870        }
871        validate_count(
872            "phone execute actions",
873            self.items.len(),
874            PHONE_EXECUTE_MAX_ITEMS,
875        )?;
876        if self
877            .items
878            .iter()
879            .filter(|item| item.url.kind() == PhoneActionKind::Http)
880            .count()
881            > 1
882        {
883            return Err(PhoneXmlError::InvalidField {
884                field: "phone execute HTTP actions",
885                expected: "at most one HTTP or HTTPS action",
886            });
887        }
888        Ok(())
889    }
890
891    /// Parses a complete execute document using [`PHONE_EXECUTE_MAX_BYTES`].
892    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
893        <Self as PhoneXmlDocument>::parse_xml(document)
894    }
895
896    /// Parses an execute document with an additional caller-selected byte limit.
897    pub fn from_xml_with_limit(
898        document: &[u8],
899        maximum_bytes: usize,
900    ) -> Result<Self, PhoneXmlError> {
901        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
902    }
903
904    /// Validates and serializes an execute document using [`PHONE_EXECUTE_MAX_BYTES`].
905    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
906        <Self as PhoneXmlDocument>::serialize_xml(self)
907    }
908
909    /// Validates and serializes an execute document within a caller-selected byte limit.
910    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
911        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
912    }
913}
914
915/// Validated hexadecimal bitmap data used by image-service documents.
916///
917/// The public value is binary. Serde converts it to and from the XML Schema
918/// `hexBinary` lexical representation, accepting schema whitespace and either
919/// letter case while emitting one stable uppercase representation.
920#[derive(Clone, Debug, Eq, PartialEq)]
921pub struct PhoneBitmapData(Vec<u8>);
922
923impl PhoneBitmapData {
924    /// Validates decoded bitmap bytes against [`PHONE_IMAGE_BITMAP_MAX_BYTES`].
925    pub fn new(bytes: impl Into<Vec<u8>>) -> Result<Self, PhoneXmlError> {
926        let bytes = bytes.into();
927        validate_count(
928            "bitmap image data bytes",
929            bytes.len(),
930            PHONE_IMAGE_BITMAP_MAX_BYTES,
931        )?;
932        Ok(Self(bytes))
933    }
934
935    pub fn as_bytes(&self) -> &[u8] {
936        &self.0
937    }
938
939    pub fn into_bytes(self) -> Vec<u8> {
940        self.0
941    }
942}
943
944impl Serialize for PhoneBitmapData {
945    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
946    where
947        S: serde::Serializer,
948    {
949        const HEX: &[u8; 16] = b"0123456789ABCDEF";
950        let mut encoded = String::with_capacity(self.0.len().saturating_mul(2));
951        for byte in &self.0 {
952            encoded.push(char::from(HEX[usize::from(byte >> 4)]));
953            encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
954        }
955        serializer.serialize_str(&encoded)
956    }
957}
958
959impl<'de> serde::Deserialize<'de> for PhoneBitmapData {
960    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
961    where
962        D: serde::Deserializer<'de>,
963    {
964        let encoded = <String as serde::Deserialize>::deserialize(deserializer)?;
965        let digits = encoded
966            .bytes()
967            .filter(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
968            .count();
969        let digit_count = encoded.len().saturating_sub(digits);
970        if digit_count % 2 != 0 {
971            return Err(serde::de::Error::custom(
972                "bitmap data must contain complete hexadecimal bytes",
973            ));
974        }
975        let mut decoded = Vec::with_capacity(digit_count / 2);
976        let mut high = None;
977        for byte in encoded
978            .bytes()
979            .filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
980        {
981            let value = match byte {
982                b'0'..=b'9' => byte - b'0',
983                b'a'..=b'f' => byte - b'a' + 10,
984                b'A'..=b'F' => byte - b'A' + 10,
985                _ => {
986                    return Err(serde::de::Error::custom("bitmap data must be hexadecimal"));
987                }
988            };
989            if let Some(high) = high.take() {
990                decoded.push((high << 4) | value);
991            } else {
992                high = Some(value);
993            }
994        }
995        Self::new(decoded).map_err(serde::de::Error::custom)
996    }
997}
998
999/// A schema-bounded image resource URL.
1000#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
1001#[serde(transparent)]
1002pub struct PhoneImageUrl(String);
1003
1004impl PhoneImageUrl {
1005    /// Validates a non-empty image URL of at most 256 characters.
1006    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
1007        let value = value.into();
1008        validate_optional_text("phone image URL", Some(&value), 1, PHONE_XML_URL_MAX_CHARS)?;
1009        Ok(Self(value))
1010    }
1011
1012    pub fn as_str(&self) -> &str {
1013        &self.0
1014    }
1015}
1016
1017impl<'de> serde::Deserialize<'de> for PhoneImageUrl {
1018    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1019    where
1020        D: serde::Deserializer<'de>,
1021    {
1022        let value = <String as serde::Deserialize>::deserialize(deserializer)?;
1023        Self::new(value).map_err(serde::de::Error::custom)
1024    }
1025}
1026
1027/// A TFTP URI accepted in a background-image selection list.
1028///
1029/// The phone's selection-list schema uses the opaque `TFTP:path` form.  The
1030/// authority-bearing `tftp://host/path` form and HTTP URLs are deliberately
1031/// rejected because they are not interchangeable on the handset.
1032#[derive(Clone, Eq, Hash, PartialEq)]
1033pub struct PhoneBackgroundTftpUrl(String);
1034
1035impl PhoneBackgroundTftpUrl {
1036    /// Validates an opaque `TFTP:path` URI naming a PNG resource.
1037    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
1038        let value = value.into();
1039        validate_optional_text(
1040            "background image TFTP URI",
1041            Some(&value),
1042            1,
1043            PHONE_XML_URL_MAX_CHARS,
1044        )?;
1045        let parsed = url::Url::parse(&value).map_err(|_| PhoneXmlError::InvalidField {
1046            field: "background image TFTP URI",
1047            expected: "a TFTP:path URI to a PNG image",
1048        })?;
1049        let has_tftp_prefix = value
1050            .get(..5)
1051            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("tftp:"));
1052        let path = value.get(5..).unwrap_or_default();
1053        let is_png = path
1054            .rsplit_once('.')
1055            .is_some_and(|(_, extension)| extension.eq_ignore_ascii_case("png"));
1056        if !has_tftp_prefix
1057            || parsed.scheme() != "tftp"
1058            || !parsed.cannot_be_a_base()
1059            || parsed.host_str().is_some()
1060            || parsed.query().is_some()
1061            || parsed.fragment().is_some()
1062            || !valid_background_tftp_path(path)
1063            || !is_png
1064        {
1065            return Err(PhoneXmlError::InvalidField {
1066                field: "background image TFTP URI",
1067                expected: "a TFTP:path URI to a PNG image",
1068            });
1069        }
1070        Ok(Self(value))
1071    }
1072
1073    pub fn as_str(&self) -> &str {
1074        &self.0
1075    }
1076
1077    pub fn into_string(self) -> String {
1078        self.0
1079    }
1080}
1081
1082impl fmt::Debug for PhoneBackgroundTftpUrl {
1083    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1084        formatter.write_str("PhoneBackgroundTftpUrl(<redacted>)")
1085    }
1086}
1087
1088impl Serialize for PhoneBackgroundTftpUrl {
1089    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1090    where
1091        S: serde::Serializer,
1092    {
1093        serializer.serialize_str(&self.0)
1094    }
1095}
1096
1097impl<'de> serde::Deserialize<'de> for PhoneBackgroundTftpUrl {
1098    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1099    where
1100        D: serde::Deserializer<'de>,
1101    {
1102        let value = String::deserialize(deserializer)?;
1103        Self::new(value).map_err(serde::de::Error::custom)
1104    }
1105}
1106
1107fn valid_background_tftp_path(path: &str) -> bool {
1108    !path.is_empty()
1109        && !path.starts_with('/')
1110        && !path.contains(['?', '#', '\\'])
1111        && path.split('/').all(|component| {
1112            valid_percent_encoding(component)
1113                && percent_encoding::percent_decode_str(component)
1114                    .decode_utf8()
1115                    .is_ok_and(|decoded| {
1116                        !decoded.is_empty()
1117                            && decoded != "."
1118                            && decoded != ".."
1119                            && !decoded.contains(['/', '\\'])
1120                            && !decoded.chars().any(char::is_control)
1121                    })
1122        })
1123}
1124
1125fn valid_percent_encoding(value: &str) -> bool {
1126    let bytes = value.as_bytes();
1127    let mut index = 0;
1128    while index < bytes.len() {
1129        if bytes[index] == b'%' {
1130            if index + 2 >= bytes.len()
1131                || !bytes[index + 1].is_ascii_hexdigit()
1132                || !bytes[index + 2].is_ascii_hexdigit()
1133            {
1134                return false;
1135            }
1136            index += 3;
1137        } else {
1138            index += 1;
1139        }
1140    }
1141    true
1142}
1143
1144/// An HTTP URL accepted by the background selection and preview application.
1145#[derive(Clone, Eq, Hash, PartialEq)]
1146pub struct PhoneBackgroundHttpUrl(String);
1147
1148impl PhoneBackgroundHttpUrl {
1149    /// Validates an absolute HTTP URL without credentials or a fragment.
1150    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
1151        let value = value.into();
1152        validate_http_resource_url(
1153            "background image HTTP URL",
1154            "an absolute HTTP URL without credentials or a fragment",
1155            &value,
1156        )?;
1157        Ok(Self(value))
1158    }
1159
1160    pub fn as_str(&self) -> &str {
1161        &self.0
1162    }
1163
1164    pub fn into_string(self) -> String {
1165        self.0
1166    }
1167}
1168
1169fn validate_http_resource_url(
1170    field: &'static str,
1171    expected: &'static str,
1172    value: &str,
1173) -> Result<(), PhoneXmlError> {
1174    validate_optional_text(field, Some(value), 1, PHONE_XML_URL_MAX_CHARS)?;
1175    if value
1176        .chars()
1177        .any(|character| character.is_ascii_whitespace() || character.is_ascii_control())
1178        || value.contains('\\')
1179        || !valid_percent_encoding(value)
1180    {
1181        return Err(PhoneXmlError::InvalidField { field, expected });
1182    }
1183    let parsed =
1184        url::Url::parse(value).map_err(|_| PhoneXmlError::InvalidField { field, expected })?;
1185    if parsed.scheme() != "http"
1186        || parsed.host_str().is_none()
1187        || !parsed.username().is_empty()
1188        || parsed.password().is_some()
1189        || parsed.fragment().is_some()
1190    {
1191        return Err(PhoneXmlError::InvalidField { field, expected });
1192    }
1193    Ok(())
1194}
1195
1196impl fmt::Debug for PhoneBackgroundHttpUrl {
1197    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1198        formatter.write_str("PhoneBackgroundHttpUrl(<redacted>)")
1199    }
1200}
1201
1202impl Serialize for PhoneBackgroundHttpUrl {
1203    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1204    where
1205        S: serde::Serializer,
1206    {
1207        serializer.serialize_str(&self.0)
1208    }
1209}
1210
1211impl<'de> serde::Deserialize<'de> for PhoneBackgroundHttpUrl {
1212    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1213    where
1214        D: serde::Deserializer<'de>,
1215    {
1216        let value = String::deserialize(deserializer)?;
1217        Self::new(value).map_err(serde::de::Error::custom)
1218    }
1219}
1220
1221/// One ordered full-size/thumbnail pair in a background image list.
1222#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1223#[serde(deny_unknown_fields)]
1224pub struct CiscoIpPhoneImageListItem {
1225    #[serde(rename = "@Image")]
1226    pub thumbnail_url: PhoneBackgroundTftpUrl,
1227    #[serde(rename = "@URL")]
1228    pub image_url: PhoneBackgroundTftpUrl,
1229}
1230
1231/// The ordered background choices pulled from a desktop `List.xml` resource.
1232#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1233#[serde(rename = "CiscoIPPhoneImageList", deny_unknown_fields)]
1234pub struct CiscoIpPhoneImageList {
1235    #[serde(rename = "ImageItem", default)]
1236    pub items: Vec<CiscoIpPhoneImageListItem>,
1237}
1238
1239impl CiscoIpPhoneImageList {
1240    /// Builds and validates an ordered list of background choices.
1241    pub fn new(items: Vec<CiscoIpPhoneImageListItem>) -> Result<Self, PhoneXmlError> {
1242        let document = Self { items };
1243        document.validate()?;
1244        Ok(document)
1245    }
1246
1247    /// Enforces [`PHONE_BACKGROUND_LIST_MAX_ITEMS`].
1248    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1249        validate_count(
1250            "background image choices",
1251            self.items.len(),
1252            PHONE_BACKGROUND_LIST_MAX_ITEMS,
1253        )
1254    }
1255
1256    /// Parses and validates a background list using its default byte bound.
1257    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1258        <Self as PhoneXmlDocument>::parse_xml(document)
1259    }
1260
1261    /// Validates and serializes a background list using its default byte bound.
1262    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1263        <Self as PhoneXmlDocument>::serialize_xml(self)
1264    }
1265
1266    /// Validates and serializes a background list within a caller-selected limit.
1267    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
1268        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
1269    }
1270}
1271
1272/// Full-size and thumbnail URLs installed as the phone background.
1273#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1274#[serde(deny_unknown_fields)]
1275pub struct CiscoIpPhoneBackground {
1276    #[serde(rename = "image")]
1277    pub image_url: PhoneBackgroundHttpUrl,
1278    #[serde(rename = "icon")]
1279    pub thumbnail_url: PhoneBackgroundHttpUrl,
1280}
1281
1282/// Background-selection application document.
1283#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1284#[serde(rename = "setBackground", deny_unknown_fields)]
1285pub struct CiscoIpPhoneSetBackground {
1286    #[serde(rename = "background")]
1287    pub background: CiscoIpPhoneBackground,
1288}
1289
1290impl CiscoIpPhoneSetBackground {
1291    /// Creates a background installation request from validated resource URLs.
1292    pub fn new(image_url: PhoneBackgroundHttpUrl, thumbnail_url: PhoneBackgroundHttpUrl) -> Self {
1293        Self {
1294            background: CiscoIpPhoneBackground {
1295                image_url,
1296                thumbnail_url,
1297            },
1298        }
1299    }
1300
1301    /// Parses an installation request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
1302    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1303        #[derive(serde::Deserialize)]
1304        enum SetBackgroundEnvelope {
1305            #[serde(rename = "setBackground")]
1306            SetBackground(CiscoIpPhoneSetBackground),
1307        }
1308
1309        let SetBackgroundEnvelope::SetBackground(document) =
1310            from_bytes(document, PHONE_BACKGROUND_CONTROL_MAX_BYTES)?;
1311        Ok(document)
1312    }
1313
1314    /// Serializes an installation request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
1315    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1316        to_string(self, PHONE_BACKGROUND_CONTROL_MAX_BYTES)
1317    }
1318}
1319
1320/// Background-preview application document.
1321#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1322#[serde(rename = "setBackgroundPreview", deny_unknown_fields)]
1323pub struct CiscoIpPhoneSetBackgroundPreview {
1324    #[serde(rename = "image")]
1325    pub image_url: PhoneBackgroundHttpUrl,
1326}
1327
1328impl CiscoIpPhoneSetBackgroundPreview {
1329    /// Creates a preview request from a validated image URL.
1330    pub const fn new(image_url: PhoneBackgroundHttpUrl) -> Self {
1331        Self { image_url }
1332    }
1333
1334    /// Parses a preview request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
1335    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1336        #[derive(serde::Deserialize)]
1337        enum PreviewEnvelope {
1338            #[serde(rename = "setBackgroundPreview")]
1339            Preview(CiscoIpPhoneSetBackgroundPreview),
1340        }
1341
1342        let PreviewEnvelope::Preview(document) =
1343            from_bytes(document, PHONE_BACKGROUND_CONTROL_MAX_BYTES)?;
1344        Ok(document)
1345    }
1346
1347    /// Serializes a preview request using [`PHONE_BACKGROUND_CONTROL_MAX_BYTES`].
1348    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1349        to_string(self, PHONE_BACKGROUND_CONTROL_MAX_BYTES)
1350    }
1351}
1352
1353/// Either application document accepted by the background-control service.
1354#[derive(Clone, Debug, Eq, PartialEq)]
1355pub enum PhoneBackgroundControlDocument {
1356    Set(CiscoIpPhoneSetBackground),
1357    Preview(CiscoIpPhoneSetBackgroundPreview),
1358}
1359
1360impl PhoneBackgroundControlDocument {
1361    /// Detects and parses either supported background-control root.
1362    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1363        #[derive(serde::Deserialize)]
1364        enum BackgroundEnvelope {
1365            #[serde(rename = "setBackground")]
1366            Set(CiscoIpPhoneSetBackground),
1367            #[serde(rename = "setBackgroundPreview")]
1368            Preview(CiscoIpPhoneSetBackgroundPreview),
1369        }
1370
1371        Ok(
1372            match from_bytes(document, PHONE_BACKGROUND_CONTROL_MAX_BYTES)? {
1373                BackgroundEnvelope::Set(document) => Self::Set(document),
1374                BackgroundEnvelope::Preview(document) => Self::Preview(document),
1375            },
1376        )
1377    }
1378
1379    /// Serializes the selected document using the default control byte bound.
1380    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1381        self.to_xml_with_limit(PHONE_BACKGROUND_CONTROL_MAX_BYTES)
1382    }
1383
1384    /// Serializes the selected document within a caller-selected byte limit.
1385    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
1386        match self {
1387            Self::Set(document) => to_string(document, maximum_bytes),
1388            Self::Preview(document) => to_string(document, maximum_bytes),
1389        }
1390    }
1391}
1392
1393/// An HTTP resource URL accepted by the ringtone-selection application.
1394#[derive(Clone, Eq, Hash, PartialEq)]
1395pub struct PhoneRingtoneUrl(String);
1396
1397impl PhoneRingtoneUrl {
1398    /// Validates an absolute lowercase-scheme HTTP URL without credentials or a fragment.
1399    pub fn new(value: impl Into<String>) -> Result<Self, PhoneXmlError> {
1400        let value = value.into();
1401        if !value.starts_with("http://") {
1402            return Err(PhoneXmlError::InvalidField {
1403                field: "ringtone HTTP URL",
1404                expected: "an absolute lowercase HTTP URL without credentials or a fragment",
1405            });
1406        }
1407        validate_http_resource_url(
1408            "ringtone HTTP URL",
1409            "an absolute lowercase HTTP URL without credentials or a fragment",
1410            &value,
1411        )?;
1412        Ok(Self(value))
1413    }
1414
1415    pub fn as_str(&self) -> &str {
1416        &self.0
1417    }
1418
1419    pub fn into_string(self) -> String {
1420        self.0
1421    }
1422}
1423
1424impl fmt::Debug for PhoneRingtoneUrl {
1425    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1426        formatter.write_str("PhoneRingtoneUrl(<redacted>)")
1427    }
1428}
1429
1430impl Serialize for PhoneRingtoneUrl {
1431    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1432    where
1433        S: serde::Serializer,
1434    {
1435        serializer.serialize_str(&self.0)
1436    }
1437}
1438
1439impl<'de> serde::Deserialize<'de> for PhoneRingtoneUrl {
1440    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1441    where
1442        D: serde::Deserializer<'de>,
1443    {
1444        let value = String::deserialize(deserializer)?;
1445        Self::new(value).map_err(serde::de::Error::custom)
1446    }
1447}
1448
1449/// Ringtone-selection application document.
1450#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1451#[serde(rename = "setRingTone", deny_unknown_fields)]
1452pub struct CiscoIpPhoneSetRingTone {
1453    #[serde(rename = "ringTone")]
1454    pub ringtone_url: PhoneRingtoneUrl,
1455}
1456
1457impl CiscoIpPhoneSetRingTone {
1458    /// Creates a ringtone request from a validated HTTP resource URL.
1459    pub const fn new(ringtone_url: PhoneRingtoneUrl) -> Self {
1460        Self { ringtone_url }
1461    }
1462
1463    /// Parses a ringtone request using [`PHONE_RINGTONE_MAX_BYTES`].
1464    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1465        #[derive(serde::Deserialize)]
1466        enum RingToneEnvelope {
1467            #[serde(rename = "setRingTone")]
1468            RingTone(CiscoIpPhoneSetRingTone),
1469        }
1470
1471        let RingToneEnvelope::RingTone(document) = from_bytes(document, PHONE_RINGTONE_MAX_BYTES)?;
1472        Ok(document)
1473    }
1474
1475    /// Serializes a ringtone request using [`PHONE_RINGTONE_MAX_BYTES`].
1476    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1477        self.to_xml_with_limit(PHONE_RINGTONE_MAX_BYTES)
1478    }
1479
1480    /// Serializes a ringtone request within a caller-selected byte limit.
1481    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
1482        to_string(self, maximum_bytes)
1483    }
1484}
1485
1486/// A rectangular selection region in a graphic-file menu.
1487#[derive(Clone, Copy, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1488#[serde(deny_unknown_fields)]
1489pub struct PhoneTouchArea {
1490    #[serde(rename = "@X1")]
1491    pub x1: u16,
1492    #[serde(rename = "@Y1")]
1493    pub y1: u16,
1494    #[serde(rename = "@X2")]
1495    pub x2: u16,
1496    #[serde(rename = "@Y2")]
1497    pub y2: u16,
1498}
1499
1500/// One optional label, action, and selection region in a graphic-file menu.
1501#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1502#[serde(deny_unknown_fields)]
1503pub struct CiscoIpPhoneTouchAreaMenuItem {
1504    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
1505    pub name: Option<String>,
1506    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
1507    pub url: Option<String>,
1508    #[serde(rename = "TouchArea", default, skip_serializing_if = "Option::is_none")]
1509    pub touch_area: Option<PhoneTouchArea>,
1510}
1511
1512/// A complete inline-bitmap image document.
1513#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1514#[serde(rename = "CiscoIPPhoneImage", deny_unknown_fields)]
1515pub struct CiscoIpPhoneImage {
1516    #[serde(
1517        rename = "@keypadTarget",
1518        default,
1519        skip_serializing_if = "Option::is_none"
1520    )]
1521    pub keypad_target: Option<PhoneKeypadTarget>,
1522    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
1523    pub application_id: Option<String>,
1524    #[serde(
1525        rename = "@onAppFocusLost",
1526        default,
1527        skip_serializing_if = "Option::is_none"
1528    )]
1529    pub on_focus_lost: Option<String>,
1530    #[serde(
1531        rename = "@onAppFocusGained",
1532        default,
1533        skip_serializing_if = "Option::is_none"
1534    )]
1535    pub on_focus_gained: Option<String>,
1536    #[serde(
1537        rename = "@onAppMinimized",
1538        default,
1539        skip_serializing_if = "Option::is_none"
1540    )]
1541    pub on_minimized: Option<String>,
1542    #[serde(
1543        rename = "@onAppClosed",
1544        default,
1545        skip_serializing_if = "Option::is_none"
1546    )]
1547    pub on_closed: Option<String>,
1548    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
1549    pub title: Option<String>,
1550    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
1551    pub prompt: Option<String>,
1552    #[serde(rename = "SoftKeyItem", default)]
1553    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
1554    #[serde(rename = "KeyItem", default)]
1555    pub key_items: Vec<CiscoIpPhoneKeyItem>,
1556    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
1557    /// Horizontal origin in `-1..=132`; `-1` requests automatic placement.
1558    pub location_x: Option<i16>,
1559    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1560    /// Vertical origin in `-1..=64`; `-1` requests automatic placement.
1561    pub location_y: Option<i16>,
1562    #[serde(rename = "Width")]
1563    /// Bitmap width in pixels, constrained to `1..=133`.
1564    pub width: u16,
1565    #[serde(rename = "Height")]
1566    /// Bitmap height in pixels, constrained to `1..=65`.
1567    pub height: u16,
1568    #[serde(rename = "Depth")]
1569    /// Bitmap bit depth, constrained to `1..=2`.
1570    pub depth: u16,
1571    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
1572    pub data: Option<PhoneBitmapData>,
1573}
1574
1575impl CiscoIpPhoneImage {
1576    /// Validates display metadata, pixel geometry, and decoded bitmap size.
1577    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1578        validate_image_display(
1579            self.title.as_deref(),
1580            self.prompt.as_deref(),
1581            self.application_id.as_deref(),
1582            [
1583                self.on_focus_lost.as_deref(),
1584                self.on_focus_gained.as_deref(),
1585                self.on_minimized.as_deref(),
1586                self.on_closed.as_deref(),
1587            ],
1588            &self.soft_keys,
1589            &self.key_items,
1590        )?;
1591        validate_bitmap_image(
1592            self.location_x,
1593            self.location_y,
1594            self.width,
1595            self.height,
1596            self.depth,
1597            self.data.as_ref(),
1598        )
1599    }
1600
1601    /// Detects, parses, and validates an image root within [`PHONE_IMAGE_MAX_BYTES`].
1602    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1603        #[derive(serde::Deserialize)]
1604        enum ImageEnvelope {
1605            #[serde(rename = "CiscoIPPhoneImage")]
1606            Image(CiscoIpPhoneImage),
1607        }
1608        let ImageEnvelope::Image(document) = from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
1609        document.validate()?;
1610        Ok(document)
1611    }
1612
1613    /// Validates and serializes the image within [`PHONE_IMAGE_MAX_BYTES`].
1614    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1615        self.validate()?;
1616        to_string(self, PHONE_IMAGE_MAX_BYTES)
1617    }
1618}
1619
1620/// A complete URL-backed image document.
1621#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1622#[serde(rename = "CiscoIPPhoneImageFile", deny_unknown_fields)]
1623pub struct CiscoIpPhoneImageFile {
1624    #[serde(
1625        rename = "@keypadTarget",
1626        default,
1627        skip_serializing_if = "Option::is_none"
1628    )]
1629    pub keypad_target: Option<PhoneKeypadTarget>,
1630    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
1631    pub application_id: Option<String>,
1632    #[serde(
1633        rename = "@onAppFocusLost",
1634        default,
1635        skip_serializing_if = "Option::is_none"
1636    )]
1637    pub on_focus_lost: Option<String>,
1638    #[serde(
1639        rename = "@onAppFocusGained",
1640        default,
1641        skip_serializing_if = "Option::is_none"
1642    )]
1643    pub on_focus_gained: Option<String>,
1644    #[serde(
1645        rename = "@onAppMinimized",
1646        default,
1647        skip_serializing_if = "Option::is_none"
1648    )]
1649    pub on_minimized: Option<String>,
1650    #[serde(
1651        rename = "@onAppClosed",
1652        default,
1653        skip_serializing_if = "Option::is_none"
1654    )]
1655    pub on_closed: Option<String>,
1656    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
1657    pub title: Option<String>,
1658    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
1659    pub prompt: Option<String>,
1660    #[serde(rename = "SoftKeyItem", default)]
1661    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
1662    #[serde(rename = "KeyItem", default)]
1663    pub key_items: Vec<CiscoIpPhoneKeyItem>,
1664    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
1665    /// Horizontal origin in `-1..=297`; `-1` requests automatic placement.
1666    pub location_x: Option<i16>,
1667    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1668    /// Vertical origin in `-1..=167`; `-1` requests automatic placement.
1669    pub location_y: Option<i16>,
1670    #[serde(rename = "URL")]
1671    pub url: PhoneImageUrl,
1672}
1673
1674impl CiscoIpPhoneImageFile {
1675    /// Validates display metadata and the optional image origin.
1676    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1677        validate_image_display(
1678            self.title.as_deref(),
1679            self.prompt.as_deref(),
1680            self.application_id.as_deref(),
1681            [
1682                self.on_focus_lost.as_deref(),
1683                self.on_focus_gained.as_deref(),
1684                self.on_minimized.as_deref(),
1685                self.on_closed.as_deref(),
1686            ],
1687            &self.soft_keys,
1688            &self.key_items,
1689        )?;
1690        validate_file_image_location(self.location_x, self.location_y)
1691    }
1692
1693    /// Detects, parses, and validates an image-file root within [`PHONE_IMAGE_MAX_BYTES`].
1694    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1695        #[derive(serde::Deserialize)]
1696        enum ImageFileEnvelope {
1697            #[serde(rename = "CiscoIPPhoneImageFile")]
1698            ImageFile(CiscoIpPhoneImageFile),
1699        }
1700        let ImageFileEnvelope::ImageFile(document) = from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
1701        document.validate()?;
1702        Ok(document)
1703    }
1704
1705    /// Validates and serializes the image-file document within its default bound.
1706    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1707        self.validate()?;
1708        to_string(self, PHONE_IMAGE_MAX_BYTES)
1709    }
1710}
1711
1712/// A complete inline-bitmap graphic menu document.
1713#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1714#[serde(rename = "CiscoIPPhoneGraphicMenu", deny_unknown_fields)]
1715pub struct CiscoIpPhoneGraphicMenu {
1716    #[serde(
1717        rename = "@keypadTarget",
1718        default,
1719        skip_serializing_if = "Option::is_none"
1720    )]
1721    pub keypad_target: Option<PhoneKeypadTarget>,
1722    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
1723    pub application_id: Option<String>,
1724    #[serde(
1725        rename = "@onAppFocusLost",
1726        default,
1727        skip_serializing_if = "Option::is_none"
1728    )]
1729    pub on_focus_lost: Option<String>,
1730    #[serde(
1731        rename = "@onAppFocusGained",
1732        default,
1733        skip_serializing_if = "Option::is_none"
1734    )]
1735    pub on_focus_gained: Option<String>,
1736    #[serde(
1737        rename = "@onAppMinimized",
1738        default,
1739        skip_serializing_if = "Option::is_none"
1740    )]
1741    pub on_minimized: Option<String>,
1742    #[serde(
1743        rename = "@onAppClosed",
1744        default,
1745        skip_serializing_if = "Option::is_none"
1746    )]
1747    pub on_closed: Option<String>,
1748    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
1749    pub title: Option<String>,
1750    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
1751    pub prompt: Option<String>,
1752    #[serde(rename = "SoftKeyItem", default)]
1753    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
1754    #[serde(rename = "KeyItem", default)]
1755    pub key_items: Vec<CiscoIpPhoneKeyItem>,
1756    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
1757    /// Horizontal origin in `-1..=132`; `-1` requests automatic placement.
1758    pub location_x: Option<i16>,
1759    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1760    /// Vertical origin in `-1..=64`; `-1` requests automatic placement.
1761    pub location_y: Option<i16>,
1762    #[serde(rename = "Width")]
1763    /// Bitmap width in pixels, constrained to `1..=133`.
1764    pub width: u16,
1765    #[serde(rename = "Height")]
1766    /// Bitmap height in pixels, constrained to `1..=65`.
1767    pub height: u16,
1768    #[serde(rename = "Depth")]
1769    /// Bitmap bit depth, constrained to `1..=2`.
1770    pub depth: u16,
1771    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
1772    pub data: Option<PhoneBitmapData>,
1773    #[serde(rename = "MenuItem", default)]
1774    pub items: Vec<CiscoIpPhoneMenuItem>,
1775}
1776
1777impl CiscoIpPhoneGraphicMenu {
1778    /// Validates display metadata, bitmap geometry, and selectable-item bounds.
1779    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1780        validate_image_display(
1781            self.title.as_deref(),
1782            self.prompt.as_deref(),
1783            self.application_id.as_deref(),
1784            [
1785                self.on_focus_lost.as_deref(),
1786                self.on_focus_gained.as_deref(),
1787                self.on_minimized.as_deref(),
1788                self.on_closed.as_deref(),
1789            ],
1790            &self.soft_keys,
1791            &self.key_items,
1792        )?;
1793        validate_bitmap_image(
1794            self.location_x,
1795            self.location_y,
1796            self.width,
1797            self.height,
1798            self.depth,
1799            self.data.as_ref(),
1800        )?;
1801        validate_count(
1802            "graphic menu items",
1803            self.items.len(),
1804            PHONE_GRAPHIC_MENU_MAX_ITEMS,
1805        )?;
1806        for item in &self.items {
1807            validate_optional_text("graphic menu item name", item.name.as_deref(), 0, 64)?;
1808            validate_optional_text(
1809                "graphic menu item URL",
1810                item.url.as_deref(),
1811                0,
1812                PHONE_XML_URL_MAX_CHARS,
1813            )?;
1814        }
1815        Ok(())
1816    }
1817
1818    /// Detects, parses, and validates a graphic menu within [`PHONE_IMAGE_MAX_BYTES`].
1819    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1820        #[derive(serde::Deserialize)]
1821        enum GraphicMenuEnvelope {
1822            #[serde(rename = "CiscoIPPhoneGraphicMenu")]
1823            GraphicMenu(CiscoIpPhoneGraphicMenu),
1824        }
1825        let GraphicMenuEnvelope::GraphicMenu(document) =
1826            from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
1827        document.validate()?;
1828        Ok(document)
1829    }
1830
1831    /// Validates and serializes the graphic menu within its default bound.
1832    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1833        self.validate()?;
1834        to_string(self, PHONE_IMAGE_MAX_BYTES)
1835    }
1836}
1837
1838/// A complete URL-backed graphic menu document.
1839#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
1840#[serde(rename = "CiscoIPPhoneGraphicFileMenu", deny_unknown_fields)]
1841pub struct CiscoIpPhoneGraphicFileMenu {
1842    #[serde(
1843        rename = "@keypadTarget",
1844        default,
1845        skip_serializing_if = "Option::is_none"
1846    )]
1847    pub keypad_target: Option<PhoneKeypadTarget>,
1848    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
1849    pub application_id: Option<String>,
1850    #[serde(
1851        rename = "@onAppFocusLost",
1852        default,
1853        skip_serializing_if = "Option::is_none"
1854    )]
1855    pub on_focus_lost: Option<String>,
1856    #[serde(
1857        rename = "@onAppFocusGained",
1858        default,
1859        skip_serializing_if = "Option::is_none"
1860    )]
1861    pub on_focus_gained: Option<String>,
1862    #[serde(
1863        rename = "@onAppMinimized",
1864        default,
1865        skip_serializing_if = "Option::is_none"
1866    )]
1867    pub on_minimized: Option<String>,
1868    #[serde(
1869        rename = "@onAppClosed",
1870        default,
1871        skip_serializing_if = "Option::is_none"
1872    )]
1873    pub on_closed: Option<String>,
1874    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
1875    pub title: Option<String>,
1876    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
1877    pub prompt: Option<String>,
1878    #[serde(rename = "SoftKeyItem", default)]
1879    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
1880    #[serde(rename = "KeyItem", default)]
1881    pub key_items: Vec<CiscoIpPhoneKeyItem>,
1882    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
1883    /// Horizontal origin in `-1..=297`; `-1` requests automatic placement.
1884    pub location_x: Option<i16>,
1885    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1886    /// Vertical origin in `-1..=167`; `-1` requests automatic placement.
1887    pub location_y: Option<i16>,
1888    #[serde(rename = "URL")]
1889    pub url: PhoneImageUrl,
1890    #[serde(rename = "MenuItem", default)]
1891    pub items: Vec<CiscoIpPhoneTouchAreaMenuItem>,
1892}
1893
1894impl CiscoIpPhoneGraphicFileMenu {
1895    /// Validates display metadata, image origin, and touch-area item bounds.
1896    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1897        validate_image_display(
1898            self.title.as_deref(),
1899            self.prompt.as_deref(),
1900            self.application_id.as_deref(),
1901            [
1902                self.on_focus_lost.as_deref(),
1903                self.on_focus_gained.as_deref(),
1904                self.on_minimized.as_deref(),
1905                self.on_closed.as_deref(),
1906            ],
1907            &self.soft_keys,
1908            &self.key_items,
1909        )?;
1910        validate_file_image_location(self.location_x, self.location_y)?;
1911        validate_count(
1912            "graphic-file menu items",
1913            self.items.len(),
1914            PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS,
1915        )?;
1916        for item in &self.items {
1917            validate_optional_text("graphic-file menu item name", item.name.as_deref(), 0, 32)?;
1918            validate_optional_text(
1919                "graphic-file menu item URL",
1920                item.url.as_deref(),
1921                0,
1922                PHONE_XML_URL_MAX_CHARS,
1923            )?;
1924        }
1925        Ok(())
1926    }
1927
1928    /// Detects, parses, and validates a graphic-file menu within its default bound.
1929    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1930        #[derive(serde::Deserialize)]
1931        enum GraphicFileMenuEnvelope {
1932            #[serde(rename = "CiscoIPPhoneGraphicFileMenu")]
1933            GraphicFileMenu(CiscoIpPhoneGraphicFileMenu),
1934        }
1935        let GraphicFileMenuEnvelope::GraphicFileMenu(document) =
1936            from_bytes(document, PHONE_IMAGE_MAX_BYTES)?;
1937        document.validate()?;
1938        Ok(document)
1939    }
1940
1941    /// Validates and serializes the graphic-file menu within its default bound.
1942    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1943        self.validate()?;
1944        to_string(self, PHONE_IMAGE_MAX_BYTES)
1945    }
1946}
1947
1948/// Any accepted image-service document family.
1949#[derive(Clone, Debug, Eq, PartialEq)]
1950pub enum PhoneImageDocument {
1951    Image(CiscoIpPhoneImage),
1952    ImageFile(CiscoIpPhoneImageFile),
1953    GraphicMenu(CiscoIpPhoneGraphicMenu),
1954    GraphicFileMenu(CiscoIpPhoneGraphicFileMenu),
1955}
1956
1957impl PhoneImageDocument {
1958    /// Applies the invariants for the selected image family.
1959    pub fn validate(&self) -> Result<(), PhoneXmlError> {
1960        match self {
1961            Self::Image(document) => document.validate(),
1962            Self::ImageFile(document) => document.validate(),
1963            Self::GraphicMenu(document) => document.validate(),
1964            Self::GraphicFileMenu(document) => document.validate(),
1965        }
1966    }
1967
1968    /// Detects the root and parses any supported image family.
1969    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1970        #[derive(serde::Deserialize)]
1971        enum ImageDocumentEnvelope {
1972            #[serde(rename = "CiscoIPPhoneImage")]
1973            Image(CiscoIpPhoneImage),
1974            #[serde(rename = "CiscoIPPhoneImageFile")]
1975            ImageFile(CiscoIpPhoneImageFile),
1976            #[serde(rename = "CiscoIPPhoneGraphicMenu")]
1977            GraphicMenu(CiscoIpPhoneGraphicMenu),
1978            #[serde(rename = "CiscoIPPhoneGraphicFileMenu")]
1979            GraphicFileMenu(CiscoIpPhoneGraphicFileMenu),
1980        }
1981        let document = match from_bytes(document, PHONE_IMAGE_MAX_BYTES)? {
1982            ImageDocumentEnvelope::Image(document) => Self::Image(document),
1983            ImageDocumentEnvelope::ImageFile(document) => Self::ImageFile(document),
1984            ImageDocumentEnvelope::GraphicMenu(document) => Self::GraphicMenu(document),
1985            ImageDocumentEnvelope::GraphicFileMenu(document) => Self::GraphicFileMenu(document),
1986        };
1987        document.validate()?;
1988        Ok(document)
1989    }
1990
1991    /// Validates and serializes the selected family using [`PHONE_IMAGE_MAX_BYTES`].
1992    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1993        self.to_xml_with_limit(PHONE_IMAGE_MAX_BYTES)
1994    }
1995
1996    /// Validates and serializes the selected family within a caller-selected limit.
1997    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
1998        self.validate()?;
1999        match self {
2000            Self::Image(document) => to_string(document, maximum_bytes),
2001            Self::ImageFile(document) => to_string(document, maximum_bytes),
2002            Self::GraphicMenu(document) => to_string(document, maximum_bytes),
2003            Self::GraphicFileMenu(document) => to_string(document, maximum_bytes),
2004        }
2005    }
2006}
2007
2008/// A bitmap-backed status item displayed in the phone's status area.
2009#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
2010#[serde(rename = "CiscoIPPhoneStatus", deny_unknown_fields)]
2011pub struct CiscoIpPhoneStatus {
2012    #[serde(rename = "Text", default, skip_serializing_if = "Option::is_none")]
2013    pub text: Option<String>,
2014    #[serde(rename = "Timer", default, skip_serializing_if = "Option::is_none")]
2015    /// Duration in seconds; omission leaves display lifetime device-defined.
2016    pub timer_seconds: Option<u16>,
2017    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
2018    /// Horizontal origin in `-1..=105`; `-1` requests automatic placement.
2019    pub location_x: Option<i16>,
2020    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
2021    /// Vertical origin in `-1..=20`; `-1` requests automatic placement.
2022    pub location_y: Option<i16>,
2023    #[serde(rename = "Width")]
2024    /// Bitmap width in pixels, constrained to `1..=106`.
2025    pub width: u16,
2026    #[serde(rename = "Height")]
2027    /// Bitmap height in pixels, constrained to `1..=21`.
2028    pub height: u16,
2029    #[serde(rename = "Depth")]
2030    /// Bitmap bit depth, constrained to `1..=2`.
2031    pub depth: u16,
2032    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
2033    pub data: Option<PhoneBitmapData>,
2034}
2035
2036impl CiscoIpPhoneStatus {
2037    /// Validates text, display geometry, and the status-specific bitmap bound.
2038    pub fn validate(&self) -> Result<(), PhoneXmlError> {
2039        validate_optional_text("phone status text", self.text.as_deref(), 0, 32)?;
2040        if self
2041            .location_x
2042            .is_some_and(|value| !(-1..=105).contains(&value))
2043        {
2044            return Err(PhoneXmlError::InvalidField {
2045                field: "phone status horizontal location",
2046                expected: "between -1 and 105",
2047            });
2048        }
2049        if self
2050            .location_y
2051            .is_some_and(|value| !(-1..=20).contains(&value))
2052        {
2053            return Err(PhoneXmlError::InvalidField {
2054                field: "phone status vertical location",
2055                expected: "between -1 and 20",
2056            });
2057        }
2058        if !(1..=106).contains(&self.width) {
2059            return Err(PhoneXmlError::InvalidField {
2060                field: "phone status width",
2061                expected: "between 1 and 106",
2062            });
2063        }
2064        if !(1..=21).contains(&self.height) {
2065            return Err(PhoneXmlError::InvalidField {
2066                field: "phone status height",
2067                expected: "between 1 and 21",
2068            });
2069        }
2070        if !(1..=2).contains(&self.depth) {
2071            return Err(PhoneXmlError::InvalidField {
2072                field: "phone status depth",
2073                expected: "between 1 and 2",
2074            });
2075        }
2076        if let Some(data) = &self.data {
2077            validate_count(
2078                "phone status bitmap bytes",
2079                data.as_bytes().len(),
2080                PHONE_STATUS_BITMAP_MAX_BYTES,
2081            )?;
2082        }
2083        Ok(())
2084    }
2085
2086    /// Parses and validates an inline status item using [`PHONE_STATUS_MAX_BYTES`].
2087    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2088        <Self as PhoneXmlDocument>::parse_xml(document)
2089    }
2090
2091    /// Parses an inline status item with an additional caller-selected byte limit.
2092    pub fn from_xml_with_limit(
2093        document: &[u8],
2094        maximum_bytes: usize,
2095    ) -> Result<Self, PhoneXmlError> {
2096        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
2097    }
2098
2099    /// Validates and serializes the status item using [`PHONE_STATUS_MAX_BYTES`].
2100    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2101        <Self as PhoneXmlDocument>::serialize_xml(self)
2102    }
2103
2104    /// Validates and serializes the status item within a caller-selected limit.
2105    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
2106        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
2107    }
2108}
2109
2110/// A URL-backed status item displayed in the phone's status area.
2111#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
2112#[serde(rename = "CiscoIPPhoneStatusFile", deny_unknown_fields)]
2113pub struct CiscoIpPhoneStatusFile {
2114    #[serde(rename = "Text", default, skip_serializing_if = "Option::is_none")]
2115    pub text: Option<String>,
2116    #[serde(rename = "Timer", default, skip_serializing_if = "Option::is_none")]
2117    /// Duration in seconds; omission leaves display lifetime device-defined.
2118    pub timer_seconds: Option<u16>,
2119    #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
2120    /// Horizontal origin in `-1..=261`; `-1` requests automatic placement.
2121    pub location_x: Option<i16>,
2122    #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
2123    /// Vertical origin in `-1..=49`; `-1` requests automatic placement.
2124    pub location_y: Option<i16>,
2125    #[serde(rename = "URL")]
2126    pub url: PhoneImageUrl,
2127}
2128
2129impl CiscoIpPhoneStatusFile {
2130    /// Validates text and the optional referenced-image origin.
2131    pub fn validate(&self) -> Result<(), PhoneXmlError> {
2132        validate_optional_text("phone status text", self.text.as_deref(), 0, 32)?;
2133        if self
2134            .location_x
2135            .is_some_and(|value| !(-1..=261).contains(&value))
2136        {
2137            return Err(PhoneXmlError::InvalidField {
2138                field: "phone status-file horizontal location",
2139                expected: "between -1 and 261",
2140            });
2141        }
2142        if self
2143            .location_y
2144            .is_some_and(|value| !(-1..=49).contains(&value))
2145        {
2146            return Err(PhoneXmlError::InvalidField {
2147                field: "phone status-file vertical location",
2148                expected: "between -1 and 49",
2149            });
2150        }
2151        Ok(())
2152    }
2153
2154    /// Parses and validates a referenced status item using its default byte bound.
2155    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2156        <Self as PhoneXmlDocument>::parse_xml(document)
2157    }
2158
2159    /// Parses a referenced status item with an additional caller-selected limit.
2160    pub fn from_xml_with_limit(
2161        document: &[u8],
2162        maximum_bytes: usize,
2163    ) -> Result<Self, PhoneXmlError> {
2164        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
2165    }
2166
2167    /// Validates and serializes the status item using [`PHONE_STATUS_MAX_BYTES`].
2168    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2169        <Self as PhoneXmlDocument>::serialize_xml(self)
2170    }
2171
2172    /// Validates and serializes the status item within a caller-selected limit.
2173    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
2174        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
2175    }
2176}
2177
2178/// Either accepted status-service document family.
2179#[derive(Clone, Debug, Eq, PartialEq)]
2180pub enum PhoneStatusDocument {
2181    Bitmap(CiscoIpPhoneStatus),
2182    File(CiscoIpPhoneStatusFile),
2183}
2184
2185impl PhoneStatusDocument {
2186    /// Applies the invariants for the selected status family.
2187    pub fn validate(&self) -> Result<(), PhoneXmlError> {
2188        match self {
2189            Self::Bitmap(document) => document.validate(),
2190            Self::File(document) => document.validate(),
2191        }
2192    }
2193
2194    /// Detects the root and parses either supported status family.
2195    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2196        #[derive(serde::Deserialize)]
2197        enum StatusDocumentEnvelope {
2198            #[serde(rename = "CiscoIPPhoneStatus")]
2199            Bitmap(CiscoIpPhoneStatus),
2200            #[serde(rename = "CiscoIPPhoneStatusFile")]
2201            File(CiscoIpPhoneStatusFile),
2202        }
2203        let document = match from_bytes(document, PHONE_STATUS_MAX_BYTES)? {
2204            StatusDocumentEnvelope::Bitmap(document) => Self::Bitmap(document),
2205            StatusDocumentEnvelope::File(document) => Self::File(document),
2206        };
2207        document.validate()?;
2208        Ok(document)
2209    }
2210
2211    /// Validates and serializes the selected family using [`PHONE_STATUS_MAX_BYTES`].
2212    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2213        self.to_xml_with_limit(PHONE_STATUS_MAX_BYTES)
2214    }
2215
2216    /// Validates and serializes the selected family within a caller-selected limit.
2217    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
2218        self.validate()?;
2219        match self {
2220            Self::Bitmap(document) => to_string(document, maximum_bytes),
2221            Self::File(document) => to_string(document, maximum_bytes),
2222        }
2223    }
2224}
2225
2226const LAST_OUT_OF_SERVICE_ALARM: &str = "LastOutOfServiceInformation";
2227
2228/// One named string value in a phone alarm parameter list.
2229#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2230#[serde(deny_unknown_fields)]
2231pub struct CiscoIpPhoneAlarmString {
2232    #[serde(rename = "@name")]
2233    pub name: String,
2234    #[serde(rename = "$text", default)]
2235    /// Free-form value omitted from [`Debug`](std::fmt::Debug) output.
2236    pub value: String,
2237}
2238
2239impl fmt::Debug for CiscoIpPhoneAlarmString {
2240    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2241        formatter
2242            .debug_struct("CiscoIpPhoneAlarmString")
2243            .field("name", &self.name)
2244            .field("value", &"<redacted>")
2245            .finish()
2246    }
2247}
2248
2249/// One named numeric enumeration in a phone alarm parameter list.
2250#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2251#[serde(deny_unknown_fields)]
2252pub struct CiscoIpPhoneAlarmEnum {
2253    #[serde(rename = "@name")]
2254    pub name: String,
2255    #[serde(rename = "$text")]
2256    pub value: i32,
2257}
2258
2259impl fmt::Debug for CiscoIpPhoneAlarmEnum {
2260    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2261        formatter
2262            .debug_struct("CiscoIpPhoneAlarmEnum")
2263            .field("name", &self.name)
2264            .field("value", &self.value)
2265            .finish()
2266    }
2267}
2268
2269/// An ordered, typed alarm parameter.
2270#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2271pub enum CiscoIpPhoneAlarmParameter {
2272    /// Textual parameter whose value remains redacted in diagnostics.
2273    #[serde(rename = "String")]
2274    String(CiscoIpPhoneAlarmString),
2275    /// Numeric parameter safe for typed summaries when explicitly allowlisted.
2276    #[serde(rename = "Enum")]
2277    Enum(CiscoIpPhoneAlarmEnum),
2278}
2279
2280impl fmt::Debug for CiscoIpPhoneAlarmParameter {
2281    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2282        match self {
2283            Self::String(value) => value.fmt(formatter),
2284            Self::Enum(value) => value.fmt(formatter),
2285        }
2286    }
2287}
2288
2289/// Ordered parameters carried by a supported phone alarm.
2290#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2291#[serde(deny_unknown_fields)]
2292pub struct CiscoIpPhoneAlarmParameterList {
2293    #[serde(rename = "$value", default)]
2294    pub parameters: Vec<CiscoIpPhoneAlarmParameter>,
2295}
2296
2297impl fmt::Debug for CiscoIpPhoneAlarmParameterList {
2298    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2299        formatter
2300            .debug_struct("CiscoIpPhoneAlarmParameterList")
2301            .field("parameter_count", &self.parameters.len())
2302            .finish()
2303    }
2304}
2305
2306/// The single supported alarm entry within an alarm document.
2307#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2308#[serde(deny_unknown_fields)]
2309pub struct CiscoIpPhoneAlarmEntry {
2310    #[serde(rename = "@Name")]
2311    pub name: String,
2312    #[serde(rename = "ParameterList")]
2313    pub parameter_list: CiscoIpPhoneAlarmParameterList,
2314}
2315
2316impl fmt::Debug for CiscoIpPhoneAlarmEntry {
2317    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2318        formatter
2319            .debug_struct("CiscoIpPhoneAlarmEntry")
2320            .field("name", &self.name)
2321            .field("parameter_count", &self.parameter_list.parameters.len())
2322            .finish()
2323    }
2324}
2325
2326/// A typed `LastOutOfServiceInformation` alarm document.
2327#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2328#[serde(rename = "x-cisco-alarm", deny_unknown_fields)]
2329pub struct CiscoIpPhoneAlarm {
2330    #[serde(rename = "Alarm")]
2331    pub alarm: CiscoIpPhoneAlarmEntry,
2332}
2333
2334impl fmt::Debug for CiscoIpPhoneAlarm {
2335    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2336        formatter
2337            .debug_struct("CiscoIpPhoneAlarm")
2338            .field("name", &self.alarm.name)
2339            .field(
2340                "parameter_count",
2341                &self.alarm.parameter_list.parameters.len(),
2342            )
2343            .finish()
2344    }
2345}
2346
2347impl CiscoIpPhoneAlarm {
2348    /// Checks the supported alarm name and uniqueness/bounds of all parameters.
2349    pub fn validate(&self) -> Result<(), PhoneXmlError> {
2350        if self.alarm.name != LAST_OUT_OF_SERVICE_ALARM {
2351            return Err(PhoneXmlError::InvalidField {
2352                field: "phone alarm name",
2353                expected: "LastOutOfServiceInformation",
2354            });
2355        }
2356        let mut names = HashSet::new();
2357        for parameter in &self.alarm.parameter_list.parameters {
2358            let name = match parameter {
2359                CiscoIpPhoneAlarmParameter::String(value) => {
2360                    validate_optional_text(
2361                        "phone alarm string name",
2362                        Some(&value.name),
2363                        1,
2364                        PHONE_ALARM_MAX_BYTES,
2365                    )?;
2366                    validate_optional_text(
2367                        "phone alarm string value",
2368                        Some(&value.value),
2369                        0,
2370                        PHONE_ALARM_MAX_BYTES,
2371                    )?;
2372                    &value.name
2373                }
2374                CiscoIpPhoneAlarmParameter::Enum(value) => {
2375                    validate_optional_text(
2376                        "phone alarm enumeration name",
2377                        Some(&value.name),
2378                        1,
2379                        PHONE_ALARM_MAX_BYTES,
2380                    )?;
2381                    &value.name
2382                }
2383            };
2384            if !names.insert(name.as_str()) {
2385                return Err(PhoneXmlError::InvalidField {
2386                    field: "phone alarm parameter names",
2387                    expected: "unique across string and enumeration parameters",
2388                });
2389            }
2390        }
2391        Ok(())
2392    }
2393
2394    /// Parses the supported alarm schema while redacting schema-error details.
2395    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2396        #[derive(serde::Deserialize)]
2397        enum AlarmEnvelope {
2398            #[serde(rename = "x-cisco-alarm")]
2399            Alarm(CiscoIpPhoneAlarm),
2400        }
2401        let AlarmEnvelope::Alarm(document) =
2402            from_bytes(document, PHONE_ALARM_MAX_BYTES).map_err(redact_alarm_schema_error)?;
2403        document.validate()?;
2404        Ok(document)
2405    }
2406
2407    /// Validates and serializes the alarm within [`PHONE_ALARM_MAX_BYTES`].
2408    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2409        self.validate()?;
2410        to_string(self, PHONE_ALARM_MAX_BYTES)
2411    }
2412
2413    /// Returns a textual parameter by name without exposing it through diagnostics.
2414    pub fn string(&self, name: &str) -> Option<&str> {
2415        self.alarm
2416            .parameter_list
2417            .parameters
2418            .iter()
2419            .find_map(|parameter| match parameter {
2420                CiscoIpPhoneAlarmParameter::String(value) if value.name == name => {
2421                    Some(value.value.as_str())
2422                }
2423                _ => None,
2424            })
2425    }
2426
2427    /// Returns a numeric parameter by name.
2428    pub fn enumeration(&self, name: &str) -> Option<i32> {
2429        self.alarm
2430            .parameter_list
2431            .parameters
2432            .iter()
2433            .find_map(|parameter| match parameter {
2434                CiscoIpPhoneAlarmParameter::Enum(value) if value.name == name => Some(value.value),
2435                _ => None,
2436            })
2437    }
2438
2439    /// Returns the allowlisted numeric out-of-service reason, when present.
2440    pub fn reason_for_out_of_service(&self) -> Option<i32> {
2441        self.enumeration("ReasonForOutOfService")
2442    }
2443}
2444
2445/// Exact bounded bytes for a syntactically valid but unsupported alarm schema.
2446#[derive(Clone, Eq, PartialEq)]
2447pub struct OpaquePhoneAlarm(Vec<u8>);
2448
2449impl OpaquePhoneAlarm {
2450    pub fn as_bytes(&self) -> &[u8] {
2451        &self.0
2452    }
2453
2454    pub fn into_bytes(self) -> Vec<u8> {
2455        self.0
2456    }
2457}
2458
2459impl fmt::Debug for OpaquePhoneAlarm {
2460    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2461        formatter
2462            .debug_struct("OpaquePhoneAlarm")
2463            .field("byte_count", &self.0.len())
2464            .finish_non_exhaustive()
2465    }
2466}
2467
2468/// Parsed alarm telemetry or a bounded lossless unknown schema.
2469#[derive(Clone, Eq, PartialEq)]
2470pub enum PhoneAlarmTelemetry {
2471    /// The supported out-of-service schema, retained as typed parameters.
2472    LastOutOfService(CiscoIpPhoneAlarm),
2473    /// A syntactically valid unsupported schema retained losslessly.
2474    Opaque(OpaquePhoneAlarm),
2475}
2476
2477/// Allowlisted alarm family that is safe to publish without parameter data.
2478#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2479pub enum PhoneAlarmKind {
2480    LastOutOfService,
2481}
2482
2483/// Secret-safe fields selected from a known alarm document.
2484#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2485pub struct PhoneAlarmSummary {
2486    pub kind: PhoneAlarmKind,
2487    /// Optional numeric reason; no free-form parameter data is published.
2488    pub reason_for_out_of_service: Option<i32>,
2489}
2490
2491impl fmt::Debug for PhoneAlarmTelemetry {
2492    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2493        match self {
2494            Self::LastOutOfService(alarm) => alarm.fmt(formatter),
2495            Self::Opaque(alarm) => alarm.fmt(formatter),
2496        }
2497    }
2498}
2499
2500impl PhoneAlarmTelemetry {
2501    /// Returns only allowlisted numeric fields for known alarm schemas.
2502    /// Opaque schemas never produce a publishable summary.
2503    pub fn summary(&self) -> Option<PhoneAlarmSummary> {
2504        match self {
2505            Self::LastOutOfService(alarm) => Some(PhoneAlarmSummary {
2506                kind: PhoneAlarmKind::LastOutOfService,
2507                reason_for_out_of_service: alarm.reason_for_out_of_service(),
2508            }),
2509            Self::Opaque(_) => None,
2510        }
2511    }
2512
2513    pub fn is_opaque(&self) -> bool {
2514        matches!(self, Self::Opaque(_))
2515    }
2516}
2517
2518/// Parse one bounded alarm document without treating malformed known XML as
2519/// an opaque schema.
2520pub fn parse_phone_alarm(document: &[u8]) -> Result<PhoneAlarmTelemetry, PhoneXmlError> {
2521    #[derive(Debug, serde::Deserialize)]
2522    struct AlarmProbe {
2523        #[serde(rename = "Alarm", default)]
2524        alarms: Vec<AlarmNameProbe>,
2525    }
2526
2527    #[derive(Debug, serde::Deserialize)]
2528    struct AlarmNameProbe {
2529        #[serde(rename = "@Name")]
2530        name: String,
2531    }
2532
2533    #[derive(serde::Deserialize)]
2534    enum AlarmProbeEnvelope {
2535        #[serde(rename = "x-cisco-alarm")]
2536        Alarm(AlarmProbe),
2537        #[serde(other)]
2538        Unknown,
2539    }
2540
2541    let supported = match from_bytes(document, PHONE_ALARM_MAX_BYTES)
2542        .map_err(redact_alarm_schema_error)?
2543    {
2544        AlarmProbeEnvelope::Alarm(probe) => {
2545            matches!(probe.alarms.as_slice(), [alarm] if alarm.name == LAST_OUT_OF_SERVICE_ALARM)
2546        }
2547        AlarmProbeEnvelope::Unknown => false,
2548    };
2549    if supported {
2550        CiscoIpPhoneAlarm::from_xml(document).map(PhoneAlarmTelemetry::LastOutOfService)
2551    } else {
2552        Ok(PhoneAlarmTelemetry::Opaque(OpaquePhoneAlarm(
2553            document.to_vec(),
2554        )))
2555    }
2556}
2557
2558fn redact_alarm_schema_error(error: PhoneXmlError) -> PhoneXmlError {
2559    match error {
2560        PhoneXmlError::Deserialize(_) => PhoneXmlError::InvalidAlarmSchema,
2561        error => error,
2562    }
2563}
2564
2565/// A six-octet wireless basic-service-set address with redacted diagnostics.
2566#[derive(Clone, Copy, Eq, Hash, PartialEq)]
2567pub struct PhoneBssid([u8; 6]);
2568
2569impl PhoneBssid {
2570    /// Wraps the exact six address octets.
2571    pub const fn from_octets(octets: [u8; 6]) -> Self {
2572        Self(octets)
2573    }
2574
2575    pub const fn octets(self) -> [u8; 6] {
2576        self.0
2577    }
2578
2579    /// Parses six colon-separated hexadecimal octets.
2580    pub fn parse(value: &str) -> Result<Self, PhoneXmlError> {
2581        parse_bssid(value).ok_or(PhoneXmlError::InvalidField {
2582            field: "phone location BSSID",
2583            expected: "six hexadecimal octets separated by colons",
2584        })
2585    }
2586}
2587
2588impl fmt::Display for PhoneBssid {
2589    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2590        write!(
2591            formatter,
2592            "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
2593            self.0[0], self.0[1], self.0[2], self.0[3], self.0[4], self.0[5]
2594        )
2595    }
2596}
2597
2598impl fmt::Debug for PhoneBssid {
2599    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2600        formatter.write_str("PhoneBssid(<redacted>)")
2601    }
2602}
2603
2604impl Serialize for PhoneBssid {
2605    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2606    where
2607        S: serde::Serializer,
2608    {
2609        serializer.serialize_str(&self.to_string())
2610    }
2611}
2612
2613impl<'de> serde::Deserialize<'de> for PhoneBssid {
2614    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2615    where
2616        D: serde::Deserializer<'de>,
2617    {
2618        let value = String::deserialize(deserializer)?;
2619        parse_bssid(&value).map_or_else(
2620            || {
2621                Err(serde::de::Error::custom(
2622                    "BSSID must contain six hexadecimal octets separated by colons",
2623                ))
2624            },
2625            Ok,
2626        )
2627    }
2628}
2629
2630fn parse_bssid(value: &str) -> Option<PhoneBssid> {
2631    let mut octets = [0u8; 6];
2632    let mut components = value.split(':');
2633    for octet in &mut octets {
2634        let component = components.next()?;
2635        if component.len() != 2 {
2636            return None;
2637        }
2638        *octet = u8::from_str_radix(component, 16).ok()?;
2639    }
2640    components.next().is_none().then_some(PhoneBssid(octets))
2641}
2642
2643/// Wireless location fields reported for the phone's first interface.
2644///
2645/// Diagnostics expose only lengths and never the address or network names.
2646#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2647#[serde(deny_unknown_fields)]
2648pub struct CiscoIpPhoneWifiLocation {
2649    #[serde(rename = "BSSID")]
2650    pub bssid: PhoneBssid,
2651    #[serde(rename = "SSID")]
2652    pub ssid: String,
2653    #[serde(rename = "APName")]
2654    pub access_point_name: String,
2655}
2656
2657impl fmt::Debug for CiscoIpPhoneWifiLocation {
2658    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2659        formatter
2660            .debug_struct("CiscoIpPhoneWifiLocation")
2661            .field("bssid", &self.bssid)
2662            .field("ssid_byte_count", &self.ssid.len())
2663            .field(
2664                "access_point_name_char_count",
2665                &self.access_point_name.chars().count(),
2666            )
2667            .finish()
2668    }
2669}
2670
2671/// Empty marker indicating that the phone considers itself off premises.
2672#[derive(Clone, Default, serde::Deserialize, Eq, PartialEq, Serialize)]
2673#[serde(deny_unknown_fields)]
2674pub struct CiscoIpPhoneOffPremises {
2675    #[serde(rename = "$text", default, skip_serializing_if = "String::is_empty")]
2676    marker: String,
2677}
2678
2679impl fmt::Debug for CiscoIpPhoneOffPremises {
2680    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2681        formatter.write_str("CiscoIpPhoneOffPremises")
2682    }
2683}
2684
2685impl CiscoIpPhoneOffPremises {
2686    /// Creates the required empty marker element.
2687    pub const fn new() -> Self {
2688        Self {
2689            marker: String::new(),
2690        }
2691    }
2692
2693    fn validate(&self) -> Result<(), PhoneXmlError> {
2694        if self.marker.is_empty() {
2695            Ok(())
2696        } else {
2697            Err(PhoneXmlError::InvalidField {
2698                field: "phone off-premises marker",
2699                expected: "an empty element",
2700            })
2701        }
2702    }
2703}
2704
2705/// Typed wireless location-information document for interface one.
2706///
2707/// Diagnostics retain only the off-premises flag and redacted wireless data.
2708#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2709#[serde(rename = "Interface1", deny_unknown_fields)]
2710pub struct CiscoIpPhoneLocationInformation {
2711    #[serde(rename = "wifi")]
2712    pub wifi: CiscoIpPhoneWifiLocation,
2713    #[serde(rename = "OffPrem", default, skip_serializing_if = "Option::is_none")]
2714    pub off_premises: Option<CiscoIpPhoneOffPremises>,
2715}
2716
2717impl fmt::Debug for CiscoIpPhoneLocationInformation {
2718    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2719        formatter
2720            .debug_struct("CiscoIpPhoneLocationInformation")
2721            .field("wifi", &self.wifi)
2722            .field("off_premises", &self.off_premises.is_some())
2723            .finish()
2724    }
2725}
2726
2727impl CiscoIpPhoneLocationInformation {
2728    /// Validates network-name bounds and the empty off-premises marker.
2729    pub fn validate(&self) -> Result<(), PhoneXmlError> {
2730        validate_optional_text(
2731            "phone location SSID",
2732            Some(&self.wifi.ssid),
2733            0,
2734            PHONE_LOCATION_MAX_BYTES,
2735        )?;
2736        validate_optional_text(
2737            "phone location access-point name",
2738            Some(&self.wifi.access_point_name),
2739            0,
2740            PHONE_LOCATION_MAX_BYTES,
2741        )?;
2742        if self.wifi.ssid.len() > 32 {
2743            return Err(PhoneXmlError::InvalidField {
2744                field: "phone location SSID",
2745                expected: "at most 32 bytes",
2746            });
2747        }
2748        if let Some(off_premises) = &self.off_premises {
2749            off_premises.validate()?;
2750        }
2751        Ok(())
2752    }
2753
2754    /// Parses the supported wireless-interface schema with redacted failures.
2755    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2756        #[derive(serde::Deserialize)]
2757        enum LocationEnvelope {
2758            #[serde(rename = "Interface1")]
2759            Location(CiscoIpPhoneLocationInformation),
2760        }
2761
2762        let LocationEnvelope::Location(location) =
2763            from_bytes(document, PHONE_LOCATION_MAX_BYTES).map_err(redact_location_schema_error)?;
2764        location.validate()?;
2765        Ok(location)
2766    }
2767
2768    /// Validates and serializes location telemetry within [`PHONE_LOCATION_MAX_BYTES`].
2769    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2770        self.validate()?;
2771        to_string(self, PHONE_LOCATION_MAX_BYTES)
2772    }
2773
2774    pub const fn is_off_premises(&self) -> bool {
2775        self.off_premises.is_some()
2776    }
2777}
2778
2779/// Exact bounded bytes for a syntactically valid unsupported location schema.
2780#[derive(Clone, Eq, PartialEq)]
2781pub struct OpaquePhoneLocation(Vec<u8>);
2782
2783impl OpaquePhoneLocation {
2784    pub fn as_bytes(&self) -> &[u8] {
2785        &self.0
2786    }
2787
2788    pub fn into_bytes(self) -> Vec<u8> {
2789        self.0
2790    }
2791}
2792
2793impl fmt::Debug for OpaquePhoneLocation {
2794    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2795        formatter
2796            .debug_struct("OpaquePhoneLocation")
2797            .field("byte_count", &self.0.len())
2798            .finish_non_exhaustive()
2799    }
2800}
2801
2802/// Parsed location telemetry or a bounded lossless unsupported schema.
2803#[derive(Clone, Eq, PartialEq)]
2804pub enum PhoneLocationTelemetry {
2805    /// The supported wireless-interface schema.
2806    WirelessInterface(CiscoIpPhoneLocationInformation),
2807    /// A syntactically valid unsupported schema retained losslessly.
2808    Opaque(OpaquePhoneLocation),
2809}
2810
2811impl fmt::Debug for PhoneLocationTelemetry {
2812    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2813        match self {
2814            Self::WirelessInterface(location) => location.fmt(formatter),
2815            Self::Opaque(location) => location.fmt(formatter),
2816        }
2817    }
2818}
2819
2820/// Allowlisted location family that is safe to publish without location data.
2821#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2822pub enum PhoneLocationKind {
2823    WirelessInterface,
2824}
2825
2826/// Secret-safe location summary without network names or addresses.
2827#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2828pub struct PhoneLocationSummary {
2829    pub kind: PhoneLocationKind,
2830    /// Whether the marker was present; network names and addresses are omitted.
2831    pub off_premises: bool,
2832}
2833
2834impl PhoneLocationTelemetry {
2835    /// Returns only allowlisted non-identifying fields for a known schema.
2836    pub fn summary(&self) -> Option<PhoneLocationSummary> {
2837        match self {
2838            Self::WirelessInterface(location) => Some(PhoneLocationSummary {
2839                kind: PhoneLocationKind::WirelessInterface,
2840                off_premises: location.is_off_premises(),
2841            }),
2842            Self::Opaque(_) => None,
2843        }
2844    }
2845
2846    pub fn is_opaque(&self) -> bool {
2847        matches!(self, Self::Opaque(_))
2848    }
2849}
2850
2851/// Parse one bounded location-information document without treating a malformed
2852/// supported root as an opaque schema.
2853pub fn parse_phone_location(document: &[u8]) -> Result<PhoneLocationTelemetry, PhoneXmlError> {
2854    #[derive(Debug, serde::Deserialize)]
2855    struct LocationProbe;
2856
2857    #[derive(serde::Deserialize)]
2858    enum LocationProbeEnvelope {
2859        #[serde(rename = "Interface1")]
2860        Location(LocationProbe),
2861        #[serde(other)]
2862        Unknown,
2863    }
2864
2865    let supported = matches!(
2866        from_bytes(document, PHONE_LOCATION_MAX_BYTES).map_err(redact_location_schema_error)?,
2867        LocationProbeEnvelope::Location(_)
2868    );
2869    if supported {
2870        CiscoIpPhoneLocationInformation::from_xml(document)
2871            .map(PhoneLocationTelemetry::WirelessInterface)
2872    } else {
2873        Ok(PhoneLocationTelemetry::Opaque(OpaquePhoneLocation(
2874            document.to_vec(),
2875        )))
2876    }
2877}
2878
2879fn redact_location_schema_error(error: PhoneXmlError) -> PhoneXmlError {
2880    match error {
2881        PhoneXmlError::Deserialize(_) => PhoneXmlError::InvalidLocationSchema,
2882        error => error,
2883    }
2884}
2885
2886/// A directory entry containing an optional display name and dialable value.
2887#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
2888#[serde(deny_unknown_fields)]
2889pub struct CiscoIpPhoneDirectoryEntry {
2890    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
2891    pub name: Option<String>,
2892    #[serde(rename = "Telephone", default, skip_serializing_if = "Option::is_none")]
2893    pub telephone: Option<String>,
2894}
2895
2896/// A complete, schema-ordered phone directory response.
2897#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
2898#[serde(rename = "CiscoIPPhoneDirectory", deny_unknown_fields)]
2899pub struct CiscoIpPhoneDirectory {
2900    #[serde(
2901        rename = "@keypadTarget",
2902        default,
2903        skip_serializing_if = "Option::is_none"
2904    )]
2905    pub keypad_target: Option<PhoneKeypadTarget>,
2906    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
2907    pub application_id: Option<String>,
2908    #[serde(
2909        rename = "@onAppFocusLost",
2910        default,
2911        skip_serializing_if = "Option::is_none"
2912    )]
2913    pub on_focus_lost: Option<String>,
2914    #[serde(
2915        rename = "@onAppFocusGained",
2916        default,
2917        skip_serializing_if = "Option::is_none"
2918    )]
2919    pub on_focus_gained: Option<String>,
2920    #[serde(
2921        rename = "@onAppMinimized",
2922        default,
2923        skip_serializing_if = "Option::is_none"
2924    )]
2925    pub on_minimized: Option<String>,
2926    #[serde(
2927        rename = "@onAppClosed",
2928        default,
2929        skip_serializing_if = "Option::is_none"
2930    )]
2931    pub on_closed: Option<String>,
2932    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
2933    pub title: Option<String>,
2934    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
2935    pub prompt: Option<String>,
2936    #[serde(rename = "SoftKeyItem", default)]
2937    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
2938    #[serde(rename = "KeyItem", default)]
2939    pub key_items: Vec<CiscoIpPhoneKeyItem>,
2940    #[serde(rename = "DirectoryEntry", default)]
2941    pub entries: Vec<CiscoIpPhoneDirectoryEntry>,
2942}
2943
2944impl CiscoIpPhoneDirectory {
2945    /// Builds and validates a directory with no optional lifecycle actions.
2946    pub fn new(
2947        title: impl Into<String>,
2948        prompt: impl Into<String>,
2949        entries: Vec<CiscoIpPhoneDirectoryEntry>,
2950    ) -> Result<Self, PhoneXmlError> {
2951        let document = Self {
2952            keypad_target: None,
2953            application_id: None,
2954            on_focus_lost: None,
2955            on_focus_gained: None,
2956            on_minimized: None,
2957            on_closed: None,
2958            title: Some(title.into()),
2959            prompt: Some(prompt.into()),
2960            soft_keys: Vec::new(),
2961            key_items: Vec::new(),
2962            entries,
2963        };
2964        document.validate()?;
2965        Ok(document)
2966    }
2967
2968    /// Parses and validates a directory using [`PHONE_DIRECTORY_MAX_BYTES`].
2969    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2970        <Self as PhoneXmlDocument>::parse_xml(document)
2971    }
2972
2973    /// Validates and serializes a directory using [`PHONE_DIRECTORY_MAX_BYTES`].
2974    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2975        <Self as PhoneXmlDocument>::serialize_xml(self)
2976    }
2977
2978    /// Checks entry counts, text bounds, lifecycle actions, and key bindings.
2979    pub fn validate(&self) -> Result<(), PhoneXmlError> {
2980        validate_count(
2981            "directory entries",
2982            self.entries.len(),
2983            PHONE_DIRECTORY_MAX_ENTRIES,
2984        )?;
2985        validate_count("directory soft keys", self.soft_keys.len(), 16)?;
2986        validate_count("directory key items", self.key_items.len(), 32)?;
2987        validate_optional_text("directory title", self.title.as_deref(), 0, 32)?;
2988        validate_optional_text("directory prompt", self.prompt.as_deref(), 0, 32)?;
2989        validate_optional_text(
2990            "directory application id",
2991            self.application_id.as_deref(),
2992            1,
2993            64,
2994        )?;
2995        for value in [
2996            self.on_focus_lost.as_deref(),
2997            self.on_focus_gained.as_deref(),
2998            self.on_minimized.as_deref(),
2999            self.on_closed.as_deref(),
3000        ] {
3001            validate_optional_text("directory lifecycle URL", value, 1, PHONE_XML_URL_MAX_CHARS)?;
3002        }
3003        validate_internal_action("directory onAppClosed action", self.on_closed.as_deref())?;
3004        for entry in &self.entries {
3005            validate_optional_text(
3006                "directory entry name",
3007                entry.name.as_deref(),
3008                0,
3009                PHONE_DIRECTORY_TEXT_MAX_CHARS,
3010            )?;
3011            validate_optional_text(
3012                "directory entry telephone",
3013                entry.telephone.as_deref(),
3014                0,
3015                PHONE_DIRECTORY_TEXT_MAX_CHARS,
3016            )?;
3017        }
3018        for soft_key in &self.soft_keys {
3019            validate_optional_text("directory soft-key name", soft_key.name.as_deref(), 0, 32)?;
3020            validate_optional_text(
3021                "directory soft-key URL",
3022                soft_key.url.as_deref(),
3023                0,
3024                PHONE_XML_URL_MAX_CHARS,
3025            )?;
3026            validate_optional_text(
3027                "directory soft-key down URL",
3028                soft_key.url_down.as_deref(),
3029                0,
3030                PHONE_XML_URL_MAX_CHARS,
3031            )?;
3032            validate_internal_action("directory soft-key URLDown", soft_key.url_down.as_deref())?;
3033        }
3034        for key_item in &self.key_items {
3035            validate_optional_text(
3036                "directory key URL",
3037                key_item.url.as_deref(),
3038                0,
3039                PHONE_XML_URL_MAX_CHARS,
3040            )?;
3041            validate_optional_text(
3042                "directory key down URL",
3043                key_item.url_down.as_deref(),
3044                0,
3045                PHONE_XML_URL_MAX_CHARS,
3046            )?;
3047            validate_internal_action("directory key URLDown", key_item.url_down.as_deref())?;
3048        }
3049        Ok(())
3050    }
3051}
3052
3053fn validate_count(kind: &'static str, actual: usize, maximum: usize) -> Result<(), PhoneXmlError> {
3054    if actual > maximum {
3055        Err(PhoneXmlError::LimitExceeded {
3056            kind,
3057            actual,
3058            maximum,
3059        })
3060    } else {
3061        Ok(())
3062    }
3063}
3064
3065fn validate_optional_text(
3066    field: &'static str,
3067    value: Option<&str>,
3068    minimum: usize,
3069    maximum: usize,
3070) -> Result<(), PhoneXmlError> {
3071    let Some(value) = value else {
3072        return Ok(());
3073    };
3074    let length = value.chars().count();
3075    if !(minimum..=maximum).contains(&length) {
3076        return Err(PhoneXmlError::InvalidField {
3077            field,
3078            expected: match (minimum, maximum) {
3079                (0, 32) => "at most 32 characters",
3080                (0, 256) => "at most 256 characters",
3081                (1, 64) => "between 1 and 64 characters",
3082                (1, 256) => "between 1 and 256 characters",
3083                _ => "within the schema length bounds",
3084            },
3085        });
3086    }
3087    if !has_only_xml_characters(value) {
3088        return Err(PhoneXmlError::InvalidField {
3089            field,
3090            expected: "valid XML text without forbidden control characters",
3091        });
3092    }
3093    Ok(())
3094}
3095
3096fn has_only_xml_characters(value: &str) -> bool {
3097    value.chars().all(|character| {
3098        matches!(
3099            character as u32,
3100            0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF
3101        )
3102    })
3103}
3104
3105fn action_kind(value: &str) -> PhoneActionKind {
3106    match value.split_once(':').map(|(scheme, _)| scheme) {
3107        Some(scheme)
3108            if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") =>
3109        {
3110            PhoneActionKind::Http
3111        }
3112        _ => PhoneActionKind::Internal,
3113    }
3114}
3115
3116fn validate_internal_action(field: &'static str, value: Option<&str>) -> Result<(), PhoneXmlError> {
3117    if value.is_some_and(|value| action_kind(value) == PhoneActionKind::Http) {
3118        Err(PhoneXmlError::InvalidField {
3119            field,
3120            expected: "an internal phone action, not HTTP or HTTPS",
3121        })
3122    } else {
3123        Ok(())
3124    }
3125}
3126
3127fn validate_displayable(
3128    title: Option<&str>,
3129    prompt: Option<&str>,
3130    application_id: Option<&str>,
3131    lifecycle_urls: [Option<&str>; 4],
3132    soft_keys: &[CiscoIpPhoneSoftKeyItem],
3133    key_items: &[CiscoIpPhoneKeyItem],
3134) -> Result<(), PhoneXmlError> {
3135    validate_optional_text("display title", title, 0, 32)?;
3136    validate_optional_text("display prompt", prompt, 0, 32)?;
3137    validate_optional_text("display application id", application_id, 1, 64)?;
3138    let [on_focus_lost, on_focus_gained, on_minimized, on_closed] = lifecycle_urls;
3139    for url in [on_focus_lost, on_focus_gained, on_minimized, on_closed] {
3140        validate_optional_text("display lifecycle URL", url, 1, PHONE_XML_URL_MAX_CHARS)?;
3141    }
3142    validate_internal_action("display onAppClosed action", on_closed)?;
3143    validate_count("display soft keys", soft_keys.len(), 16)?;
3144    for soft_key in soft_keys {
3145        validate_optional_text("display soft-key name", soft_key.name.as_deref(), 0, 32)?;
3146        validate_optional_text(
3147            "display soft-key URL",
3148            soft_key.url.as_deref(),
3149            0,
3150            PHONE_XML_URL_MAX_CHARS,
3151        )?;
3152        validate_optional_text(
3153            "display soft-key down URL",
3154            soft_key.url_down.as_deref(),
3155            0,
3156            PHONE_XML_URL_MAX_CHARS,
3157        )?;
3158        validate_internal_action("display soft-key URLDown", soft_key.url_down.as_deref())?;
3159    }
3160    validate_count("display key items", key_items.len(), 32)?;
3161    for key_item in key_items {
3162        validate_optional_text(
3163            "display key URL",
3164            key_item.url.as_deref(),
3165            0,
3166            PHONE_XML_URL_MAX_CHARS,
3167        )?;
3168        validate_optional_text(
3169            "display key down URL",
3170            key_item.url_down.as_deref(),
3171            0,
3172            PHONE_XML_URL_MAX_CHARS,
3173        )?;
3174        validate_internal_action("display key URLDown", key_item.url_down.as_deref())?;
3175    }
3176    Ok(())
3177}
3178
3179fn validate_image_display(
3180    title: Option<&str>,
3181    prompt: Option<&str>,
3182    application_id: Option<&str>,
3183    lifecycle_urls: [Option<&str>; 4],
3184    soft_keys: &[CiscoIpPhoneSoftKeyItem],
3185    key_items: &[CiscoIpPhoneKeyItem],
3186) -> Result<(), PhoneXmlError> {
3187    validate_displayable(
3188        title,
3189        prompt,
3190        application_id,
3191        lifecycle_urls,
3192        soft_keys,
3193        key_items,
3194    )
3195}
3196
3197fn validate_bitmap_image(
3198    location_x: Option<i16>,
3199    location_y: Option<i16>,
3200    width: u16,
3201    height: u16,
3202    depth: u16,
3203    data: Option<&PhoneBitmapData>,
3204) -> Result<(), PhoneXmlError> {
3205    if location_x.is_some_and(|value| !(-1..=132).contains(&value)) {
3206        return Err(PhoneXmlError::InvalidField {
3207            field: "bitmap image horizontal location",
3208            expected: "between -1 and 132",
3209        });
3210    }
3211    if location_y.is_some_and(|value| !(-1..=64).contains(&value)) {
3212        return Err(PhoneXmlError::InvalidField {
3213            field: "bitmap image vertical location",
3214            expected: "between -1 and 64",
3215        });
3216    }
3217    if !(1..=133).contains(&width) {
3218        return Err(PhoneXmlError::InvalidField {
3219            field: "bitmap image width",
3220            expected: "between 1 and 133",
3221        });
3222    }
3223    if !(1..=65).contains(&height) {
3224        return Err(PhoneXmlError::InvalidField {
3225            field: "bitmap image height",
3226            expected: "between 1 and 65",
3227        });
3228    }
3229    if !(1..=2).contains(&depth) {
3230        return Err(PhoneXmlError::InvalidField {
3231            field: "bitmap image depth",
3232            expected: "between 1 and 2",
3233        });
3234    }
3235    if let Some(data) = data {
3236        validate_count(
3237            "bitmap image data bytes",
3238            data.as_bytes().len(),
3239            PHONE_IMAGE_BITMAP_MAX_BYTES,
3240        )?;
3241    }
3242    Ok(())
3243}
3244
3245fn validate_file_image_location(
3246    location_x: Option<i16>,
3247    location_y: Option<i16>,
3248) -> Result<(), PhoneXmlError> {
3249    if location_x.is_some_and(|value| !(-1..=297).contains(&value)) {
3250        return Err(PhoneXmlError::InvalidField {
3251            field: "image-file horizontal location",
3252            expected: "between -1 and 297",
3253        });
3254    }
3255    if location_y.is_some_and(|value| !(-1..=167).contains(&value)) {
3256        return Err(PhoneXmlError::InvalidField {
3257            field: "image-file vertical location",
3258            expected: "between -1 and 167",
3259        });
3260    }
3261    Ok(())
3262}
3263
3264fn validate_icon_menu_items(items: &[CiscoIpPhoneIconMenuItem]) -> Result<(), PhoneXmlError> {
3265    validate_count("icon menu items", items.len(), PHONE_ICON_MENU_MAX_ITEMS)?;
3266    for item in items {
3267        validate_optional_text("icon menu item name", item.name.as_deref(), 0, 64)?;
3268        validate_optional_text(
3269            "icon menu item URL",
3270            item.url.as_deref(),
3271            0,
3272            PHONE_XML_URL_MAX_CHARS,
3273        )?;
3274        if item.icon_index.is_some_and(|index| index > 9) {
3275            return Err(PhoneXmlError::InvalidField {
3276                field: "icon menu item index",
3277                expected: "between 0 and 9",
3278            });
3279        }
3280    }
3281    Ok(())
3282}
3283
3284fn validate_bitmap_icon(icon: &CiscoIpPhoneIconItem) -> Result<(), PhoneXmlError> {
3285    if !(1..=16).contains(&icon.width) {
3286        return Err(PhoneXmlError::InvalidField {
3287            field: "bitmap icon width",
3288            expected: "between 1 and 16",
3289        });
3290    }
3291    if !(1..=10).contains(&icon.height) {
3292        return Err(PhoneXmlError::InvalidField {
3293            field: "bitmap icon height",
3294            expected: "between 1 and 10",
3295        });
3296    }
3297    if !(1..=2).contains(&icon.depth) {
3298        return Err(PhoneXmlError::InvalidField {
3299            field: "bitmap icon depth",
3300            expected: "between 1 and 2",
3301        });
3302    }
3303    if let Some(data) = &icon.data
3304        && (data.len() > 80
3305            || data.len() % 2 != 0
3306            || !data.bytes().all(|byte| byte.is_ascii_hexdigit()))
3307    {
3308        return Err(PhoneXmlError::InvalidField {
3309            field: "bitmap icon data",
3310            expected: "at most 40 hexadecimal bytes",
3311        });
3312    }
3313    Ok(())
3314}
3315
3316/// One optional label/action pair in a plain menu.
3317#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3318#[serde(deny_unknown_fields)]
3319pub struct CiscoIpPhoneMenuItem {
3320    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
3321    pub name: Option<String>,
3322    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
3323    pub url: Option<String>,
3324}
3325
3326/// A complete plain menu with optional lifecycle and physical-key actions.
3327#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3328#[serde(rename = "CiscoIPPhoneMenu", deny_unknown_fields)]
3329pub struct CiscoIpPhoneMenu {
3330    #[serde(
3331        rename = "@keypadTarget",
3332        default,
3333        skip_serializing_if = "Option::is_none"
3334    )]
3335    pub keypad_target: Option<PhoneKeypadTarget>,
3336    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
3337    pub application_id: Option<String>,
3338    #[serde(
3339        rename = "@onAppFocusLost",
3340        default,
3341        skip_serializing_if = "Option::is_none"
3342    )]
3343    pub on_focus_lost: Option<String>,
3344    #[serde(
3345        rename = "@onAppFocusGained",
3346        default,
3347        skip_serializing_if = "Option::is_none"
3348    )]
3349    pub on_focus_gained: Option<String>,
3350    #[serde(
3351        rename = "@onAppMinimized",
3352        default,
3353        skip_serializing_if = "Option::is_none"
3354    )]
3355    pub on_minimized: Option<String>,
3356    #[serde(
3357        rename = "@onAppClosed",
3358        default,
3359        skip_serializing_if = "Option::is_none"
3360    )]
3361    pub on_closed: Option<String>,
3362    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
3363    pub title: Option<String>,
3364    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
3365    pub prompt: Option<String>,
3366    #[serde(rename = "SoftKeyItem", default)]
3367    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
3368    #[serde(rename = "KeyItem", default)]
3369    pub key_items: Vec<CiscoIpPhoneKeyItem>,
3370    #[serde(rename = "MenuItem", default)]
3371    pub items: Vec<CiscoIpPhoneMenuItem>,
3372}
3373
3374impl CiscoIpPhoneMenu {
3375    /// Builds and validates a menu with no optional lifecycle actions.
3376    pub fn new(
3377        title: impl Into<String>,
3378        prompt: impl Into<String>,
3379        items: Vec<CiscoIpPhoneMenuItem>,
3380    ) -> Result<Self, PhoneXmlError> {
3381        let document = Self {
3382            keypad_target: None,
3383            application_id: None,
3384            on_focus_lost: None,
3385            on_focus_gained: None,
3386            on_minimized: None,
3387            on_closed: None,
3388            title: Some(title.into()),
3389            prompt: Some(prompt.into()),
3390            soft_keys: Vec::new(),
3391            key_items: Vec::new(),
3392            items,
3393        };
3394        document.validate()?;
3395        Ok(document)
3396    }
3397
3398    /// Validates display metadata and the bounded list of menu choices.
3399    pub fn validate(&self) -> Result<(), PhoneXmlError> {
3400        validate_displayable(
3401            self.title.as_deref(),
3402            self.prompt.as_deref(),
3403            self.application_id.as_deref(),
3404            [
3405                self.on_focus_lost.as_deref(),
3406                self.on_focus_gained.as_deref(),
3407                self.on_minimized.as_deref(),
3408                self.on_closed.as_deref(),
3409            ],
3410            &self.soft_keys,
3411            &self.key_items,
3412        )?;
3413        validate_count("menu items", self.items.len(), PHONE_MENU_MAX_ITEMS)?;
3414        for item in &self.items {
3415            validate_optional_text("menu item name", item.name.as_deref(), 0, 64)?;
3416            validate_optional_text(
3417                "menu item URL",
3418                item.url.as_deref(),
3419                0,
3420                PHONE_XML_URL_MAX_CHARS,
3421            )?;
3422        }
3423        Ok(())
3424    }
3425
3426    /// Parses and validates a menu using [`PHONE_MENU_MAX_BYTES`].
3427    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
3428        <Self as PhoneXmlDocument>::parse_xml(document)
3429    }
3430
3431    /// Parses a menu with an additional caller-selected byte limit.
3432    pub fn from_xml_with_limit(
3433        document: &[u8],
3434        maximum_bytes: usize,
3435    ) -> Result<Self, PhoneXmlError> {
3436        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
3437    }
3438
3439    /// Validates and serializes a menu using [`PHONE_MENU_MAX_BYTES`].
3440    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
3441        <Self as PhoneXmlDocument>::serialize_xml(self)
3442    }
3443
3444    /// Validates and serializes a menu within a caller-selected byte limit.
3445    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
3446        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
3447    }
3448}
3449
3450/// One indexed inline bitmap icon used by an icon menu.
3451#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3452#[serde(deny_unknown_fields)]
3453pub struct CiscoIpPhoneIconItem {
3454    #[serde(rename = "Index")]
3455    pub index: u16,
3456    #[serde(rename = "Width")]
3457    /// Icon width in pixels, constrained to `1..=16`.
3458    pub width: u16,
3459    #[serde(rename = "Height")]
3460    /// Icon height in pixels, constrained to `1..=10`.
3461    pub height: u16,
3462    #[serde(rename = "Depth")]
3463    /// Icon bit depth, constrained to `1..=2`.
3464    pub depth: u16,
3465    #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
3466    /// Optional hexadecimal bitmap containing at most 40 bytes.
3467    pub data: Option<String>,
3468}
3469
3470/// One indexed referenced icon used by an icon-file menu.
3471#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3472#[serde(deny_unknown_fields)]
3473pub struct CiscoIpPhoneIconFileItem {
3474    #[serde(rename = "Index")]
3475    pub index: u16,
3476    #[serde(rename = "URL")]
3477    /// Resource URL constrained to at most 256 characters.
3478    pub url: String,
3479}
3480
3481/// One optional label/action pair with an optional icon association.
3482#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3483#[serde(deny_unknown_fields)]
3484pub struct CiscoIpPhoneIconMenuItem {
3485    #[serde(rename = "Name", default, skip_serializing_if = "Option::is_none")]
3486    pub name: Option<String>,
3487    #[serde(rename = "URL", default, skip_serializing_if = "Option::is_none")]
3488    pub url: Option<String>,
3489    #[serde(rename = "IconIndex", default, skip_serializing_if = "Option::is_none")]
3490    /// Icon index in `0..=9`; omission displays no icon.
3491    pub icon_index: Option<u16>,
3492}
3493
3494/// Icon-bearing title used by an icon-file menu.
3495#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3496#[serde(deny_unknown_fields)]
3497pub struct CiscoIpPhoneIconTitle {
3498    #[serde(
3499        rename = "@IconIndex",
3500        default,
3501        skip_serializing_if = "Option::is_none"
3502    )]
3503    /// Icon index in `0..=9`; omission displays only title text.
3504    pub icon_index: Option<u16>,
3505    #[serde(rename = "$text", default)]
3506    pub text: String,
3507}
3508
3509/// A complete menu whose icons are inline hexadecimal bitmaps.
3510#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3511#[serde(rename = "CiscoIPPhoneIconMenu", deny_unknown_fields)]
3512pub struct CiscoIpPhoneIconMenu {
3513    #[serde(
3514        rename = "@keypadTarget",
3515        default,
3516        skip_serializing_if = "Option::is_none"
3517    )]
3518    pub keypad_target: Option<PhoneKeypadTarget>,
3519    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
3520    pub application_id: Option<String>,
3521    #[serde(
3522        rename = "@onAppFocusLost",
3523        default,
3524        skip_serializing_if = "Option::is_none"
3525    )]
3526    pub on_focus_lost: Option<String>,
3527    #[serde(
3528        rename = "@onAppFocusGained",
3529        default,
3530        skip_serializing_if = "Option::is_none"
3531    )]
3532    pub on_focus_gained: Option<String>,
3533    #[serde(
3534        rename = "@onAppMinimized",
3535        default,
3536        skip_serializing_if = "Option::is_none"
3537    )]
3538    pub on_minimized: Option<String>,
3539    #[serde(
3540        rename = "@onAppClosed",
3541        default,
3542        skip_serializing_if = "Option::is_none"
3543    )]
3544    pub on_closed: Option<String>,
3545    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
3546    pub title: Option<String>,
3547    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
3548    pub prompt: Option<String>,
3549    #[serde(rename = "SoftKeyItem", default)]
3550    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
3551    #[serde(rename = "KeyItem", default)]
3552    pub key_items: Vec<CiscoIpPhoneKeyItem>,
3553    #[serde(rename = "MenuItem", default)]
3554    pub items: Vec<CiscoIpPhoneIconMenuItem>,
3555    #[serde(rename = "IconItem", default)]
3556    pub icons: Vec<CiscoIpPhoneIconItem>,
3557}
3558
3559impl CiscoIpPhoneIconMenu {
3560    /// Builds and validates an inline-icon menu without lifecycle actions.
3561    pub fn new(
3562        title: impl Into<String>,
3563        prompt: impl Into<String>,
3564        items: Vec<CiscoIpPhoneIconMenuItem>,
3565        icons: Vec<CiscoIpPhoneIconItem>,
3566    ) -> Result<Self, PhoneXmlError> {
3567        let document = Self {
3568            keypad_target: None,
3569            application_id: None,
3570            on_focus_lost: None,
3571            on_focus_gained: None,
3572            on_minimized: None,
3573            on_closed: None,
3574            title: Some(title.into()),
3575            prompt: Some(prompt.into()),
3576            soft_keys: Vec::new(),
3577            key_items: Vec::new(),
3578            items,
3579            icons,
3580        };
3581        document.validate()?;
3582        Ok(document)
3583    }
3584
3585    /// Validates display metadata, choice bounds, and bitmap icon geometry.
3586    pub fn validate(&self) -> Result<(), PhoneXmlError> {
3587        validate_displayable(
3588            self.title.as_deref(),
3589            self.prompt.as_deref(),
3590            self.application_id.as_deref(),
3591            [
3592                self.on_focus_lost.as_deref(),
3593                self.on_focus_gained.as_deref(),
3594                self.on_minimized.as_deref(),
3595                self.on_closed.as_deref(),
3596            ],
3597            &self.soft_keys,
3598            &self.key_items,
3599        )?;
3600        validate_icon_menu_items(&self.items)?;
3601        validate_count(
3602            "icon menu icons",
3603            self.icons.len(),
3604            PHONE_ICON_MENU_MAX_ICONS,
3605        )?;
3606        for icon in &self.icons {
3607            validate_bitmap_icon(icon)?;
3608        }
3609        Ok(())
3610    }
3611
3612    /// Parses and validates an inline-icon menu using [`PHONE_MENU_MAX_BYTES`].
3613    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
3614        <Self as PhoneXmlDocument>::parse_xml(document)
3615    }
3616
3617    /// Parses an inline-icon menu with an additional caller-selected limit.
3618    pub fn from_xml_with_limit(
3619        document: &[u8],
3620        maximum_bytes: usize,
3621    ) -> Result<Self, PhoneXmlError> {
3622        <Self as PhoneXmlDocument>::parse_xml_with_limit(document, maximum_bytes)
3623    }
3624
3625    /// Validates and serializes an inline-icon menu using its default bound.
3626    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
3627        <Self as PhoneXmlDocument>::serialize_xml(self)
3628    }
3629
3630    /// Validates and serializes an inline-icon menu within a caller-selected limit.
3631    pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
3632        <Self as PhoneXmlDocument>::serialize_xml_with_limit(self, maximum_bytes)
3633    }
3634}
3635
3636/// A complete menu whose icons are loaded from resource URLs.
3637#[derive(Clone, Debug, serde::Deserialize, Eq, PartialEq, Serialize)]
3638#[serde(rename = "CiscoIPPhoneIconFileMenu", deny_unknown_fields)]
3639pub struct CiscoIpPhoneIconFileMenu {
3640    #[serde(
3641        rename = "@keypadTarget",
3642        default,
3643        skip_serializing_if = "Option::is_none"
3644    )]
3645    pub keypad_target: Option<PhoneKeypadTarget>,
3646    #[serde(rename = "@appId", default, skip_serializing_if = "Option::is_none")]
3647    pub application_id: Option<String>,
3648    #[serde(
3649        rename = "@onAppFocusLost",
3650        default,
3651        skip_serializing_if = "Option::is_none"
3652    )]
3653    pub on_focus_lost: Option<String>,
3654    #[serde(
3655        rename = "@onAppFocusGained",
3656        default,
3657        skip_serializing_if = "Option::is_none"
3658    )]
3659    pub on_focus_gained: Option<String>,
3660    #[serde(
3661        rename = "@onAppMinimized",
3662        default,
3663        skip_serializing_if = "Option::is_none"
3664    )]
3665    pub on_minimized: Option<String>,
3666    #[serde(
3667        rename = "@onAppClosed",
3668        default,
3669        skip_serializing_if = "Option::is_none"
3670    )]
3671    pub on_closed: Option<String>,
3672    #[serde(
3673        rename = "@IconIndex",
3674        default,
3675        skip_serializing_if = "Option::is_none"
3676    )]
3677    /// Optional icon index in `0..=9` displayed beside the title.
3678    pub icon_index: Option<u16>,
3679    #[serde(rename = "Title", default, skip_serializing_if = "Option::is_none")]
3680    pub title: Option<CiscoIpPhoneIconTitle>,
3681    #[serde(rename = "Prompt", default, skip_serializing_if = "Option::is_none")]
3682    pub prompt: Option<String>,
3683    #[serde(rename = "SoftKeyItem", default)]
3684    pub soft_keys: Vec<CiscoIpPhoneSoftKeyItem>,
3685    #[serde(rename = "KeyItem", default)]
3686    pub key_items: Vec<CiscoIpPhoneKeyItem>,
3687    #[serde(rename = "MenuItem", default)]
3688    pub items: Vec<CiscoIpPhoneIconMenuItem>,
3689    #[serde(rename = "IconItem", default)]
3690    pub icons: Vec<CiscoIpPhoneIconFileItem>,
3691}
3692
3693impl CiscoIpPhoneIconFileMenu {
3694    /// Validates display metadata, choice bounds, and referenced icons.
3695    pub fn validate(&self) -> Result<(), PhoneXmlError> {
3696        validate_displayable(
3697            self.title.as_ref().map(|title| title.text.as_str()),
3698            self.prompt.as_deref(),
3699            self.application_id.as_deref(),
3700            [
3701                self.on_focus_lost.as_deref(),
3702                self.on_focus_gained.as_deref(),
3703                self.on_minimized.as_deref(),
3704                self.on_closed.as_deref(),
3705            ],
3706            &self.soft_keys,
3707            &self.key_items,
3708        )?;
3709        validate_icon_menu_items(&self.items)?;
3710        validate_count(
3711            "icon-file menu icons",
3712            self.icons.len(),
3713            PHONE_ICON_MENU_MAX_ICONS,
3714        )?;
3715        for icon in &self.icons {
3716            if icon.index > 9 {
3717                return Err(PhoneXmlError::InvalidField {
3718                    field: "icon-file index",
3719                    expected: "between 0 and 9",
3720                });
3721            }
3722            validate_optional_text("icon-file URL", Some(&icon.url), 1, PHONE_XML_URL_MAX_CHARS)?;
3723        }
3724        Ok(())
3725    }
3726
3727    /// Parses and validates a referenced-icon menu using [`PHONE_MENU_MAX_BYTES`].
3728    pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
3729        <Self as PhoneXmlDocument>::parse_xml(document)
3730    }
3731
3732    /// Validates and serializes a referenced-icon menu using its default bound.
3733    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
3734        <Self as PhoneXmlDocument>::serialize_xml(self)
3735    }
3736}
3737
3738/// Display schema used when rendering conference workflows.
3739#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3740pub enum ConferenceMenuFamily {
3741    Menu,
3742    IconMenu,
3743}
3744
3745/// Participant state needed to render conference menus and allowed actions.
3746#[derive(Clone, Debug, Eq, PartialEq)]
3747pub struct ConferenceListEntry {
3748    pub participant_id: ParticipantId,
3749    pub name: String,
3750    pub number: String,
3751    pub moderator: bool,
3752    pub muted: bool,
3753}
3754
3755/// Typed callback encoded into conference menu action URLs.
3756#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3757pub enum ConferenceListAction {
3758    /// Open the action menu for one participant.
3759    Participant {
3760        conference_id: ConferenceId,
3761        participant_id: ParticipantId,
3762    },
3763    Mute {
3764        conference_id: ConferenceId,
3765        participant_id: ParticipantId,
3766    },
3767    Unmute {
3768        conference_id: ConferenceId,
3769        participant_id: ParticipantId,
3770    },
3771    Remove {
3772        conference_id: ConferenceId,
3773        participant_id: ParticipantId,
3774    },
3775    Promote {
3776        conference_id: ConferenceId,
3777        participant_id: ParticipantId,
3778    },
3779    Demote {
3780        conference_id: ConferenceId,
3781        participant_id: ParticipantId,
3782    },
3783    End {
3784        conference_id: ConferenceId,
3785    },
3786}
3787
3788impl ConferenceListAction {
3789    /// Application identifier embedded in generated callback URLs.
3790    pub const APPLICATION_ID: u32 = 9091;
3791
3792    /// Encodes the action as a device-local callback URL.
3793    pub fn url(self) -> String {
3794        match self {
3795            Self::Participant {
3796                conference_id,
3797                participant_id,
3798            } => format!(
3799                "UserData:{}:0:conference/{}/participant/{}",
3800                Self::APPLICATION_ID,
3801                conference_id.get(),
3802                participant_id.get()
3803            ),
3804            Self::Mute {
3805                conference_id,
3806                participant_id,
3807            } => format!(
3808                "UserData:{}:0:conference/{}/participant/{}/mute",
3809                Self::APPLICATION_ID,
3810                conference_id.get(),
3811                participant_id.get()
3812            ),
3813            Self::Unmute {
3814                conference_id,
3815                participant_id,
3816            } => format!(
3817                "UserData:{}:0:conference/{}/participant/{}/unmute",
3818                Self::APPLICATION_ID,
3819                conference_id.get(),
3820                participant_id.get()
3821            ),
3822            Self::Remove {
3823                conference_id,
3824                participant_id,
3825            } => format!(
3826                "UserData:{}:0:conference/{}/participant/{}/remove",
3827                Self::APPLICATION_ID,
3828                conference_id.get(),
3829                participant_id.get()
3830            ),
3831            Self::Promote {
3832                conference_id,
3833                participant_id,
3834            } => format!(
3835                "UserData:{}:0:conference/{}/participant/{}/promote",
3836                Self::APPLICATION_ID,
3837                conference_id.get(),
3838                participant_id.get()
3839            ),
3840            Self::Demote {
3841                conference_id,
3842                participant_id,
3843            } => format!(
3844                "UserData:{}:0:conference/{}/participant/{}/demote",
3845                Self::APPLICATION_ID,
3846                conference_id.get(),
3847                participant_id.get()
3848            ),
3849            Self::End { conference_id } => format!(
3850                "UserData:{}:0:conference/{}/end",
3851                Self::APPLICATION_ID,
3852                conference_id.get()
3853            ),
3854        }
3855    }
3856
3857    /// Parses a complete callback URL or its bare `conference/...` path.
3858    pub fn parse(value: &str) -> Option<Self> {
3859        let path = value
3860            .trim_matches(['\0', ' ', '\r', '\n'])
3861            .strip_prefix(&format!("UserData:{}:0:", Self::APPLICATION_ID))
3862            .unwrap_or(value)
3863            .strip_prefix("conference/")?;
3864        let segments: Vec<_> = path.split('/').collect();
3865        let [conference_id, action, rest @ ..] = segments.as_slice() else {
3866            return None;
3867        };
3868        let conference_id = ConferenceId::new(conference_id.parse().ok()?);
3869        match (*action, rest) {
3870            ("participant", [participant]) => Some(Self::Participant {
3871                conference_id,
3872                participant_id: ParticipantId::new(participant.parse().ok()?),
3873            }),
3874            ("participant", [participant, "mute"]) => Some(Self::Mute {
3875                conference_id,
3876                participant_id: ParticipantId::new(participant.parse().ok()?),
3877            }),
3878            ("participant", [participant, "unmute"]) => Some(Self::Unmute {
3879                conference_id,
3880                participant_id: ParticipantId::new(participant.parse().ok()?),
3881            }),
3882            ("participant", [participant, "remove"]) => Some(Self::Remove {
3883                conference_id,
3884                participant_id: ParticipantId::new(participant.parse().ok()?),
3885            }),
3886            ("participant", [participant, "promote"]) => Some(Self::Promote {
3887                conference_id,
3888                participant_id: ParticipantId::new(participant.parse().ok()?),
3889            }),
3890            ("participant", [participant, "demote"]) => Some(Self::Demote {
3891                conference_id,
3892                participant_id: ParticipantId::new(participant.parse().ok()?),
3893            }),
3894            ("end", []) => Some(Self::End { conference_id }),
3895            _ => None,
3896        }
3897    }
3898
3899    /// Parses the percent-decoded route produced by a service submission.
3900    pub fn from_route(route: &[String]) -> Option<Self> {
3901        let [conference, conference_id, action, rest @ ..] = route else {
3902            return None;
3903        };
3904        if conference != "conference" {
3905            return None;
3906        }
3907        let conference_id = ConferenceId::new(conference_id.parse().ok()?);
3908        match (action.as_str(), rest) {
3909            ("participant", [participant]) => Some(Self::Participant {
3910                conference_id,
3911                participant_id: ParticipantId::new(participant.parse().ok()?),
3912            }),
3913            ("participant", [participant, operation]) if operation == "mute" => Some(Self::Mute {
3914                conference_id,
3915                participant_id: ParticipantId::new(participant.parse().ok()?),
3916            }),
3917            ("participant", [participant, operation]) if operation == "unmute" => {
3918                Some(Self::Unmute {
3919                    conference_id,
3920                    participant_id: ParticipantId::new(participant.parse().ok()?),
3921                })
3922            }
3923            ("participant", [participant, operation]) if operation == "remove" => {
3924                Some(Self::Remove {
3925                    conference_id,
3926                    participant_id: ParticipantId::new(participant.parse().ok()?),
3927                })
3928            }
3929            ("participant", [participant, operation]) if operation == "promote" => {
3930                Some(Self::Promote {
3931                    conference_id,
3932                    participant_id: ParticipantId::new(participant.parse().ok()?),
3933                })
3934            }
3935            ("participant", [participant, operation]) if operation == "demote" => {
3936                Some(Self::Demote {
3937                    conference_id,
3938                    participant_id: ParticipantId::new(participant.parse().ok()?),
3939                })
3940            }
3941            ("end", []) => Some(Self::End { conference_id }),
3942            _ => None,
3943        }
3944    }
3945}
3946
3947/// Rendered conference overview in either supported menu family.
3948#[derive(Clone, Debug, Eq, PartialEq)]
3949pub enum ConferenceListDocument {
3950    Menu(CiscoIpPhoneMenu),
3951    IconMenu(CiscoIpPhoneIconMenu),
3952}
3953
3954impl ConferenceListDocument {
3955    /// Renders a conference overview and validates all menu and byte bounds.
3956    pub fn new(
3957        conference_id: ConferenceId,
3958        participants: &[ConferenceListEntry],
3959        family: ConferenceMenuFamily,
3960    ) -> Result<Self, PhoneXmlError> {
3961        if participants.len() > CONFERENCE_LIST_MAX_PARTICIPANTS {
3962            return Err(PhoneXmlError::LimitExceeded {
3963                kind: "conference participants",
3964                actual: participants.len(),
3965                maximum: CONFERENCE_LIST_MAX_PARTICIPANTS,
3966            });
3967        }
3968        let title = format!("Conference {}", conference_id.get());
3969        let prompt = if participants.is_empty() {
3970            "No participants".to_owned()
3971        } else {
3972            "Select a participant".to_owned()
3973        };
3974        match family {
3975            ConferenceMenuFamily::Menu => CiscoIpPhoneMenu::new(
3976                title,
3977                prompt,
3978                participants
3979                    .iter()
3980                    .map(|participant| CiscoIpPhoneMenuItem {
3981                        name: Some(conference_participant_label(participant)),
3982                        url: Some(
3983                            ConferenceListAction::Participant {
3984                                conference_id,
3985                                participant_id: participant.participant_id,
3986                            }
3987                            .url(),
3988                        ),
3989                    })
3990                    .chain(std::iter::once(CiscoIpPhoneMenuItem {
3991                        name: Some("End conference".into()),
3992                        url: Some(ConferenceListAction::End { conference_id }.url()),
3993                    }))
3994                    .collect(),
3995            )
3996            .map(Self::Menu),
3997            ConferenceMenuFamily::IconMenu => CiscoIpPhoneIconMenu::new(
3998                title,
3999                prompt,
4000                participants
4001                    .iter()
4002                    .map(|participant| CiscoIpPhoneIconMenuItem {
4003                        name: Some(conference_participant_label(participant)),
4004                        url: Some(
4005                            ConferenceListAction::Participant {
4006                                conference_id,
4007                                participant_id: participant.participant_id,
4008                            }
4009                            .url(),
4010                        ),
4011                        icon_index: Some(u16::from(participant.moderator)),
4012                    })
4013                    .chain(std::iter::once(CiscoIpPhoneIconMenuItem {
4014                        name: Some("End conference".into()),
4015                        url: Some(ConferenceListAction::End { conference_id }.url()),
4016                        icon_index: Some(0),
4017                    }))
4018                    .collect(),
4019                conference_icons(),
4020            )
4021            .map(Self::IconMenu),
4022        }
4023    }
4024
4025    /// Serializes the rendered menu within [`CONFERENCE_LIST_MAX_BYTES`].
4026    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
4027        match self {
4028            Self::Menu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
4029            Self::IconMenu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
4030        }
4031    }
4032
4033    /// Parses a conference overview using the caller-selected menu family.
4034    pub fn from_xml(document: &[u8], family: ConferenceMenuFamily) -> Result<Self, PhoneXmlError> {
4035        match family {
4036            ConferenceMenuFamily::Menu => {
4037                CiscoIpPhoneMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
4038                    .map(Self::Menu)
4039            }
4040            ConferenceMenuFamily::IconMenu => {
4041                CiscoIpPhoneIconMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
4042                    .map(Self::IconMenu)
4043            }
4044        }
4045    }
4046
4047    /// Iterates recognized callback actions in display order.
4048    pub fn actions(&self) -> impl Iterator<Item = ConferenceListAction> + '_ {
4049        let urls: Box<dyn Iterator<Item = &str>> = match self {
4050            Self::Menu(document) => {
4051                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
4052            }
4053            Self::IconMenu(document) => {
4054                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
4055            }
4056        };
4057        urls.filter_map(ConferenceListAction::parse)
4058    }
4059}
4060
4061/// Participant-specific conference actions in either supported menu family.
4062#[derive(Clone, Debug, Eq, PartialEq)]
4063pub enum ConferenceParticipantActionsDocument {
4064    Menu(CiscoIpPhoneMenu),
4065    IconMenu(CiscoIpPhoneIconMenu),
4066}
4067
4068impl ConferenceParticipantActionsDocument {
4069    /// Renders the actions currently permitted for one participant.
4070    ///
4071    /// `removable` and `demotable` let session policy suppress actions even
4072    /// when the participant state would otherwise permit them.
4073    pub fn new(
4074        conference_id: ConferenceId,
4075        participant: &ConferenceListEntry,
4076        removable: bool,
4077        demotable: bool,
4078        family: ConferenceMenuFamily,
4079    ) -> Result<Self, PhoneXmlError> {
4080        let mut actions = Vec::new();
4081        if participant.moderator {
4082            if demotable {
4083                actions.push((
4084                    "Demote",
4085                    ConferenceListAction::Demote {
4086                        conference_id,
4087                        participant_id: participant.participant_id,
4088                    },
4089                ));
4090            }
4091        } else {
4092            let (toggle_name, toggle) = if participant.muted {
4093                (
4094                    "Unmute",
4095                    ConferenceListAction::Unmute {
4096                        conference_id,
4097                        participant_id: participant.participant_id,
4098                    },
4099                )
4100            } else {
4101                (
4102                    "Mute",
4103                    ConferenceListAction::Mute {
4104                        conference_id,
4105                        participant_id: participant.participant_id,
4106                    },
4107                )
4108            };
4109            actions.push((toggle_name, toggle));
4110            if removable {
4111                actions.push((
4112                    "Remove",
4113                    ConferenceListAction::Remove {
4114                        conference_id,
4115                        participant_id: participant.participant_id,
4116                    },
4117                ));
4118            }
4119            actions.push((
4120                "Promote",
4121                ConferenceListAction::Promote {
4122                    conference_id,
4123                    participant_id: participant.participant_id,
4124                },
4125            ));
4126        }
4127        let title = format!("Participant {}", participant.participant_id.get());
4128        match family {
4129            ConferenceMenuFamily::Menu => CiscoIpPhoneMenu::new(
4130                title,
4131                "Choose an action",
4132                actions
4133                    .into_iter()
4134                    .map(|(name, action)| CiscoIpPhoneMenuItem {
4135                        name: Some(name.into()),
4136                        url: Some(action.url()),
4137                    })
4138                    .collect(),
4139            )
4140            .map(Self::Menu),
4141            ConferenceMenuFamily::IconMenu => CiscoIpPhoneIconMenu::new(
4142                title,
4143                "Choose an action",
4144                actions
4145                    .into_iter()
4146                    .map(|(name, action)| CiscoIpPhoneIconMenuItem {
4147                        name: Some(name.into()),
4148                        url: Some(action.url()),
4149                        icon_index: None,
4150                    })
4151                    .collect(),
4152                Vec::new(),
4153            )
4154            .map(Self::IconMenu),
4155        }
4156    }
4157
4158    /// Serializes the rendered menu within [`CONFERENCE_LIST_MAX_BYTES`].
4159    pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
4160        match self {
4161            Self::Menu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
4162            Self::IconMenu(document) => document.to_xml_with_limit(CONFERENCE_LIST_MAX_BYTES),
4163        }
4164    }
4165
4166    /// Parses participant actions using the caller-selected menu family.
4167    pub fn from_xml(document: &[u8], family: ConferenceMenuFamily) -> Result<Self, PhoneXmlError> {
4168        match family {
4169            ConferenceMenuFamily::Menu => {
4170                CiscoIpPhoneMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
4171                    .map(Self::Menu)
4172            }
4173            ConferenceMenuFamily::IconMenu => {
4174                CiscoIpPhoneIconMenu::from_xml_with_limit(document, CONFERENCE_LIST_MAX_BYTES)
4175                    .map(Self::IconMenu)
4176            }
4177        }
4178    }
4179
4180    /// Iterates recognized callback actions in display order.
4181    pub fn actions(&self) -> impl Iterator<Item = ConferenceListAction> + '_ {
4182        let urls: Box<dyn Iterator<Item = &str>> = match self {
4183            Self::Menu(document) => {
4184                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
4185            }
4186            Self::IconMenu(document) => {
4187                Box::new(document.items.iter().filter_map(|item| item.url.as_deref()))
4188            }
4189        };
4190        urls.filter_map(ConferenceListAction::parse)
4191    }
4192}
4193
4194fn conference_participant_label(participant: &ConferenceListEntry) -> String {
4195    let identity = if !participant.name.trim().is_empty() {
4196        participant.name.trim()
4197    } else if !participant.number.trim().is_empty() {
4198        participant.number.trim()
4199    } else {
4200        "Unknown participant"
4201    };
4202    let role = if participant.moderator {
4203        "Moderator"
4204    } else {
4205        "Participant"
4206    };
4207    let mute = if participant.muted { ", muted" } else { "" };
4208    format!("{identity} ({role}{mute})")
4209}
4210
4211fn conference_icons() -> Vec<CiscoIpPhoneIconItem> {
4212    vec![
4213        CiscoIpPhoneIconItem {
4214            index: 0,
4215            width: 10,
4216            height: 10,
4217            depth: 2,
4218            data: Some("00000000000000000000000000".into()),
4219        },
4220        CiscoIpPhoneIconItem {
4221            index: 1,
4222            width: 10,
4223            height: 10,
4224            depth: 2,
4225            data: Some("00000155415555554155000000".into()),
4226        },
4227    ]
4228}
4229
4230/// Parses bounded UTF-8, US-ASCII, or ISO-8859-1 XML using the encoding named
4231/// in the declaration. The XML reader, rather than an ad-hoc byte scan, owns
4232/// decoding, entity policy, and nesting validation.
4233pub fn from_bytes<T: DeserializeOwned>(
4234    document: &[u8],
4235    maximum_bytes: usize,
4236) -> Result<T, PhoneXmlError> {
4237    if document.len() > maximum_bytes {
4238        return Err(PhoneXmlError::LimitExceeded {
4239            kind: "phone XML document",
4240            actual: document.len(),
4241            maximum: maximum_bytes,
4242        });
4243    }
4244    // Without an explicit legacy declaration, raw non-UTF-8 input remains a
4245    // typed UTF-8 failure. quick-xml handles the declared ASCII/ISO decoder;
4246    // this guard prevents arbitrary malformed bytes from being reported as a
4247    // less useful generic deserialization error.
4248    if let Err(error) = std::str::from_utf8(document)
4249        && !declares_iso_8859_1(document)
4250    {
4251        return Err(PhoneXmlError::InvalidUtf8(error));
4252    }
4253    reject_document_type(document)?;
4254    quick_xml::de::from_reader(decoding_reader(document)).map_err(PhoneXmlError::Deserialize)
4255}
4256
4257fn decoding_reader(document: &[u8]) -> quick_xml::encoding::DecodingReader<&[u8]> {
4258    let mut decoder = quick_xml::encoding::DecodingReader::new(document);
4259    let mut declaration_reader = Reader::from_reader(document);
4260    if let Ok(Event::Decl(declaration)) = declaration_reader.read_event()
4261        && declaration
4262            .encoding()
4263            .and_then(Result::ok)
4264            .is_some_and(|encoding| encoding.eq_ignore_ascii_case("iso-8859-1"))
4265        && let Some(encoding) = declaration.encoder()
4266    {
4267        decoder.set_encoding(encoding);
4268    }
4269    decoder
4270}
4271
4272fn declares_iso_8859_1(document: &[u8]) -> bool {
4273    let mut reader = Reader::from_reader(document);
4274    let Ok(Event::Decl(declaration)) = reader.read_event() else {
4275        return false;
4276    };
4277    declaration
4278        .encoding()
4279        .and_then(Result::ok)
4280        .is_some_and(|encoding| encoding.eq_ignore_ascii_case("iso-8859-1"))
4281}
4282
4283/// Serializes a known Serde model and rejects an oversized result.
4284pub fn to_string<T: Serialize>(
4285    document: &T,
4286    maximum_bytes: usize,
4287) -> Result<String, PhoneXmlError> {
4288    let xml = quick_xml::se::to_string(document).map_err(PhoneXmlError::Serialize)?;
4289    if xml.len() > maximum_bytes {
4290        return Err(PhoneXmlError::LimitExceeded {
4291            kind: "phone XML document",
4292            actual: xml.len(),
4293            maximum: maximum_bytes,
4294        });
4295    }
4296    Ok(xml)
4297}
4298
4299/// Serializes through the bounded string boundary before touching the writer.
4300pub fn to_writer<T: Serialize>(
4301    mut writer: impl fmt::Write,
4302    document: &T,
4303    maximum_bytes: usize,
4304) -> Result<(), PhoneXmlError> {
4305    let xml = to_string(document, maximum_bytes)?;
4306    writer.write_str(&xml).map_err(PhoneXmlError::Write)
4307}
4308
4309fn reject_document_type(document: &[u8]) -> Result<(), PhoneXmlError> {
4310    let mut reader = Reader::from_reader(decoding_reader(document));
4311    let mut buffer = Vec::new();
4312    let mut depth = 0usize;
4313    loop {
4314        match reader.read_event_into(&mut buffer) {
4315            Ok(Event::DocType(_)) => return Err(PhoneXmlError::DocumentTypeForbidden),
4316            Ok(Event::Start(element)) => {
4317                validate_xml_attributes(&element)?;
4318                depth = depth.saturating_add(1);
4319                if depth > PHONE_XML_MAX_NESTING_DEPTH {
4320                    return Err(PhoneXmlError::NestingTooDeep {
4321                        maximum: PHONE_XML_MAX_NESTING_DEPTH,
4322                    });
4323                }
4324            }
4325            Ok(Event::Empty(element)) => validate_xml_attributes(&element)?,
4326            Ok(Event::GeneralRef(reference)) => {
4327                let reference = reference.xml_content(XmlVersion::Implicit1_0);
4328                let escaped = format!("&{reference};");
4329                let resolved = quick_xml::escape::unescape(&escaped)
4330                    .map_err(|_| PhoneXmlError::InvalidEntity)?;
4331                if !has_only_xml_characters(&resolved) {
4332                    return Err(PhoneXmlError::InvalidEntity);
4333                }
4334            }
4335            Ok(Event::Text(text)) => {
4336                let text = text.xml_content(XmlVersion::Implicit1_0);
4337                if !has_only_xml_characters(&text) {
4338                    return Err(PhoneXmlError::InvalidEntity);
4339                }
4340            }
4341            Ok(Event::CData(text)) => {
4342                let text = text.xml_content(XmlVersion::Implicit1_0);
4343                if !has_only_xml_characters(&text) {
4344                    return Err(PhoneXmlError::InvalidEntity);
4345                }
4346            }
4347            Ok(Event::End(_)) => depth = depth.saturating_sub(1),
4348            Ok(Event::Eof) => return Ok(()),
4349            Ok(_) => {}
4350            Err(error) => return Err(PhoneXmlError::Malformed(error)),
4351        }
4352        buffer.clear();
4353    }
4354}
4355
4356fn validate_xml_attributes(
4357    element: &quick_xml::events::BytesStart<'_>,
4358) -> Result<(), PhoneXmlError> {
4359    for attribute in element.attributes() {
4360        let attribute = attribute
4361            .map_err(quick_xml::Error::from)
4362            .map_err(PhoneXmlError::Malformed)?;
4363        let value = attribute
4364            .normalized_value(XmlVersion::Implicit1_0)
4365            .map_err(|_| PhoneXmlError::InvalidEntity)?;
4366        if !has_only_xml_characters(&value) {
4367            return Err(PhoneXmlError::InvalidEntity);
4368        }
4369    }
4370    Ok(())
4371}
4372
4373macro_rules! impl_validated_string_value {
4374    ($($value:ty),+ $(,)?) => {
4375        $(
4376            impl AsRef<str> for $value {
4377                fn as_ref(&self) -> &str {
4378                    self.as_str()
4379                }
4380            }
4381
4382            impl TryFrom<String> for $value {
4383                type Error = PhoneXmlError;
4384
4385                fn try_from(value: String) -> Result<Self, Self::Error> {
4386                    Self::new(value)
4387                }
4388            }
4389
4390            impl FromStr for $value {
4391                type Err = PhoneXmlError;
4392
4393                fn from_str(value: &str) -> Result<Self, Self::Err> {
4394                    Self::new(value)
4395                }
4396            }
4397        )+
4398    };
4399}
4400
4401impl_validated_string_value!(
4402    PhoneInputParameterName,
4403    PhoneExecuteUrl,
4404    PhoneImageUrl,
4405    PhoneBackgroundTftpUrl,
4406    PhoneBackgroundHttpUrl,
4407    PhoneRingtoneUrl,
4408);
4409
4410#[cfg(test)]
4411mod tests {
4412    use super::*;
4413
4414    #[test]
4415    fn document_contract_rejects_a_valid_but_wrong_schema_root() {
4416        let menu =
4417            br#"<CiscoIPPhoneMenu><Title>Menu</Title><Prompt>Choose</Prompt></CiscoIPPhoneMenu>"#;
4418
4419        assert!(matches!(
4420            CiscoIpPhoneText::from_xml(menu),
4421            Err(PhoneXmlError::InvalidField {
4422                field: "phone XML document root",
4423                ..
4424            })
4425        ));
4426    }
4427
4428    #[test]
4429    fn typed_boundary_round_trips_escaped_menu_text() {
4430        let expected = CiscoIpPhoneMenu::new(
4431            "Support <East> & West",
4432            "Choose \"one\"",
4433            vec![CiscoIpPhoneMenuItem {
4434                name: Some("Alice & Bob".into()),
4435                url: Some("UserData:1:0:select/701?lot=east&side=west".into()),
4436            }],
4437        )
4438        .unwrap();
4439        let xml = to_string(&expected, 2_000).unwrap();
4440        assert!(xml.contains("Support &lt;East&gt; &amp; West"));
4441        assert_eq!(
4442            from_bytes::<CiscoIpPhoneMenu>(xml.as_bytes(), 2_000).unwrap(),
4443            expected
4444        );
4445    }
4446
4447    #[test]
4448    fn typed_boundary_rejects_size_utf8_doctype_entities_and_malformed_xml() {
4449        assert!(matches!(
4450            from_bytes::<CiscoIpPhoneMenu>(b"<CiscoIPPhoneMenu/>", 5),
4451            Err(PhoneXmlError::LimitExceeded { .. })
4452        ));
4453        assert!(matches!(
4454            from_bytes::<CiscoIpPhoneMenu>(&[0xff], 5),
4455            Err(PhoneXmlError::InvalidUtf8(_))
4456        ));
4457        let dtd = br#"<!DOCTYPE menu [<!ENTITY name "caller">]><CiscoIPPhoneMenu><Title>&name;</Title><Prompt/></CiscoIPPhoneMenu>"#;
4458        assert!(matches!(
4459            from_bytes::<CiscoIpPhoneMenu>(dtd, 2_000),
4460            Err(PhoneXmlError::DocumentTypeForbidden)
4461        ));
4462        let external = br#"<!DOCTYPE menu SYSTEM "file:///untrusted/menu.dtd"><CiscoIPPhoneMenu><Title/><Prompt/></CiscoIPPhoneMenu>"#;
4463        assert!(matches!(
4464            from_bytes::<CiscoIpPhoneMenu>(external, 2_000),
4465            Err(PhoneXmlError::DocumentTypeForbidden)
4466        ));
4467        assert!(matches!(
4468            from_bytes::<CiscoIpPhoneMenu>(
4469                b"<CiscoIPPhoneMenu><Title>&custom;</Title><Prompt/></CiscoIPPhoneMenu>",
4470                2_000,
4471            ),
4472            Err(PhoneXmlError::InvalidEntity)
4473        ));
4474        assert!(from_bytes::<CiscoIpPhoneMenu>(b"<CiscoIPPhoneMenu>", 2_000).is_err());
4475
4476        let mut oversized = CiscoIpPhoneMenu::new("Menu", "Choose", Vec::new()).unwrap();
4477        oversized.title = Some("x".repeat(100));
4478        assert!(matches!(
4479            to_string(&oversized, 10),
4480            Err(PhoneXmlError::LimitExceeded { .. })
4481        ));
4482    }
4483
4484    fn complete_text_document() -> CiscoIpPhoneText {
4485        CiscoIpPhoneText {
4486            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
4487            application_id: Some("text-service".into()),
4488            on_focus_lost: Some("Notify:focus?state=lost&view=text".into()),
4489            on_focus_gained: Some("Notify:focus?state=gained".into()),
4490            on_minimized: Some("Notify:minimized".into()),
4491            on_closed: Some("Notify:closed".into()),
4492            title: Some("Message <East> & West".into()),
4493            prompt: Some("Read & refresh".into()),
4494            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
4495                name: Some("Refresh".into()),
4496                position: PhoneSoftKeyPosition::new(1).unwrap(),
4497                url: Some("https://pbx.example/text?id=7&view=full".into()),
4498                url_down: Some("SoftKey:Update".into()),
4499            }],
4500            key_items: vec![CiscoIpPhoneKeyItem {
4501                key: PhoneXmlKey::NavBack,
4502                url: Some("SoftKey:Exit".into()),
4503                url_down: None,
4504            }],
4505            text: Some("Line one\nCafé <ready> & waiting\t✓".into()),
4506        }
4507    }
4508
4509    #[test]
4510    fn text_document_round_trips_controls_order_utf8_and_escaping() {
4511        let expected = complete_text_document();
4512        let xml = expected.to_xml().unwrap();
4513        assert!(xml.contains("Message &lt;East&gt; &amp; West"));
4514        assert!(xml.contains("Café &lt;ready&gt; &amp; waiting"));
4515        assert!(xml.contains("id=7&amp;view=full"));
4516        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
4517        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<Text>").unwrap());
4518        assert_eq!(
4519            CiscoIpPhoneText::from_xml(xml.as_bytes()).unwrap(),
4520            expected
4521        );
4522
4523        let minimal = CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText/>").unwrap();
4524        assert!(minimal.text.is_none());
4525        let empty =
4526            CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText><Text></Text></CiscoIPPhoneText>")
4527                .unwrap();
4528        assert_eq!(empty.text.as_deref(), Some(""));
4529    }
4530
4531    #[test]
4532    fn text_document_enforces_body_control_soft_key_and_refresh_bounds() {
4533        let exact = CiscoIpPhoneText::new("Title", "Prompt", "é".repeat(PHONE_TEXT_MAX_CHARS));
4534        assert!(exact.is_ok());
4535        assert!(matches!(
4536            CiscoIpPhoneText::new("Title", "Prompt", "x".repeat(PHONE_TEXT_MAX_CHARS + 1),),
4537            Err(PhoneXmlError::InvalidField {
4538                field: "phone text body",
4539                ..
4540            })
4541        ));
4542        let mut invalid = complete_text_document();
4543        invalid.text = Some("not\u{1} XML".into());
4544        assert!(matches!(
4545            invalid.to_xml(),
4546            Err(PhoneXmlError::InvalidField {
4547                field: "phone text body",
4548                ..
4549            })
4550        ));
4551        invalid = complete_text_document();
4552        invalid.soft_keys[0].position = PhoneSoftKeyPosition::new(16).unwrap();
4553        assert!(invalid.to_xml().is_ok());
4554        invalid = complete_text_document();
4555        invalid.soft_keys[0].url = Some("x".repeat(PHONE_XML_URL_MAX_CHARS + 1));
4556        assert!(matches!(
4557            invalid.to_xml(),
4558            Err(PhoneXmlError::InvalidField { .. })
4559        ));
4560
4561        assert_eq!(PhoneServicePriority::LOW.wire(), 0);
4562        assert_eq!(PhoneServicePriority::NORMAL.wire(), 1);
4563        assert_eq!(PhoneServicePriority::HIGH.wire(), 2);
4564        assert_eq!(
4565            PhoneServicePriority::default(),
4566            PhoneServicePriority::NORMAL
4567        );
4568        assert!(PhoneServicePriority::new(3).is_err());
4569        let refresh = PhoneXmlRefresh::new(15, "https://pbx.example/text?page=2").unwrap();
4570        assert_eq!(refresh.delay_seconds(), 15);
4571        assert_eq!(refresh.url(), "https://pbx.example/text?page=2");
4572        assert_eq!(
4573            refresh.http_header_value(),
4574            "15;url=https://pbx.example/text?page=2"
4575        );
4576        assert!(PhoneXmlRefresh::new(0, "").is_err());
4577        assert!(PhoneXmlRefresh::new(0, "x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
4578        assert!(PhoneXmlRefresh::new(0, "https://example.test/é").is_err());
4579        assert!(PhoneXmlRefresh::new(0, "https://example.test/not encoded").is_err());
4580        assert!(PhoneXmlRefresh::new(0, "https://example.test/\r\nInjected: yes").is_err());
4581    }
4582
4583    #[test]
4584    fn text_parser_rejects_wrong_root_malformed_oversize_nesting_dtd_and_entities() {
4585        assert!(CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
4586        assert!(
4587            CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText><Unknown/></CiscoIPPhoneText>",)
4588                .is_err()
4589        );
4590        assert!(CiscoIpPhoneText::from_xml(b"<CiscoIPPhoneText><Text>").is_err());
4591        assert!(matches!(
4592            CiscoIpPhoneText::from_xml(&[0xff]),
4593            Err(PhoneXmlError::InvalidUtf8(_))
4594        ));
4595        assert!(matches!(
4596            CiscoIpPhoneText::from_xml(
4597                b"<!DOCTYPE text [<!ENTITY value 'secret'>]><CiscoIPPhoneText><Text>&value;</Text></CiscoIPPhoneText>",
4598            ),
4599            Err(PhoneXmlError::DocumentTypeForbidden)
4600        ));
4601        assert!(
4602            CiscoIpPhoneText::from_xml(
4603                b"<CiscoIPPhoneText><Text>&unknown;</Text></CiscoIPPhoneText>",
4604            )
4605            .is_err()
4606        );
4607        assert!(matches!(
4608            complete_text_document().to_xml_with_limit(10),
4609            Err(PhoneXmlError::LimitExceeded { .. })
4610        ));
4611
4612        let nested = format!(
4613            "<CiscoIPPhoneText>{}<Text>body</Text>{}</CiscoIPPhoneText>",
4614            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
4615            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
4616        );
4617        assert!(matches!(
4618            CiscoIpPhoneText::from_xml(nested.as_bytes()),
4619            Err(PhoneXmlError::NestingTooDeep { .. })
4620        ));
4621
4622        #[derive(Debug)]
4623        struct FailingWriter;
4624        impl fmt::Write for FailingWriter {
4625            fn write_str(&mut self, _value: &str) -> fmt::Result {
4626                Err(fmt::Error)
4627            }
4628        }
4629        assert!(matches!(
4630            to_writer(
4631                FailingWriter,
4632                &complete_text_document(),
4633                PHONE_TEXT_MAX_BYTES,
4634            ),
4635            Err(PhoneXmlError::Write(_))
4636        ));
4637    }
4638
4639    fn complete_input_document() -> CiscoIpPhoneInput {
4640        CiscoIpPhoneInput {
4641            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
4642            application_id: Some("conference-invite".into()),
4643            on_focus_lost: Some("Notify:input?focus=lost&view=invite".into()),
4644            on_focus_gained: Some("Notify:input?focus=gained".into()),
4645            on_minimized: Some("Notify:input?state=minimized".into()),
4646            on_closed: Some("Notify:input?state=closed".into()),
4647            title: Some("Invite <guest>".into()),
4648            prompt: Some("Enter name & number".into()),
4649            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
4650                name: Some("Submit".into()),
4651                position: PhoneSoftKeyPosition::new(1).unwrap(),
4652                url: Some("SoftKey:Submit".into()),
4653                url_down: Some("Notify:submit?state=down".into()),
4654            }],
4655            key_items: vec![CiscoIpPhoneKeyItem {
4656                key: PhoneXmlKey::NavBack,
4657                url: Some("SoftKey:Exit".into()),
4658                url_down: None,
4659            }],
4660            url: "UserData:9091:0:conference/7/invite?source=phone&mode=full".into(),
4661            items: vec![
4662                CiscoIpPhoneInputItem {
4663                    display_name: Some("Number".into()),
4664                    parameter: PhoneInputParameterName::new("NUMBER").unwrap(),
4665                    flags: PhoneInputFlags::Telephone,
4666                    default_value: Some("+1 555 0100".into()),
4667                },
4668                CiscoIpPhoneInputItem {
4669                    display_name: Some("Name & team".into()),
4670                    parameter: PhoneInputParameterName::new("NAME&TEAM").unwrap(),
4671                    flags: PhoneInputFlags::AlphabeticPassword,
4672                    default_value: Some("Café <guest>".into()),
4673                },
4674            ],
4675        }
4676    }
4677
4678    #[test]
4679    fn input_document_round_trips_every_control_in_schema_order_and_escapes_values() {
4680        let expected = complete_input_document();
4681        let xml = expected.to_xml().unwrap();
4682        assert!(xml.contains("Invite &lt;guest&gt;"));
4683        assert!(xml.contains("Enter name &amp; number"));
4684        assert!(xml.contains("NAME&amp;TEAM"));
4685        assert!(xml.contains("Café &lt;guest&gt;"));
4686        assert!(xml.contains("source=phone&amp;mode=full"));
4687        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
4688        let submission = xml.find("<URL>UserData:").unwrap();
4689        assert!(xml.find("<KeyItem>").unwrap() < submission);
4690        assert!(submission < xml.find("<InputItem>").unwrap());
4691        assert_eq!(
4692            CiscoIpPhoneInput::from_xml(xml.as_bytes()).unwrap(),
4693            expected
4694        );
4695
4696        let minimal = CiscoIpPhoneInput::from_xml(
4697            b"<CiscoIPPhoneInput><URL>submit</URL></CiscoIPPhoneInput>",
4698        )
4699        .unwrap();
4700        assert!(minimal.items.is_empty());
4701        assert_eq!(minimal.url, "submit");
4702    }
4703
4704    #[test]
4705    fn input_flags_round_trip_every_accepted_schema_value() {
4706        let codes = [
4707            "A", "T", "N", "E", "U", "L", "AP", "TP", "NP", "EP", "UP", "LP", "PA", "PT", "PN",
4708            "PE", "PU", "PL",
4709        ];
4710        for (flags, code) in PhoneInputFlags::ALL.into_iter().zip(codes) {
4711            let document = CiscoIpPhoneInput::new(
4712                "Input",
4713                "Enter value",
4714                "submit",
4715                vec![CiscoIpPhoneInputItem {
4716                    display_name: None,
4717                    parameter: PhoneInputParameterName::new("VALUE").unwrap(),
4718                    flags,
4719                    default_value: Some(String::new()),
4720                }],
4721            )
4722            .unwrap();
4723            let xml = document.to_xml().unwrap();
4724            assert!(xml.contains(&format!("<InputFlags>{code}</InputFlags>")));
4725            assert_eq!(
4726                CiscoIpPhoneInput::from_xml(xml.as_bytes()).unwrap(),
4727                document
4728            );
4729        }
4730    }
4731
4732    #[test]
4733    fn input_document_enforces_field_collection_and_display_bounds() {
4734        assert!(PhoneInputParameterName::new("").is_err());
4735        assert!(PhoneInputParameterName::new("x".repeat(33)).is_err());
4736        assert!(PhoneInputParameterName::new("not\u{1}xml").is_err());
4737
4738        let exact = CiscoIpPhoneInput::new(
4739            "t".repeat(32),
4740            "p".repeat(32),
4741            "u".repeat(PHONE_XML_URL_MAX_CHARS),
4742            vec![CiscoIpPhoneInputItem {
4743                display_name: Some("n".repeat(32)),
4744                parameter: PhoneInputParameterName::new("q".repeat(32)).unwrap(),
4745                flags: PhoneInputFlags::Numeric,
4746                default_value: Some("d".repeat(32)),
4747            }],
4748        );
4749        assert!(exact.is_ok());
4750
4751        let too_many = (0..=PHONE_INPUT_MAX_ITEMS)
4752            .map(|index| CiscoIpPhoneInputItem {
4753                display_name: None,
4754                parameter: PhoneInputParameterName::new(format!("VALUE{index}")).unwrap(),
4755                flags: PhoneInputFlags::Alphabetic,
4756                default_value: None,
4757            })
4758            .collect();
4759        assert!(matches!(
4760            CiscoIpPhoneInput::new("Input", "Prompt", "submit", too_many),
4761            Err(PhoneXmlError::LimitExceeded {
4762                kind: "phone input fields",
4763                maximum: PHONE_INPUT_MAX_ITEMS,
4764                ..
4765            })
4766        ));
4767
4768        for invalid in [
4769            CiscoIpPhoneInput::new("x".repeat(33), "Prompt", "submit", Vec::new()),
4770            CiscoIpPhoneInput::new("Input", "x".repeat(33), "submit", Vec::new()),
4771            CiscoIpPhoneInput::new("Input", "Prompt", "", Vec::new()),
4772            CiscoIpPhoneInput::new(
4773                "Input",
4774                "Prompt",
4775                "x".repeat(PHONE_XML_URL_MAX_CHARS + 1),
4776                Vec::new(),
4777            ),
4778        ] {
4779            assert!(invalid.is_err());
4780        }
4781
4782        let mut invalid = complete_input_document();
4783        invalid.items[0].display_name = Some("x".repeat(33));
4784        assert!(invalid.to_xml().is_err());
4785        invalid = complete_input_document();
4786        invalid.items[0].default_value = Some("x".repeat(33));
4787        assert!(invalid.to_xml().is_err());
4788        assert!(PhoneSoftKeyPosition::new(0).is_err());
4789    }
4790
4791    #[test]
4792    fn input_parser_rejects_wrong_root_unknown_flag_malformed_and_unsafe_documents() {
4793        assert!(CiscoIpPhoneInput::from_xml(b"<CiscoIPPhoneText/>").is_err());
4794        assert!(CiscoIpPhoneInput::from_xml(b"<CiscoIPPhoneInput/>").is_err());
4795        assert!(
4796            CiscoIpPhoneInput::from_xml(
4797                b"<CiscoIPPhoneInput><Unknown/><URL>submit</URL></CiscoIPPhoneInput>"
4798            )
4799            .is_err()
4800        );
4801        assert!(CiscoIpPhoneInput::from_xml(
4802            b"<CiscoIPPhoneInput><URL>submit</URL><InputItem><QueryStringParam>q</QueryStringParam><InputFlags>Q</InputFlags></InputItem></CiscoIPPhoneInput>"
4803        )
4804        .is_err());
4805        assert!(CiscoIpPhoneInput::from_xml(b"<CiscoIPPhoneInput><URL>").is_err());
4806        assert!(matches!(
4807            CiscoIpPhoneInput::from_xml(&[0xff]),
4808            Err(PhoneXmlError::InvalidUtf8(_))
4809        ));
4810        assert!(matches!(
4811            CiscoIpPhoneInput::from_xml(
4812                b"<!DOCTYPE input [<!ENTITY value 'secret'>]><CiscoIPPhoneInput><URL>&value;</URL></CiscoIPPhoneInput>",
4813            ),
4814            Err(PhoneXmlError::DocumentTypeForbidden)
4815        ));
4816        assert!(
4817            CiscoIpPhoneInput::from_xml(
4818                b"<CiscoIPPhoneInput><URL>&unknown;</URL></CiscoIPPhoneInput>"
4819            )
4820            .is_err()
4821        );
4822        assert!(matches!(
4823            complete_input_document().to_xml_with_limit(10),
4824            Err(PhoneXmlError::LimitExceeded { .. })
4825        ));
4826        let encoded = complete_input_document().to_xml().unwrap();
4827        assert!(matches!(
4828            CiscoIpPhoneInput::from_xml_with_limit(encoded.as_bytes(), 10),
4829            Err(PhoneXmlError::LimitExceeded { .. })
4830        ));
4831
4832        let nested = format!(
4833            "<CiscoIPPhoneInput>{}<URL>submit</URL>{}</CiscoIPPhoneInput>",
4834            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
4835            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
4836        );
4837        assert!(matches!(
4838            CiscoIpPhoneInput::from_xml(nested.as_bytes()),
4839            Err(PhoneXmlError::NestingTooDeep { .. })
4840        ));
4841
4842        #[derive(Debug)]
4843        struct FailingWriter;
4844        impl fmt::Write for FailingWriter {
4845            fn write_str(&mut self, _value: &str) -> fmt::Result {
4846                Err(fmt::Error)
4847            }
4848        }
4849        assert!(matches!(
4850            to_writer(
4851                FailingWriter,
4852                &complete_input_document(),
4853                PHONE_INPUT_MAX_BYTES,
4854            ),
4855            Err(PhoneXmlError::Write(_))
4856        ));
4857    }
4858
4859    fn complete_execute_document() -> CiscoIpPhoneExecute {
4860        CiscoIpPhoneExecute::new(vec![
4861            CiscoIpPhoneExecuteItem::with_priority(
4862                "Key:Directories?name=Café&view=<all>",
4863                PhoneExecutePriority::LOW,
4864            )
4865            .unwrap(),
4866            CiscoIpPhoneExecuteItem::with_priority(
4867                "Application:PlacedCalls",
4868                PhoneExecutePriority::HIGH,
4869            )
4870            .unwrap(),
4871            CiscoIpPhoneExecuteItem::new("Init:Services").unwrap(),
4872        ])
4873        .unwrap()
4874    }
4875
4876    #[test]
4877    fn execute_document_round_trips_order_optional_priority_utf8_and_escaping() {
4878        let expected = complete_execute_document();
4879        let xml = expected.to_xml().unwrap();
4880        assert!(xml.starts_with("<CiscoIPPhoneExecute>"));
4881        assert!(xml.contains(
4882            r#"<ExecuteItem Priority="0" URL="Key:Directories?name=Café&amp;view=&lt;all&gt;"/>"#
4883        ));
4884        assert!(xml.contains(r#"<ExecuteItem Priority="2" URL="Application:PlacedCalls"/>"#));
4885        assert!(xml.contains(r#"<ExecuteItem URL="Init:Services"/>"#));
4886        assert_eq!(
4887            CiscoIpPhoneExecute::from_xml(xml.as_bytes()).unwrap(),
4888            expected
4889        );
4890        assert_eq!(
4891            expected
4892                .items
4893                .iter()
4894                .map(|item| item.url.as_str())
4895                .collect::<Vec<_>>(),
4896            [
4897                "Key:Directories?name=Café&view=<all>",
4898                "Application:PlacedCalls",
4899                "Init:Services",
4900            ]
4901        );
4902    }
4903
4904    #[test]
4905    fn execute_document_enforces_action_priority_url_and_collection_bounds() {
4906        assert_eq!(PhoneExecutePriority::LOW.wire(), 0);
4907        assert_eq!(PhoneExecutePriority::NORMAL.wire(), 1);
4908        assert_eq!(PhoneExecutePriority::HIGH.wire(), 2);
4909        assert!(PhoneExecutePriority::new(3).is_err());
4910        assert!(PhoneExecuteUrl::new("").is_err());
4911        assert!(PhoneExecuteUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
4912        assert!(PhoneExecuteUrl::new("not\u{1}xml").is_err());
4913
4914        assert!(matches!(
4915            CiscoIpPhoneExecute::new(Vec::new()),
4916            Err(PhoneXmlError::InvalidField {
4917                field: "phone execute actions",
4918                ..
4919            })
4920        ));
4921        let maximum = (0..PHONE_EXECUTE_MAX_ITEMS)
4922            .map(|index| CiscoIpPhoneExecuteItem::new(format!("Key:KeyPad{index}")).unwrap())
4923            .collect();
4924        assert!(CiscoIpPhoneExecute::new(maximum).is_ok());
4925        assert!(matches!(
4926            CiscoIpPhoneExecute::new(vec![
4927                CiscoIpPhoneExecuteItem::new("https://example.test/one").unwrap(),
4928                CiscoIpPhoneExecuteItem::new("http://example.test/two").unwrap(),
4929            ]),
4930            Err(PhoneXmlError::InvalidField {
4931                field: "phone execute HTTP actions",
4932                ..
4933            })
4934        ));
4935        let too_many = (0..=PHONE_EXECUTE_MAX_ITEMS)
4936            .map(|index| CiscoIpPhoneExecuteItem::new(format!("Key:KeyPad{index}")).unwrap())
4937            .collect();
4938        assert!(matches!(
4939            CiscoIpPhoneExecute::new(too_many),
4940            Err(PhoneXmlError::LimitExceeded {
4941                kind: "phone execute actions",
4942                maximum: PHONE_EXECUTE_MAX_ITEMS,
4943                ..
4944            })
4945        ));
4946    }
4947
4948    #[test]
4949    fn execute_parser_rejects_wrong_root_malformed_unsafe_and_oversized_documents() {
4950        assert!(CiscoIpPhoneExecute::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
4951        assert!(CiscoIpPhoneExecute::from_xml(b"<CiscoIPPhoneExecute/>").is_err());
4952        assert!(CiscoIpPhoneExecute::from_xml(
4953            br#"<CiscoIPPhoneExecute><ExecuteItem Priority="3" URL="Init:Services"/></CiscoIPPhoneExecute>"#
4954        )
4955        .is_err());
4956        assert!(
4957            CiscoIpPhoneExecute::from_xml(
4958                br#"<CiscoIPPhoneExecute><ExecuteItem Priority="0"/></CiscoIPPhoneExecute>"#
4959            )
4960            .is_err()
4961        );
4962        assert!(
4963            CiscoIpPhoneExecute::from_xml(
4964                br#"<CiscoIPPhoneExecute><ExecuteItem URL=""/></CiscoIPPhoneExecute>"#
4965            )
4966            .is_err()
4967        );
4968        let oversized_url = format!(
4969            "<CiscoIPPhoneExecute><ExecuteItem URL=\"{}\"/></CiscoIPPhoneExecute>",
4970            "x".repeat(PHONE_XML_URL_MAX_CHARS + 1),
4971        );
4972        assert!(CiscoIpPhoneExecute::from_xml(oversized_url.as_bytes()).is_err());
4973        let too_many_actions = format!(
4974            "<CiscoIPPhoneExecute>{}</CiscoIPPhoneExecute>",
4975            r#"<ExecuteItem URL="Init:Services"/>"#.repeat(PHONE_EXECUTE_MAX_ITEMS + 1),
4976        );
4977        assert!(matches!(
4978            CiscoIpPhoneExecute::from_xml(too_many_actions.as_bytes()),
4979            Err(PhoneXmlError::LimitExceeded {
4980                kind: "phone execute actions",
4981                maximum: PHONE_EXECUTE_MAX_ITEMS,
4982                ..
4983            })
4984        ));
4985        assert!(CiscoIpPhoneExecute::from_xml(
4986            br#"<CiscoIPPhoneExecute><ExecuteItem Unknown="yes" URL="Init:Services"/></CiscoIPPhoneExecute>"#
4987        )
4988        .is_err());
4989        assert!(
4990            CiscoIpPhoneExecute::from_xml(
4991                b"<CiscoIPPhoneExecute><ExecuteItem URL=\"Init:Services\"></CiscoIPPhoneExecute>"
4992            )
4993            .is_err()
4994        );
4995        assert!(matches!(
4996            CiscoIpPhoneExecute::from_xml(&[0xff]),
4997            Err(PhoneXmlError::InvalidUtf8(_))
4998        ));
4999        assert!(matches!(
5000            CiscoIpPhoneExecute::from_xml(
5001                br#"<!DOCTYPE execute [<!ENTITY action "Init:Services">]><CiscoIPPhoneExecute><ExecuteItem URL="&action;"/></CiscoIPPhoneExecute>"#,
5002            ),
5003            Err(PhoneXmlError::DocumentTypeForbidden)
5004        ));
5005        assert!(
5006            CiscoIpPhoneExecute::from_xml(
5007                br#"<CiscoIPPhoneExecute><ExecuteItem URL="&unknown;"/></CiscoIPPhoneExecute>"#
5008            )
5009            .is_err()
5010        );
5011        let encoded = complete_execute_document().to_xml().unwrap();
5012        assert!(matches!(
5013            CiscoIpPhoneExecute::from_xml_with_limit(encoded.as_bytes(), 10),
5014            Err(PhoneXmlError::LimitExceeded { .. })
5015        ));
5016        assert!(matches!(
5017            complete_execute_document().to_xml_with_limit(10),
5018            Err(PhoneXmlError::LimitExceeded { .. })
5019        ));
5020
5021        let nested = format!(
5022            "<CiscoIPPhoneExecute>{}<ExecuteItem URL=\"Init:Services\"/>{}</CiscoIPPhoneExecute>",
5023            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5024            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5025        );
5026        assert!(matches!(
5027            CiscoIpPhoneExecute::from_xml(nested.as_bytes()),
5028            Err(PhoneXmlError::NestingTooDeep { .. })
5029        ));
5030
5031        #[derive(Debug)]
5032        struct FailingWriter;
5033        impl fmt::Write for FailingWriter {
5034            fn write_str(&mut self, _value: &str) -> fmt::Result {
5035                Err(fmt::Error)
5036            }
5037        }
5038        assert!(matches!(
5039            to_writer(
5040                FailingWriter,
5041                &complete_execute_document(),
5042                PHONE_EXECUTE_MAX_BYTES,
5043            ),
5044            Err(PhoneXmlError::Write(_))
5045        ));
5046    }
5047
5048    #[test]
5049    fn declared_iso_8859_1_input_decodes_before_schema_validation() {
5050        let mut document = br#"<?xml version="1.0" encoding = 'ISO-8859-1'?><CiscoIPPhoneExecute><ExecuteItem URL="Key:Caf"#
5051            .to_vec();
5052        document.push(0xe9);
5053        document.extend_from_slice(br#""/></CiscoIPPhoneExecute>"#);
5054        let parsed = CiscoIpPhoneExecute::from_xml(&document).unwrap();
5055        assert_eq!(parsed.items[0].url.as_str(), "Key:Café");
5056        assert!(matches!(
5057            CiscoIpPhoneExecute::from_xml(&[b'<', 0xe9, b'>']),
5058            Err(PhoneXmlError::InvalidUtf8(_))
5059        ));
5060    }
5061
5062    fn background_list_item(name: &str) -> CiscoIpPhoneImageListItem {
5063        CiscoIpPhoneImageListItem {
5064            thumbnail_url: PhoneBackgroundTftpUrl::new(format!(
5065                "TFTP:Desktops/320x212x16/TN-{name}.png"
5066            ))
5067            .unwrap(),
5068            image_url: PhoneBackgroundTftpUrl::new(format!("TFTP:Desktops/320x212x16/{name}.png"))
5069                .unwrap(),
5070        }
5071    }
5072
5073    #[test]
5074    fn background_image_list_round_trips_order_attributes_and_escaping() {
5075        let expected = CiscoIpPhoneImageList::new(vec![
5076            background_list_item("Fountain"),
5077            background_list_item("Moon&Stars"),
5078        ])
5079        .unwrap();
5080        let xml = expected.to_xml().unwrap();
5081        assert!(xml.starts_with("<CiscoIPPhoneImageList>"));
5082        assert!(xml.contains(
5083            r#"<ImageItem Image="TFTP:Desktops/320x212x16/TN-Fountain.png" URL="TFTP:Desktops/320x212x16/Fountain.png"/>"#
5084        ));
5085        assert!(xml.contains("TN-Moon&amp;Stars.png"));
5086        assert!(xml.find("Fountain.png").unwrap() < xml.find("Moon&amp;Stars.png").unwrap());
5087        assert_eq!(
5088            CiscoIpPhoneImageList::from_xml(xml.as_bytes()).unwrap(),
5089            expected
5090        );
5091
5092        let empty = CiscoIpPhoneImageList::from_xml(b"<CiscoIPPhoneImageList/>").unwrap();
5093        assert!(empty.items.is_empty());
5094    }
5095
5096    #[test]
5097    fn background_control_documents_round_trip_exact_evidenced_roots_and_order() {
5098        let image =
5099            PhoneBackgroundHttpUrl::new("http://pbx.example/background.png?site=east&screen=main")
5100                .unwrap();
5101        let thumbnail =
5102            PhoneBackgroundHttpUrl::new("http://pbx.example/background-thumb.png").unwrap();
5103        let set = CiscoIpPhoneSetBackground::new(image.clone(), thumbnail);
5104        let xml = set.to_xml().unwrap();
5105        assert_eq!(
5106            xml,
5107            "<setBackground><background><image>http://pbx.example/background.png?site=east&amp;screen=main</image><icon>http://pbx.example/background-thumb.png</icon></background></setBackground>"
5108        );
5109        assert_eq!(
5110            CiscoIpPhoneSetBackground::from_xml(xml.as_bytes()).unwrap(),
5111            set
5112        );
5113        assert_eq!(
5114            PhoneBackgroundControlDocument::from_xml(xml.as_bytes()).unwrap(),
5115            PhoneBackgroundControlDocument::Set(set)
5116        );
5117
5118        let preview = CiscoIpPhoneSetBackgroundPreview::new(image);
5119        let xml = preview.to_xml().unwrap();
5120        assert_eq!(
5121            xml,
5122            "<setBackgroundPreview><image>http://pbx.example/background.png?site=east&amp;screen=main</image></setBackgroundPreview>"
5123        );
5124        assert_eq!(
5125            CiscoIpPhoneSetBackgroundPreview::from_xml(xml.as_bytes()).unwrap(),
5126            preview
5127        );
5128        assert_eq!(
5129            PhoneBackgroundControlDocument::from_xml(xml.as_bytes()).unwrap(),
5130            PhoneBackgroundControlDocument::Preview(preview)
5131        );
5132    }
5133
5134    #[test]
5135    fn background_urls_enforce_transport_shape_length_and_secret_safe_errors() {
5136        assert_eq!(
5137            PhoneBackgroundTftpUrl::new("TFTP:Desktops/800x480x24/Picture.PNG")
5138                .unwrap()
5139                .as_str(),
5140            "TFTP:Desktops/800x480x24/Picture.PNG"
5141        );
5142        assert_eq!(
5143            PhoneBackgroundHttpUrl::new("http://[2001:db8::1]:8080/image.png?size=full")
5144                .unwrap()
5145                .as_str(),
5146            "http://[2001:db8::1]:8080/image.png?size=full"
5147        );
5148        for invalid in [
5149            "",
5150            "HTTP:Desktops/320x212x16/image.png",
5151            "TFTP://server/Desktops/image.png",
5152            "TFTP:/Desktops/image.png",
5153            "TFTP:Desktops/../image.png",
5154            "TFTP:Desktops/%2e%2e/image.png",
5155            "TFTP:Desktops/%2Fprivate/image.png",
5156            "TFTP:Desktops/%00private.png",
5157            "TFTP:Desktops/%Q0private.png",
5158            "TFTP:Desktops/image.jpg",
5159            "TFTP:Desktops/image.png?token=private",
5160            "TFTP:Desktops/image.png#private",
5161        ] {
5162            let error = PhoneBackgroundTftpUrl::new(invalid).unwrap_err();
5163            if !invalid.is_empty() {
5164                assert!(!error.to_string().contains(invalid));
5165            }
5166        }
5167        for invalid in [
5168            "",
5169            "https://pbx.example/private.png",
5170            "TFTP:Desktops/image.png",
5171            "background.png",
5172            "http://user:secret@pbx.example/private.png",
5173            "http://pbx.example/private.png#token",
5174        ] {
5175            let error = PhoneBackgroundHttpUrl::new(invalid).unwrap_err();
5176            if !invalid.is_empty() {
5177                assert!(!error.to_string().contains(invalid));
5178            }
5179        }
5180        assert!(PhoneBackgroundTftpUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
5181        assert!(PhoneBackgroundHttpUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
5182        assert!(PhoneBackgroundTftpUrl::new("TFTP:Desktops/not\u{1}xml.png").is_err());
5183        assert!(PhoneBackgroundHttpUrl::new("http://pbx.example/not\u{1}xml.png").is_err());
5184        assert!(
5185            !format!(
5186                "{:?}",
5187                PhoneBackgroundHttpUrl::new("http://private.example/secret.png").unwrap()
5188            )
5189            .contains("private.example")
5190        );
5191    }
5192
5193    #[test]
5194    fn background_image_list_enforces_collection_and_document_bounds() {
5195        let maximum = (0..PHONE_BACKGROUND_LIST_MAX_ITEMS)
5196            .map(|index| background_list_item(&format!("image-{index}")))
5197            .collect();
5198        assert!(CiscoIpPhoneImageList::new(maximum).is_ok());
5199
5200        let too_many = (0..=PHONE_BACKGROUND_LIST_MAX_ITEMS)
5201            .map(|index| background_list_item(&format!("image-{index}")))
5202            .collect();
5203        assert!(matches!(
5204            CiscoIpPhoneImageList::new(too_many),
5205            Err(PhoneXmlError::LimitExceeded {
5206                kind: "background image choices",
5207                maximum: PHONE_BACKGROUND_LIST_MAX_ITEMS,
5208                ..
5209            })
5210        ));
5211
5212        let document = CiscoIpPhoneImageList::new(vec![background_list_item("image")]).unwrap();
5213        assert!(matches!(
5214            document.to_xml_with_limit(10),
5215            Err(PhoneXmlError::LimitExceeded { .. })
5216        ));
5217        assert!(matches!(
5218            CiscoIpPhoneImageList::from_xml(&vec![b'x'; PHONE_BACKGROUND_LIST_MAX_BYTES + 1]),
5219            Err(PhoneXmlError::LimitExceeded { .. })
5220        ));
5221        let preview =
5222            PhoneBackgroundControlDocument::Preview(CiscoIpPhoneSetBackgroundPreview::new(
5223                PhoneBackgroundHttpUrl::new("http://pbx.example/image.png").unwrap(),
5224            ));
5225        assert!(matches!(
5226            preview.to_xml_with_limit(10),
5227            Err(PhoneXmlError::LimitExceeded { .. })
5228        ));
5229    }
5230
5231    #[test]
5232    fn background_parser_rejects_wrong_roots_unknowns_malformed_and_unsafe_xml() {
5233        for invalid in [
5234            b"<CiscoIPPhoneMenu/>".as_slice(),
5235            b"<CiscoIPPhoneImageList><ImageItem Image=\"TFTP:Desktops/TN.png\"/></CiscoIPPhoneImageList>".as_slice(),
5236            b"<CiscoIPPhoneImageList><ImageItem Image=\"TFTP:Desktops/TN.png\" URL=\"TFTP:Desktops/image.png\" Unknown=\"yes\"/></CiscoIPPhoneImageList>".as_slice(),
5237            b"<CiscoIPPhoneImageList>".as_slice(),
5238        ] {
5239            assert!(CiscoIpPhoneImageList::from_xml(invalid).is_err());
5240        }
5241        assert!(CiscoIpPhoneSetBackground::from_xml(
5242            b"<setBackgroundPreview><image>http://pbx.example/image.png</image></setBackgroundPreview>"
5243        )
5244        .is_err());
5245        assert!(CiscoIpPhoneSetBackgroundPreview::from_xml(
5246            b"<setBackgroundPreview><image>https://pbx.example/image.png</image></setBackgroundPreview>"
5247        )
5248        .is_err());
5249        assert!(PhoneBackgroundControlDocument::from_xml(b"<getDeviceCaps/>").is_err());
5250        assert!(matches!(
5251            CiscoIpPhoneImageList::from_xml(&[0xff]),
5252            Err(PhoneXmlError::InvalidUtf8(_))
5253        ));
5254        assert!(matches!(
5255            CiscoIpPhoneImageList::from_xml(
5256                br#"<!DOCTYPE images [<!ENTITY path "private">]><CiscoIPPhoneImageList><ImageItem Image="TFTP:Desktops/&path;-TN.png" URL="TFTP:Desktops/&path;.png"/></CiscoIPPhoneImageList>"#,
5257            ),
5258            Err(PhoneXmlError::DocumentTypeForbidden)
5259        ));
5260        assert!(CiscoIpPhoneImageList::from_xml(
5261            br#"<CiscoIPPhoneImageList><ImageItem Image="TFTP:Desktops/&unknown;-TN.png" URL="TFTP:Desktops/image.png"/></CiscoIPPhoneImageList>"#,
5262        )
5263        .is_err());
5264        let nested = format!(
5265            "<CiscoIPPhoneImageList>{}<ImageItem Image=\"TFTP:Desktops/TN.png\" URL=\"TFTP:Desktops/image.png\"/>{}</CiscoIPPhoneImageList>",
5266            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5267            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5268        );
5269        assert!(matches!(
5270            CiscoIpPhoneImageList::from_xml(nested.as_bytes()),
5271            Err(PhoneXmlError::NestingTooDeep { .. })
5272        ));
5273
5274        #[derive(Debug)]
5275        struct FailingWriter;
5276        impl fmt::Write for FailingWriter {
5277            fn write_str(&mut self, _value: &str) -> fmt::Result {
5278                Err(fmt::Error)
5279            }
5280        }
5281        assert!(matches!(
5282            to_writer(
5283                FailingWriter,
5284                &CiscoIpPhoneImageList::new(vec![background_list_item("image")]).unwrap(),
5285                PHONE_BACKGROUND_LIST_MAX_BYTES,
5286            ),
5287            Err(PhoneXmlError::Write(_))
5288        ));
5289    }
5290
5291    #[test]
5292    fn ringtone_document_round_trips_exact_root_child_order_and_escaping() {
5293        let url =
5294            PhoneRingtoneUrl::new("http://pbx.example/ringtones/Classic.raw?site=east&set=primary")
5295                .unwrap();
5296        assert_eq!(
5297            url.as_str(),
5298            "http://pbx.example/ringtones/Classic.raw?site=east&set=primary"
5299        );
5300        assert_eq!(
5301            url.clone().into_string(),
5302            "http://pbx.example/ringtones/Classic.raw?site=east&set=primary"
5303        );
5304        let expected = CiscoIpPhoneSetRingTone::new(url);
5305        let xml = expected.to_xml().unwrap();
5306        assert_eq!(
5307            xml,
5308            "<setRingTone><ringTone>http://pbx.example/ringtones/Classic.raw?site=east&amp;set=primary</ringTone></setRingTone>"
5309        );
5310        assert_eq!(
5311            CiscoIpPhoneSetRingTone::from_xml(xml.as_bytes()).unwrap(),
5312            expected
5313        );
5314    }
5315
5316    #[test]
5317    fn ringtone_url_enforces_transport_shape_length_and_secret_safe_errors() {
5318        assert_eq!(
5319            PhoneRingtoneUrl::new("http://[2001:db8::1]:8080/ringtones/Office.raw?locale=sv")
5320                .unwrap()
5321                .as_str(),
5322            "http://[2001:db8::1]:8080/ringtones/Office.raw?locale=sv"
5323        );
5324        for invalid in [
5325            "",
5326            "HTTP://pbx.example/ringtone.raw",
5327            "https://pbx.example/ringtone.raw",
5328            "TFTP:Ringlist.xml",
5329            "ringtone.raw",
5330            "http://user:secret@pbx.example/private.raw",
5331            "http://pbx.example/private.raw#secret",
5332            "http://pbx.example/not allowed.raw",
5333            "http://pbx.example/not\tallowed.raw",
5334            "http://pbx.example/not\\allowed.raw",
5335            "http://pbx.example/not%Q0allowed.raw",
5336        ] {
5337            let error = PhoneRingtoneUrl::new(invalid).unwrap_err();
5338            if !invalid.is_empty() {
5339                assert!(!error.to_string().contains(invalid));
5340            }
5341        }
5342        assert!(PhoneRingtoneUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
5343        assert!(PhoneRingtoneUrl::new("http://pbx.example/not\u{1}xml.raw").is_err());
5344        assert!(
5345            !format!(
5346                "{:?}",
5347                PhoneRingtoneUrl::new("http://private.example/secret.raw").unwrap()
5348            )
5349            .contains("private.example")
5350        );
5351    }
5352
5353    #[test]
5354    fn ringtone_parser_rejects_wrong_root_unknown_malformed_unsafe_and_bounded_xml() {
5355        for invalid in [
5356            b"<setBackground><ringTone>http://pbx.example/r.raw</ringTone></setBackground>"
5357                .as_slice(),
5358            b"<setRingTone/>".as_slice(),
5359            b"<setRingTone unknown=\"yes\"><ringTone>http://pbx.example/r.raw</ringTone></setRingTone>"
5360                .as_slice(),
5361            b"<setRingTone><ringTone>http://pbx.example/r.raw</ringTone><Unknown/></setRingTone>"
5362                .as_slice(),
5363            b"<setRingTone><ringTone>https://pbx.example/r.raw</ringTone></setRingTone>"
5364                .as_slice(),
5365            b"<setRingTone><ringTone>".as_slice(),
5366        ] {
5367            assert!(CiscoIpPhoneSetRingTone::from_xml(invalid).is_err());
5368        }
5369        assert!(matches!(
5370            CiscoIpPhoneSetRingTone::from_xml(&[0xff]),
5371            Err(PhoneXmlError::InvalidUtf8(_))
5372        ));
5373        assert!(matches!(
5374            CiscoIpPhoneSetRingTone::from_xml(
5375                br#"<!DOCTYPE ringtone [<!ENTITY host "private.example">]><setRingTone><ringTone>http://&host;/r.raw</ringTone></setRingTone>"#,
5376            ),
5377            Err(PhoneXmlError::DocumentTypeForbidden)
5378        ));
5379        assert!(
5380            CiscoIpPhoneSetRingTone::from_xml(
5381                b"<setRingTone><ringTone>http://&unknown;/r.raw</ringTone></setRingTone>",
5382            )
5383            .is_err()
5384        );
5385        assert!(matches!(
5386            CiscoIpPhoneSetRingTone::from_xml(&vec![b'x'; PHONE_RINGTONE_MAX_BYTES + 1]),
5387            Err(PhoneXmlError::LimitExceeded {
5388                maximum: PHONE_RINGTONE_MAX_BYTES,
5389                ..
5390            })
5391        ));
5392
5393        let nested = format!(
5394            "<setRingTone>{}<ringTone>http://pbx.example/r.raw</ringTone>{}</setRingTone>",
5395            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5396            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5397        );
5398        assert!(matches!(
5399            CiscoIpPhoneSetRingTone::from_xml(nested.as_bytes()),
5400            Err(PhoneXmlError::NestingTooDeep { .. })
5401        ));
5402
5403        let document = CiscoIpPhoneSetRingTone::new(
5404            PhoneRingtoneUrl::new("http://pbx.example/r.raw").unwrap(),
5405        );
5406        assert!(matches!(
5407            document.to_xml_with_limit(10),
5408            Err(PhoneXmlError::LimitExceeded { .. })
5409        ));
5410        #[derive(Debug)]
5411        struct FailingWriter;
5412        impl fmt::Write for FailingWriter {
5413            fn write_str(&mut self, _value: &str) -> fmt::Result {
5414                Err(fmt::Error)
5415            }
5416        }
5417        assert!(matches!(
5418            to_writer(FailingWriter, &document, PHONE_RINGTONE_MAX_BYTES),
5419            Err(PhoneXmlError::Write(_))
5420        ));
5421    }
5422
5423    fn image_soft_keys() -> Vec<CiscoIpPhoneSoftKeyItem> {
5424        vec![CiscoIpPhoneSoftKeyItem {
5425            name: Some("Select & view".into()),
5426            position: PhoneSoftKeyPosition::new(1).unwrap(),
5427            url: Some("SoftKey:Select?view=image&side=west".into()),
5428            url_down: Some("Notify:select?state=down".into()),
5429        }]
5430    }
5431
5432    fn image_key_items() -> Vec<CiscoIpPhoneKeyItem> {
5433        vec![CiscoIpPhoneKeyItem {
5434            key: PhoneXmlKey::NavSelect,
5435            url: Some("Key:Select?view=image&side=west".into()),
5436            url_down: None,
5437        }]
5438    }
5439
5440    fn complete_bitmap_image() -> CiscoIpPhoneImage {
5441        CiscoIpPhoneImage {
5442            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
5443            application_id: Some("image-service".into()),
5444            on_focus_lost: Some("Notify:image?focus=lost".into()),
5445            on_focus_gained: Some("Notify:image?focus=gained".into()),
5446            on_minimized: Some("Notify:image?state=minimized".into()),
5447            on_closed: Some("Notify:image?state=closed".into()),
5448            title: Some("Café <map> & menu".into()),
5449            prompt: Some("Choose & inspect".into()),
5450            soft_keys: image_soft_keys(),
5451            key_items: image_key_items(),
5452            location_x: Some(-1),
5453            location_y: Some(64),
5454            width: 133,
5455            height: 65,
5456            depth: 2,
5457            data: Some(PhoneBitmapData::new(vec![0x00, 0xab, 0xff]).unwrap()),
5458        }
5459    }
5460
5461    fn complete_image_file() -> CiscoIpPhoneImageFile {
5462        CiscoIpPhoneImageFile {
5463            keypad_target: Some(PhoneKeypadTarget::Application),
5464            application_id: Some("image-file-service".into()),
5465            on_focus_lost: None,
5466            on_focus_gained: None,
5467            on_minimized: None,
5468            on_closed: Some("Notify:image-file?state=closed".into()),
5469            title: Some("Image <file>".into()),
5470            prompt: Some("Open & inspect".into()),
5471            soft_keys: image_soft_keys(),
5472            key_items: image_key_items(),
5473            location_x: Some(297),
5474            location_y: Some(-1),
5475            url: PhoneImageUrl::new("https://pbx.example/image.png?id=7&view=full").unwrap(),
5476        }
5477    }
5478
5479    fn complete_graphic_menu() -> CiscoIpPhoneGraphicMenu {
5480        CiscoIpPhoneGraphicMenu {
5481            keypad_target: Some(PhoneKeypadTarget::ActiveCall),
5482            application_id: Some("graphic-menu".into()),
5483            on_focus_lost: None,
5484            on_focus_gained: None,
5485            on_minimized: None,
5486            on_closed: None,
5487            title: Some("Graphic menu".into()),
5488            prompt: Some("Choose a region".into()),
5489            soft_keys: image_soft_keys(),
5490            key_items: image_key_items(),
5491            location_x: Some(132),
5492            location_y: Some(-1),
5493            width: 1,
5494            height: 1,
5495            depth: 1,
5496            data: Some(PhoneBitmapData::new(vec![0x12, 0x34]).unwrap()),
5497            items: vec![CiscoIpPhoneMenuItem {
5498                name: Some("West <wing>".into()),
5499                url: Some("UserData:9095:0:image/west?floor=1&open=true".into()),
5500            }],
5501        }
5502    }
5503
5504    fn complete_graphic_file_menu() -> CiscoIpPhoneGraphicFileMenu {
5505        CiscoIpPhoneGraphicFileMenu {
5506            keypad_target: None,
5507            application_id: Some("graphic-file-menu".into()),
5508            on_focus_lost: None,
5509            on_focus_gained: None,
5510            on_minimized: None,
5511            on_closed: None,
5512            title: Some("Floor plan".into()),
5513            prompt: Some("Touch a room".into()),
5514            soft_keys: image_soft_keys(),
5515            key_items: image_key_items(),
5516            location_x: Some(-1),
5517            location_y: Some(167),
5518            url: PhoneImageUrl::new("https://pbx.example/floor.png?site=east&floor=2").unwrap(),
5519            items: vec![CiscoIpPhoneTouchAreaMenuItem {
5520                name: Some("Room A & B".into()),
5521                url: Some("UserData:9095:0/room/a?mode=open&floor=2".into()),
5522                touch_area: Some(PhoneTouchArea {
5523                    x1: 4,
5524                    y1: 8,
5525                    x2: 90,
5526                    y2: 120,
5527                }),
5528            }],
5529        }
5530    }
5531
5532    #[test]
5533    fn image_documents_round_trip_schema_order_hex_utf8_and_escaping() {
5534        let image = complete_bitmap_image();
5535        let xml = image.to_xml().unwrap();
5536        assert!(xml.contains("Café &lt;map&gt; &amp; menu"));
5537        assert!(xml.contains("<Data>00ABFF</Data>"));
5538        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
5539        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<LocationX>").unwrap());
5540        assert!(xml.find("<Depth>").unwrap() < xml.find("<Data>").unwrap());
5541        assert_eq!(CiscoIpPhoneImage::from_xml(xml.as_bytes()).unwrap(), image);
5542        assert_eq!(
5543            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
5544            PhoneImageDocument::Image(image)
5545        );
5546
5547        let spaced_hex = b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>00 ab\nFF</Data></CiscoIPPhoneImage>";
5548        let parsed = CiscoIpPhoneImage::from_xml(spaced_hex).unwrap();
5549        assert_eq!(parsed.data.unwrap().as_bytes(), [0x00, 0xab, 0xff]);
5550
5551        let image_file = complete_image_file();
5552        let xml = image_file.to_xml().unwrap();
5553        assert!(xml.contains("Image &lt;file&gt;"));
5554        assert!(xml.contains("id=7&amp;view=full"));
5555        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<LocationX>").unwrap());
5556        let controls_end = xml.find("</KeyItem>").unwrap();
5557        let image_url = controls_end + xml[controls_end..].find("<URL>").unwrap();
5558        assert!(xml.find("<LocationY>").unwrap() < image_url);
5559        assert_eq!(
5560            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
5561            PhoneImageDocument::ImageFile(image_file)
5562        );
5563
5564        let graphic = complete_graphic_menu();
5565        let xml = graphic.to_xml().unwrap();
5566        assert!(xml.contains("West &lt;wing&gt;"));
5567        assert!(xml.find("<Data>").unwrap() < xml.find("<MenuItem>").unwrap());
5568        assert_eq!(
5569            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
5570            PhoneImageDocument::GraphicMenu(graphic)
5571        );
5572
5573        let graphic_file = complete_graphic_file_menu();
5574        let xml = graphic_file.to_xml().unwrap();
5575        assert!(xml.contains("Room A &amp; B"));
5576        assert!(xml.contains(r#"<TouchArea X1="4" Y1="8" X2="90" Y2="120"/>"#));
5577        let controls_end = xml.find("</KeyItem>").unwrap();
5578        let image_url = controls_end + xml[controls_end..].find("<URL>").unwrap();
5579        assert!(image_url < xml.find("<MenuItem>").unwrap());
5580        assert_eq!(
5581            PhoneImageDocument::from_xml(xml.as_bytes()).unwrap(),
5582            PhoneImageDocument::GraphicFileMenu(graphic_file)
5583        );
5584    }
5585
5586    #[test]
5587    fn image_documents_enforce_exact_geometry_data_url_and_collection_bounds() {
5588        let mut image = complete_bitmap_image();
5589        assert!(image.validate().is_ok());
5590        image.location_x = Some(-2);
5591        assert!(image.validate().is_err());
5592        image.location_x = Some(133);
5593        assert!(image.validate().is_err());
5594        image.location_x = Some(0);
5595        image.location_y = Some(-2);
5596        assert!(image.validate().is_err());
5597        image.location_y = Some(65);
5598        assert!(image.validate().is_err());
5599        image.location_y = None;
5600        for (width, height, depth) in [
5601            (0, 1, 1),
5602            (134, 1, 1),
5603            (1, 0, 1),
5604            (1, 66, 1),
5605            (1, 1, 0),
5606            (1, 1, 3),
5607        ] {
5608            image.width = width;
5609            image.height = height;
5610            image.depth = depth;
5611            assert!(image.validate().is_err());
5612        }
5613        image.width = 1;
5614        image.height = 1;
5615        image.depth = 1;
5616        image.data = Some(PhoneBitmapData::new(vec![0; PHONE_IMAGE_BITMAP_MAX_BYTES]).unwrap());
5617        assert!(image.validate().is_ok());
5618        assert!(matches!(
5619            PhoneBitmapData::new(vec![0; PHONE_IMAGE_BITMAP_MAX_BYTES + 1]),
5620            Err(PhoneXmlError::LimitExceeded {
5621                kind: "bitmap image data bytes",
5622                maximum: PHONE_IMAGE_BITMAP_MAX_BYTES,
5623                ..
5624            })
5625        ));
5626
5627        let mut image_file = complete_image_file();
5628        for x in [-2, 298] {
5629            image_file.location_x = Some(x);
5630            assert!(image_file.validate().is_err());
5631        }
5632        image_file.location_x = None;
5633        for y in [-2, 168] {
5634            image_file.location_y = Some(y);
5635            assert!(image_file.validate().is_err());
5636        }
5637        assert!(PhoneImageUrl::new("").is_err());
5638        assert!(PhoneImageUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
5639        assert!(PhoneImageUrl::new("not\u{1}xml").is_err());
5640
5641        let mut graphic = complete_graphic_menu();
5642        graphic.items = (0..PHONE_GRAPHIC_MENU_MAX_ITEMS)
5643            .map(|_| CiscoIpPhoneMenuItem {
5644                name: Some("x".repeat(64)),
5645                url: Some("x".repeat(PHONE_XML_URL_MAX_CHARS)),
5646            })
5647            .collect();
5648        assert!(graphic.validate().is_ok());
5649        graphic.items.push(CiscoIpPhoneMenuItem {
5650            name: None,
5651            url: None,
5652        });
5653        assert!(graphic.validate().is_err());
5654        graphic.items.truncate(1);
5655        graphic.items[0].name = Some("x".repeat(65));
5656        assert!(graphic.validate().is_err());
5657
5658        let mut graphic_file = complete_graphic_file_menu();
5659        graphic_file.items = (0..PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS)
5660            .map(|_| CiscoIpPhoneTouchAreaMenuItem {
5661                name: Some("x".repeat(32)),
5662                url: Some("x".repeat(PHONE_XML_URL_MAX_CHARS)),
5663                touch_area: Some(PhoneTouchArea {
5664                    x1: u16::MIN,
5665                    y1: u16::MIN,
5666                    x2: u16::MAX,
5667                    y2: u16::MAX,
5668                }),
5669            })
5670            .collect();
5671        assert!(graphic_file.validate().is_ok());
5672        graphic_file.items.push(CiscoIpPhoneTouchAreaMenuItem {
5673            name: None,
5674            url: None,
5675            touch_area: None,
5676        });
5677        assert!(graphic_file.validate().is_err());
5678        graphic_file.items.truncate(1);
5679        graphic_file.items[0].name = Some("x".repeat(33));
5680        assert!(graphic_file.validate().is_err());
5681    }
5682
5683    #[test]
5684    fn image_parsers_reject_wrong_roots_malformed_unsafe_nested_and_oversized_input() {
5685        assert!(
5686            CiscoIpPhoneImage::from_xml(
5687                b"<CiscoIPPhoneImageFile><URL>x</URL></CiscoIPPhoneImageFile>"
5688            )
5689            .is_err()
5690        );
5691        assert!(PhoneImageDocument::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
5692        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Unknown/></CiscoIPPhoneImage>").is_err());
5693        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>123</Data></CiscoIPPhoneImage>").is_err());
5694        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>zz</Data></CiscoIPPhoneImage>").is_err());
5695        assert!(CiscoIpPhoneGraphicFileMenu::from_xml(b"<CiscoIPPhoneGraphicFileMenu><URL>x</URL><MenuItem><TouchArea X1=\"bad\" Y1=\"0\" X2=\"1\" Y2=\"1\"/></MenuItem></CiscoIPPhoneGraphicFileMenu>").is_err());
5696        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage>").is_err());
5697        assert!(matches!(
5698            CiscoIpPhoneImage::from_xml(&[0xff]),
5699            Err(PhoneXmlError::InvalidUtf8(_))
5700        ));
5701        assert!(matches!(
5702            CiscoIpPhoneImage::from_xml(b"<!DOCTYPE image [<!ENTITY bits '00'>]><CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&bits;</Data></CiscoIPPhoneImage>"),
5703            Err(PhoneXmlError::DocumentTypeForbidden)
5704        ));
5705        assert!(CiscoIpPhoneImage::from_xml(b"<CiscoIPPhoneImage><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&unknown;</Data></CiscoIPPhoneImage>").is_err());
5706
5707        let nested = format!(
5708            "<CiscoIPPhoneImage>{}<Width>1</Width><Height>1</Height><Depth>1</Depth>{}</CiscoIPPhoneImage>",
5709            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5710            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5711        );
5712        assert!(matches!(
5713            CiscoIpPhoneImage::from_xml(nested.as_bytes()),
5714            Err(PhoneXmlError::NestingTooDeep { .. })
5715        ));
5716        assert!(matches!(
5717            PhoneImageDocument::from_xml(&vec![b'x'; PHONE_IMAGE_MAX_BYTES + 1]),
5718            Err(PhoneXmlError::LimitExceeded { .. })
5719        ));
5720        assert!(matches!(
5721            PhoneImageDocument::Image(complete_bitmap_image()).to_xml_with_limit(10),
5722            Err(PhoneXmlError::LimitExceeded { .. })
5723        ));
5724
5725        #[derive(Debug)]
5726        struct FailingWriter;
5727        impl fmt::Write for FailingWriter {
5728            fn write_str(&mut self, _value: &str) -> fmt::Result {
5729                Err(fmt::Error)
5730            }
5731        }
5732        assert!(matches!(
5733            to_writer(
5734                FailingWriter,
5735                &complete_graphic_file_menu(),
5736                PHONE_IMAGE_MAX_BYTES
5737            ),
5738            Err(PhoneXmlError::Write(_))
5739        ));
5740    }
5741
5742    fn complete_bitmap_status() -> CiscoIpPhoneStatus {
5743        CiscoIpPhoneStatus {
5744            text: Some("Café <ready> & active".into()),
5745            timer_seconds: Some(15),
5746            location_x: Some(-1),
5747            location_y: Some(20),
5748            width: 106,
5749            height: 21,
5750            depth: 2,
5751            data: Some(PhoneBitmapData::new(vec![0x00, 0xab, 0xff]).unwrap()),
5752        }
5753    }
5754
5755    fn complete_file_status() -> CiscoIpPhoneStatusFile {
5756        CiscoIpPhoneStatusFile {
5757            text: Some("Status <file> & refresh".into()),
5758            timer_seconds: Some(u16::MAX),
5759            location_x: Some(261),
5760            location_y: Some(-1),
5761            url: PhoneImageUrl::new("https://pbx.example/status.png?id=7&view=compact").unwrap(),
5762        }
5763    }
5764
5765    #[test]
5766    fn status_documents_round_trip_icons_timers_order_utf8_and_escaping() {
5767        let bitmap = complete_bitmap_status();
5768        let xml = bitmap.to_xml().unwrap();
5769        assert!(xml.contains("Café &lt;ready&gt; &amp; active"));
5770        assert!(xml.contains("<Timer>15</Timer>"));
5771        assert!(xml.contains("<Data>00ABFF</Data>"));
5772        assert!(xml.find("<Text>").unwrap() < xml.find("<Timer>").unwrap());
5773        assert!(xml.find("<Timer>").unwrap() < xml.find("<LocationX>").unwrap());
5774        assert!(xml.find("<Depth>").unwrap() < xml.find("<Data>").unwrap());
5775        assert_eq!(
5776            CiscoIpPhoneStatus::from_xml(xml.as_bytes()).unwrap(),
5777            bitmap
5778        );
5779        assert_eq!(
5780            PhoneStatusDocument::from_xml(xml.as_bytes()).unwrap(),
5781            PhoneStatusDocument::Bitmap(bitmap)
5782        );
5783
5784        let file = complete_file_status();
5785        let xml = file.to_xml().unwrap();
5786        assert!(xml.contains("Status &lt;file&gt; &amp; refresh"));
5787        assert!(xml.contains(&format!("<Timer>{}</Timer>", u16::MAX)));
5788        assert!(xml.contains("id=7&amp;view=compact"));
5789        assert!(xml.find("<LocationY>").unwrap() < xml.find("<URL>").unwrap());
5790        assert_eq!(
5791            PhoneStatusDocument::from_xml(xml.as_bytes()).unwrap(),
5792            PhoneStatusDocument::File(file)
5793        );
5794
5795        let zero_timer = CiscoIpPhoneStatus::from_xml(
5796            b"<CiscoIPPhoneStatus><Timer>0</Timer><Width>1</Width><Height>1</Height><Depth>1</Depth><Data></Data></CiscoIPPhoneStatus>",
5797        )
5798        .unwrap();
5799        assert_eq!(zero_timer.timer_seconds, Some(0));
5800        assert_eq!(zero_timer.data.unwrap().as_bytes(), []);
5801        let absent_data = CiscoIpPhoneStatus::from_xml(
5802            b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth></CiscoIPPhoneStatus>",
5803        )
5804        .unwrap();
5805        assert!(absent_data.timer_seconds.is_none());
5806        assert!(absent_data.data.is_none());
5807    }
5808
5809    #[test]
5810    fn status_documents_enforce_exact_text_geometry_icon_and_url_bounds() {
5811        let mut bitmap = complete_bitmap_status();
5812        bitmap.text = Some("x".repeat(32));
5813        assert!(bitmap.validate().is_ok());
5814        bitmap.text = Some("x".repeat(33));
5815        assert!(bitmap.validate().is_err());
5816        bitmap.text = None;
5817        for x in [-2, 106] {
5818            bitmap.location_x = Some(x);
5819            assert!(bitmap.validate().is_err());
5820        }
5821        bitmap.location_x = None;
5822        for y in [-2, 21] {
5823            bitmap.location_y = Some(y);
5824            assert!(bitmap.validate().is_err());
5825        }
5826        bitmap.location_y = None;
5827        for (width, height, depth) in [
5828            (0, 1, 1),
5829            (107, 1, 1),
5830            (1, 0, 1),
5831            (1, 22, 1),
5832            (1, 1, 0),
5833            (1, 1, 3),
5834        ] {
5835            bitmap.width = width;
5836            bitmap.height = height;
5837            bitmap.depth = depth;
5838            assert!(bitmap.validate().is_err());
5839        }
5840        bitmap.width = 1;
5841        bitmap.height = 1;
5842        bitmap.depth = 1;
5843        bitmap.data = Some(PhoneBitmapData::new(vec![0; PHONE_STATUS_BITMAP_MAX_BYTES]).unwrap());
5844        assert!(bitmap.validate().is_ok());
5845        bitmap.data =
5846            Some(PhoneBitmapData::new(vec![0; PHONE_STATUS_BITMAP_MAX_BYTES + 1]).unwrap());
5847        assert!(matches!(
5848            bitmap.validate(),
5849            Err(PhoneXmlError::LimitExceeded {
5850                kind: "phone status bitmap bytes",
5851                maximum: PHONE_STATUS_BITMAP_MAX_BYTES,
5852                ..
5853            })
5854        ));
5855
5856        let mut file = complete_file_status();
5857        for x in [-2, 262] {
5858            file.location_x = Some(x);
5859            assert!(file.validate().is_err());
5860        }
5861        file.location_x = None;
5862        for y in [-2, 50] {
5863            file.location_y = Some(y);
5864            assert!(file.validate().is_err());
5865        }
5866        assert!(PhoneImageUrl::new("").is_err());
5867        assert!(PhoneImageUrl::new("x".repeat(PHONE_XML_URL_MAX_CHARS + 1)).is_err());
5868    }
5869
5870    #[test]
5871    fn status_parsers_reject_wrong_roots_malformed_unsafe_nested_and_oversized_input() {
5872        assert!(
5873            CiscoIpPhoneStatus::from_xml(
5874                b"<CiscoIPPhoneStatusFile><URL>x</URL></CiscoIPPhoneStatusFile>"
5875            )
5876            .is_err()
5877        );
5878        assert!(PhoneStatusDocument::from_xml(b"<CiscoIPPhoneText/>").is_err());
5879        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Unknown/></CiscoIPPhoneStatus>").is_err());
5880        assert!(
5881            CiscoIpPhoneStatus::from_xml(
5882                b"<CiscoIPPhoneStatus><Height>1</Height><Depth>1</Depth></CiscoIPPhoneStatus>"
5883            )
5884            .is_err()
5885        );
5886        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>f</Data></CiscoIPPhoneStatus>").is_err());
5887        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>zz</Data></CiscoIPPhoneStatus>").is_err());
5888        assert!(
5889            CiscoIpPhoneStatusFile::from_xml(
5890                b"<CiscoIPPhoneStatusFile><URL></URL></CiscoIPPhoneStatusFile>"
5891            )
5892            .is_err()
5893        );
5894        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus>").is_err());
5895        assert!(matches!(
5896            CiscoIpPhoneStatus::from_xml(&[0xff]),
5897            Err(PhoneXmlError::InvalidUtf8(_))
5898        ));
5899        assert!(matches!(
5900            CiscoIpPhoneStatus::from_xml(b"<!DOCTYPE status [<!ENTITY bits '00'>]><CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&bits;</Data></CiscoIPPhoneStatus>"),
5901            Err(PhoneXmlError::DocumentTypeForbidden)
5902        ));
5903        assert!(CiscoIpPhoneStatus::from_xml(b"<CiscoIPPhoneStatus><Width>1</Width><Height>1</Height><Depth>1</Depth><Data>&unknown;</Data></CiscoIPPhoneStatus>").is_err());
5904
5905        let nested = format!(
5906            "<CiscoIPPhoneStatus>{}<Width>1</Width><Height>1</Height><Depth>1</Depth>{}</CiscoIPPhoneStatus>",
5907            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5908            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
5909        );
5910        assert!(matches!(
5911            CiscoIpPhoneStatus::from_xml(nested.as_bytes()),
5912            Err(PhoneXmlError::NestingTooDeep { .. })
5913        ));
5914        assert!(matches!(
5915            PhoneStatusDocument::from_xml(&vec![b'x'; PHONE_STATUS_MAX_BYTES + 1]),
5916            Err(PhoneXmlError::LimitExceeded { .. })
5917        ));
5918        assert!(matches!(
5919            PhoneStatusDocument::Bitmap(complete_bitmap_status()).to_xml_with_limit(10),
5920            Err(PhoneXmlError::LimitExceeded { .. })
5921        ));
5922
5923        #[derive(Debug)]
5924        struct FailingWriter;
5925        impl fmt::Write for FailingWriter {
5926            fn write_str(&mut self, _value: &str) -> fmt::Result {
5927                Err(fmt::Error)
5928            }
5929        }
5930        assert!(matches!(
5931            to_writer(
5932                FailingWriter,
5933                &complete_file_status(),
5934                PHONE_STATUS_MAX_BYTES,
5935            ),
5936            Err(PhoneXmlError::Write(_))
5937        ));
5938    }
5939
5940    fn complete_alarm() -> CiscoIpPhoneAlarm {
5941        CiscoIpPhoneAlarm {
5942            alarm: CiscoIpPhoneAlarmEntry {
5943                name: LAST_OUT_OF_SERVICE_ALARM.into(),
5944                parameter_list: CiscoIpPhoneAlarmParameterList {
5945                    parameters: vec![
5946                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
5947                            name: "DeviceName".into(),
5948                            value: "SEP001122334455".into(),
5949                        }),
5950                        CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
5951                            name: "DHCPv4Status".into(),
5952                            value: 1,
5953                        }),
5954                        CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
5955                            name: "ReasonForOutOfService".into(),
5956                            value: 25,
5957                        }),
5958                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
5959                            name: "LastProtocolEventSent".into(),
5960                            value: "Sent:REGISTER <call-id> & route".into(),
5961                        }),
5962                        CiscoIpPhoneAlarmParameter::String(CiscoIpPhoneAlarmString {
5963                            name: "LastProtocolEventReceived".into(),
5964                            value: String::new(),
5965                        }),
5966                    ],
5967                },
5968            },
5969        }
5970    }
5971
5972    #[test]
5973    fn alarm_schema_round_trips_ordered_typed_parameters_and_accessors() {
5974        let expected = complete_alarm();
5975        let xml = expected.to_xml().unwrap();
5976        assert!(xml.starts_with(
5977            "<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList>"
5978        ));
5979        assert!(xml.contains("Sent:REGISTER &lt;call-id&gt; &amp; route"));
5980        assert!(xml.find("DeviceName").unwrap() < xml.find("DHCPv4Status").unwrap());
5981        assert!(
5982            xml.find("ReasonForOutOfService").unwrap() < xml.find("LastProtocolEventSent").unwrap()
5983        );
5984        let decoded = CiscoIpPhoneAlarm::from_xml(xml.as_bytes()).unwrap();
5985        assert_eq!(decoded, expected);
5986        assert_eq!(decoded.reason_for_out_of_service(), Some(25));
5987        assert_eq!(decoded.enumeration("DHCPv4Status"), Some(1));
5988        assert_eq!(decoded.string("DeviceName"), Some("SEP001122334455"));
5989        assert_eq!(decoded.string("LastProtocolEventReceived"), Some(""));
5990        assert_eq!(decoded.string("Unknown"), None);
5991        let telemetry = parse_phone_alarm(xml.as_bytes()).unwrap();
5992        assert!(matches!(
5993            &telemetry,
5994            PhoneAlarmTelemetry::LastOutOfService(alarm) if alarm == &expected
5995        ));
5996        assert_eq!(
5997            telemetry.summary(),
5998            Some(PhoneAlarmSummary {
5999                kind: PhoneAlarmKind::LastOutOfService,
6000                reason_for_out_of_service: Some(25),
6001            })
6002        );
6003    }
6004
6005    #[test]
6006    fn unknown_alarm_schemas_remain_bounded_lossless_and_secret_safe() {
6007        for unknown in [
6008            b"<x-cisco-alarm/>".as_slice(),
6009            b"<x-cisco-alarm><Alarm Name=\"DeviceTroubleshootingReport\"><ParameterList><String name=\"Token\">secret-value</String></ParameterList></Alarm></x-cisco-alarm>".as_slice(),
6010            b"<vendor-alarm><Credential>secret-value</Credential></vendor-alarm>".as_slice(),
6011        ] {
6012            let PhoneAlarmTelemetry::Opaque(opaque) = parse_phone_alarm(unknown).unwrap() else {
6013                panic!("unknown alarm schema must remain opaque");
6014            };
6015            assert_eq!(opaque.as_bytes(), unknown);
6016            let debug = format!("{opaque:?}");
6017            assert!(!debug.contains("secret-value"));
6018            assert!(debug.contains(&unknown.len().to_string()));
6019            assert_eq!(opaque.clone().into_bytes(), unknown);
6020        }
6021
6022        let opaque = parse_phone_alarm(b"<vendor-alarm/>").unwrap();
6023        assert!(opaque.is_opaque());
6024        assert_eq!(opaque.summary(), None);
6025
6026        let known = complete_alarm();
6027        let debug = format!("{known:?}");
6028        assert!(!debug.contains("SEP001122334455"));
6029        assert!(!debug.contains("call-id"));
6030        assert!(debug.contains(LAST_OUT_OF_SERVICE_ALARM));
6031        assert_eq!(
6032            format!("{:?}", known.alarm.parameter_list),
6033            "CiscoIpPhoneAlarmParameterList { parameter_count: 5 }"
6034        );
6035    }
6036
6037    #[test]
6038    fn known_alarm_validation_rejects_ambiguity_unsafe_values_and_size_overflow() {
6039        let mut alarm = complete_alarm();
6040        alarm
6041            .alarm
6042            .parameter_list
6043            .parameters
6044            .push(CiscoIpPhoneAlarmParameter::Enum(CiscoIpPhoneAlarmEnum {
6045                name: "DeviceName".into(),
6046                value: 2,
6047            }));
6048        assert!(matches!(
6049            alarm.validate(),
6050            Err(PhoneXmlError::InvalidField {
6051                field: "phone alarm parameter names",
6052                ..
6053            })
6054        ));
6055
6056        alarm = complete_alarm();
6057        match &mut alarm.alarm.parameter_list.parameters[0] {
6058            CiscoIpPhoneAlarmParameter::String(device) => device.name.clear(),
6059            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
6060        }
6061        assert!(alarm.validate().is_err());
6062        match &mut alarm.alarm.parameter_list.parameters[0] {
6063            CiscoIpPhoneAlarmParameter::String(device) => {
6064                device.name = "DeviceName".into();
6065                device.value = "not\u{1}xml".into();
6066            }
6067            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
6068        }
6069        assert!(alarm.validate().is_err());
6070        match &mut alarm.alarm.parameter_list.parameters[0] {
6071            CiscoIpPhoneAlarmParameter::String(device) => {
6072                device.value = "sensitive-value".repeat(PHONE_ALARM_MAX_BYTES);
6073            }
6074            CiscoIpPhoneAlarmParameter::Enum(_) => panic!("first parameter must be a string"),
6075        }
6076        let error = alarm.to_xml().unwrap_err();
6077        assert!(!error.to_string().contains("sensitive-value"));
6078
6079        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>";
6080        let error = parse_phone_alarm(duplicate).unwrap_err();
6081        assert!(!error.to_string().contains("first-secret"));
6082        assert!(!error.to_string().contains("second-secret"));
6083    }
6084
6085    #[test]
6086    fn alarm_parser_rejects_malformed_known_unsafe_and_oversized_documents() {
6087        assert!(parse_phone_alarm(b"<x-cisco-alarm>").is_err());
6088        assert!(matches!(
6089            parse_phone_alarm(&[0xff]),
6090            Err(PhoneXmlError::InvalidUtf8(_))
6091        ));
6092        assert!(matches!(
6093            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>"),
6094            Err(PhoneXmlError::DocumentTypeForbidden)
6095        ));
6096        assert!(parse_phone_alarm(b"<x-cisco-alarm><Alarm Name=\"Unknown\"><ParameterList><String name=\"Value\">&unknown;</String></ParameterList></Alarm></x-cisco-alarm>").is_err());
6097        assert!(parse_phone_alarm(b"<vendor-alarm><Value>&#1;</Value></vendor-alarm>").is_err());
6098        assert!(parse_phone_alarm(b"<vendor-alarm value=\"&#1;\"/>").is_err());
6099        assert!(parse_phone_alarm(b"<vendor-alarm>not\x01xml</vendor-alarm>").is_err());
6100        assert!(parse_phone_alarm(b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><Binary name=\"Value\">00</Binary></ParameterList></Alarm></x-cisco-alarm>").is_err());
6101        assert!(
6102            parse_phone_alarm(
6103                b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"/></x-cisco-alarm>"
6104            )
6105            .is_err()
6106        );
6107        let invalid_enum = b"<x-cisco-alarm><Alarm Name=\"LastOutOfServiceInformation\"><ParameterList><Enum name=\"ReasonForOutOfService\">secret-enum</Enum></ParameterList></Alarm></x-cisco-alarm>";
6108        let error = parse_phone_alarm(invalid_enum).unwrap_err();
6109        assert!(!error.to_string().contains("secret-enum"));
6110
6111        let nested = format!(
6112            "<x-cisco-alarm>{}<Alarm Name=\"Unknown\"/>{}</x-cisco-alarm>",
6113            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
6114            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
6115        );
6116        assert!(matches!(
6117            parse_phone_alarm(nested.as_bytes()),
6118            Err(PhoneXmlError::NestingTooDeep { .. })
6119        ));
6120        assert!(matches!(
6121            parse_phone_alarm(&vec![b'x'; PHONE_ALARM_MAX_BYTES + 1]),
6122            Err(PhoneXmlError::LimitExceeded {
6123                maximum: PHONE_ALARM_MAX_BYTES,
6124                ..
6125            })
6126        ));
6127
6128        #[derive(Debug)]
6129        struct FailingWriter;
6130        impl fmt::Write for FailingWriter {
6131            fn write_str(&mut self, _value: &str) -> fmt::Result {
6132                Err(fmt::Error)
6133            }
6134        }
6135        assert!(matches!(
6136            to_writer(FailingWriter, &complete_alarm(), PHONE_ALARM_MAX_BYTES),
6137            Err(PhoneXmlError::Write(_))
6138        ));
6139    }
6140
6141    fn complete_location() -> CiscoIpPhoneLocationInformation {
6142        CiscoIpPhoneLocationInformation {
6143            wifi: CiscoIpPhoneWifiLocation {
6144                bssid: PhoneBssid::parse("e8:ed:f3:10:29:fd").unwrap(),
6145                ssid: "Café <voice> & data".into(),
6146                access_point_name: "West wing <3>".into(),
6147            },
6148            off_premises: Some(CiscoIpPhoneOffPremises::new()),
6149        }
6150    }
6151
6152    #[test]
6153    fn location_schema_round_trips_typed_address_fields_order_and_escaping() {
6154        let expected = complete_location();
6155        let xml = expected.to_xml().unwrap();
6156        assert!(xml.starts_with("<Interface1><wifi><BSSID>E8:ED:F3:10:29:FD</BSSID>"));
6157        assert!(xml.contains("<SSID>Café &lt;voice&gt; &amp; data</SSID>"));
6158        assert!(xml.contains("<APName>West wing &lt;3&gt;</APName>"));
6159        assert!(xml.find("</wifi>").unwrap() < xml.find("<OffPrem").unwrap());
6160        assert_eq!(
6161            CiscoIpPhoneLocationInformation::from_xml(xml.as_bytes()).unwrap(),
6162            expected
6163        );
6164        assert_eq!(
6165            expected.wifi.bssid.octets(),
6166            [0xe8, 0xed, 0xf3, 0x10, 0x29, 0xfd]
6167        );
6168        assert_eq!(expected.wifi.bssid.to_string(), "E8:ED:F3:10:29:FD");
6169        assert!(expected.is_off_premises());
6170
6171        let telemetry = parse_phone_location(xml.as_bytes()).unwrap();
6172        assert_eq!(
6173            telemetry.summary(),
6174            Some(PhoneLocationSummary {
6175                kind: PhoneLocationKind::WirelessInterface,
6176                off_premises: true,
6177            })
6178        );
6179
6180        let on_premises = CiscoIpPhoneLocationInformation::from_xml(
6181            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID></SSID><APName/></wifi></Interface1>",
6182        )
6183        .unwrap();
6184        assert!(!on_premises.is_off_premises());
6185        assert_eq!(on_premises.wifi.ssid, "");
6186        assert_eq!(on_premises.wifi.access_point_name, "");
6187    }
6188
6189    #[test]
6190    fn location_models_enforce_address_marker_text_and_document_bounds() {
6191        for invalid in [
6192            "00:11:22:33:44",
6193            "00:11:22:33:44:555",
6194            "00-11-22-33-44-55",
6195            "00:11:22:33:44:gg",
6196            "private-address",
6197        ] {
6198            let error = PhoneBssid::parse(invalid).unwrap_err();
6199            assert!(!error.to_string().contains(invalid));
6200        }
6201
6202        let mut location = complete_location();
6203        location.wifi.ssid = "é".repeat(16);
6204        assert!(location.validate().is_ok());
6205        location.wifi.ssid.push('é');
6206        assert!(matches!(
6207            location.validate(),
6208            Err(PhoneXmlError::InvalidField {
6209                field: "phone location SSID",
6210                expected: "at most 32 bytes",
6211            })
6212        ));
6213
6214        location = complete_location();
6215        location.wifi.access_point_name = "private-name".repeat(PHONE_LOCATION_MAX_BYTES);
6216        let error = location.to_xml().unwrap_err();
6217        assert!(!error.to_string().contains("private-name"));
6218
6219        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>";
6220        let error = parse_phone_location(nonempty_marker).unwrap_err();
6221        assert!(!error.to_string().contains("private-location"));
6222    }
6223
6224    #[test]
6225    fn unknown_location_schemas_are_bounded_lossless_and_secret_safe() {
6226        for unknown in [
6227            b"<Interface2><wifi><BSSID>00:11:22:33:44:55</BSSID></wifi></Interface2>".as_slice(),
6228            b"<DeviceLocation><CivicAddress>private-building</CivicAddress></DeviceLocation>"
6229                .as_slice(),
6230        ] {
6231            let telemetry = parse_phone_location(unknown).unwrap();
6232            let PhoneLocationTelemetry::Opaque(opaque) = &telemetry else {
6233                panic!("unsupported location schema must remain opaque");
6234            };
6235            assert_eq!(opaque.as_bytes(), unknown);
6236            assert_eq!(opaque.clone().into_bytes(), unknown);
6237            assert_eq!(telemetry.summary(), None);
6238            assert!(telemetry.is_opaque());
6239            let debug = format!("{telemetry:?}");
6240            assert!(!debug.contains("private-building"));
6241            assert!(!debug.contains("00:11:22:33:44:55"));
6242            assert!(debug.contains(&unknown.len().to_string()));
6243        }
6244
6245        let debug = format!("{:?}", complete_location());
6246        assert!(!debug.contains("Café"));
6247        assert!(!debug.contains("West wing"));
6248        assert!(!debug.contains("E8:ED:F3:10:29:FD"));
6249    }
6250
6251    #[test]
6252    fn location_parser_rejects_malformed_known_unsafe_and_oversized_documents() {
6253        for invalid in [
6254            b"<Interface1>".as_slice(),
6255            b"<Interface1><wifi><BSSID>private-address</BSSID><SSID>private-network</SSID><APName>private-access-point</APName></wifi></Interface1>".as_slice(),
6256            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(),
6257            b"<Interface1><OffPrem/></Interface1>".as_slice(),
6258            b"<Interface1><wifi><BSSID>00:11:22:33:44:55</BSSID><SSID>one</SSID><SSID>two</SSID><APName>west</APName></wifi></Interface1>".as_slice(),
6259        ] {
6260            let error = parse_phone_location(invalid).unwrap_err();
6261            let error = error.to_string();
6262            assert!(!error.contains("private-address"));
6263            assert!(!error.contains("private-network"));
6264            assert!(!error.contains("private-access-point"));
6265            assert!(!error.contains("private-secret"));
6266        }
6267        assert!(matches!(
6268            parse_phone_location(&[0xff]),
6269            Err(PhoneXmlError::InvalidUtf8(_))
6270        ));
6271        assert!(matches!(
6272            parse_phone_location(b"<!DOCTYPE Interface2 [<!ENTITY location 'private'>]><Interface2>&location;</Interface2>"),
6273            Err(PhoneXmlError::DocumentTypeForbidden)
6274        ));
6275        assert!(matches!(
6276            parse_phone_location(b"<Interface2>&undeclared;</Interface2>"),
6277            Err(PhoneXmlError::InvalidEntity)
6278        ));
6279        assert!(parse_phone_location(b"<Interface2>&#1;</Interface2>").is_err());
6280        assert!(parse_phone_location(b"<Interface2>not\x01xml</Interface2>").is_err());
6281
6282        let nested = format!(
6283            "<Interface2>{}{}</Interface2>",
6284            "<Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
6285            "</Nested>".repeat(PHONE_XML_MAX_NESTING_DEPTH),
6286        );
6287        assert!(matches!(
6288            parse_phone_location(nested.as_bytes()),
6289            Err(PhoneXmlError::NestingTooDeep { .. })
6290        ));
6291        assert!(matches!(
6292            parse_phone_location(&vec![b'x'; PHONE_LOCATION_MAX_BYTES + 1]),
6293            Err(PhoneXmlError::LimitExceeded {
6294                maximum: PHONE_LOCATION_MAX_BYTES,
6295                ..
6296            })
6297        ));
6298
6299        #[derive(Debug)]
6300        struct FailingWriter;
6301        impl fmt::Write for FailingWriter {
6302            fn write_str(&mut self, _value: &str) -> fmt::Result {
6303                Err(fmt::Error)
6304            }
6305        }
6306        assert!(matches!(
6307            to_writer(
6308                FailingWriter,
6309                &complete_location(),
6310                PHONE_LOCATION_MAX_BYTES,
6311            ),
6312            Err(PhoneXmlError::Write(_))
6313        ));
6314    }
6315
6316    fn complete_menu() -> CiscoIpPhoneMenu {
6317        CiscoIpPhoneMenu {
6318            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
6319            application_id: Some("menu-west".into()),
6320            on_focus_lost: Some("Notify:focus?state=lost&side=west".into()),
6321            on_focus_gained: Some("Notify:focus?state=gained".into()),
6322            on_minimized: Some("Notify:minimized".into()),
6323            on_closed: Some("Notify:closed".into()),
6324            title: Some("Support <East> & West".into()),
6325            prompt: Some("Choose A & B".into()),
6326            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
6327                name: Some("Open & inspect".into()),
6328                position: PhoneSoftKeyPosition::new(1).unwrap(),
6329                url: Some("SoftKey:Select?a=1&b=2".into()),
6330                url_down: Some("SoftKey:SelectDown".into()),
6331            }],
6332            key_items: vec![CiscoIpPhoneKeyItem {
6333                key: PhoneXmlKey::NavBack,
6334                url: Some("SoftKey:Cancel".into()),
6335                url_down: None,
6336            }],
6337            items: vec![CiscoIpPhoneMenuItem {
6338                name: Some("Alice <Admin> & Bob".into()),
6339                url: Some("UserData:7:0:open/a?x=1&y=2".into()),
6340            }],
6341        }
6342    }
6343
6344    #[test]
6345    fn basic_menu_round_trips_complete_display_controls_in_schema_order() {
6346        let expected = complete_menu();
6347        let xml = expected.to_xml().unwrap();
6348        assert!(xml.contains("Support &lt;East&gt; &amp; West"));
6349        assert!(xml.contains("Alice &lt;Admin&gt; &amp; Bob"));
6350        assert!(xml.contains("x=1&amp;y=2"));
6351        assert!(xml.find("<SoftKeyItem>").unwrap() < xml.find("<KeyItem>").unwrap());
6352        assert!(xml.find("<KeyItem>").unwrap() < xml.find("<MenuItem>").unwrap());
6353        assert_eq!(
6354            CiscoIpPhoneMenu::from_xml(xml.as_bytes()).unwrap(),
6355            expected
6356        );
6357
6358        let minimal = CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneMenu/>").unwrap();
6359        assert!(minimal.title.is_none());
6360        assert!(minimal.items.is_empty());
6361    }
6362
6363    #[test]
6364    fn bitmap_and_resource_icon_menus_round_trip_exact_icon_families() {
6365        let bitmap = CiscoIpPhoneIconMenu::new(
6366            "Conference & staff",
6367            "Choose <one>",
6368            vec![CiscoIpPhoneIconMenuItem {
6369                name: Some("Taylor & team".into()),
6370                url: Some("UserData:1:0:participant/7?view=a&b=c".into()),
6371                icon_index: Some(2),
6372            }],
6373            vec![CiscoIpPhoneIconItem {
6374                index: 2,
6375                width: 16,
6376                height: 10,
6377                depth: 2,
6378                data: Some("000FF0".into()),
6379            }],
6380        )
6381        .unwrap();
6382        let xml = bitmap.to_xml().unwrap();
6383        assert!(xml.find("<MenuItem>").unwrap() < xml.find("<IconItem>").unwrap());
6384        assert!(xml.find("<Width>").unwrap() < xml.find("<Height>").unwrap());
6385        assert!(xml.contains("Conference &amp; staff"));
6386        assert_eq!(
6387            CiscoIpPhoneIconMenu::from_xml(xml.as_bytes()).unwrap(),
6388            bitmap
6389        );
6390
6391        let resources = CiscoIpPhoneIconFileMenu {
6392            keypad_target: Some(PhoneKeypadTarget::ActiveCall),
6393            application_id: Some("conference-list".into()),
6394            on_focus_lost: None,
6395            on_focus_gained: Some("Notify:focus".into()),
6396            on_minimized: None,
6397            on_closed: Some("SoftKey:Exit".into()),
6398            icon_index: Some(4),
6399            title: Some(CiscoIpPhoneIconTitle {
6400                icon_index: Some(5),
6401                text: "Locked & secure".into(),
6402            }),
6403            prompt: Some("Choose a participant".into()),
6404            soft_keys: Vec::new(),
6405            key_items: Vec::new(),
6406            items: vec![CiscoIpPhoneIconMenuItem {
6407                name: Some("Alex <Host>".into()),
6408                url: Some("UserData:1:0:participant/1".into()),
6409                icon_index: Some(5),
6410            }],
6411            icons: vec![CiscoIpPhoneIconFileItem {
6412                index: 5,
6413                url: "Resource:Icon.SecureCall?shade=dark&size=small".into(),
6414            }],
6415        };
6416        let xml = resources.to_xml().unwrap();
6417        assert!(xml.contains("<Title IconIndex=\"5\">Locked &amp; secure</Title>"));
6418        assert!(xml.contains("shade=dark&amp;size=small"));
6419        assert_eq!(
6420            CiscoIpPhoneIconFileMenu::from_xml(xml.as_bytes()).unwrap(),
6421            resources
6422        );
6423    }
6424
6425    #[test]
6426    fn menu_models_reject_every_collection_text_url_position_and_icon_bound() {
6427        let mut basic = complete_menu();
6428        basic.items = vec![basic.items[0].clone(); PHONE_MENU_MAX_ITEMS + 1];
6429        assert!(matches!(
6430            basic.to_xml(),
6431            Err(PhoneXmlError::LimitExceeded {
6432                kind: "menu items",
6433                ..
6434            })
6435        ));
6436
6437        let mut invalid = complete_menu();
6438        invalid.items[0].name = Some("x".repeat(65));
6439        assert!(matches!(
6440            invalid.to_xml(),
6441            Err(PhoneXmlError::InvalidField { .. })
6442        ));
6443        invalid = complete_menu();
6444        invalid.items[0].url = Some("x".repeat(PHONE_XML_URL_MAX_CHARS + 1));
6445        assert!(matches!(
6446            invalid.to_xml(),
6447            Err(PhoneXmlError::InvalidField { .. })
6448        ));
6449        invalid = complete_menu();
6450        invalid.application_id = Some(String::new());
6451        assert!(matches!(
6452            invalid.to_xml(),
6453            Err(PhoneXmlError::InvalidField { .. })
6454        ));
6455        invalid = complete_menu();
6456        invalid.on_closed = Some(String::new());
6457        assert!(matches!(
6458            invalid.to_xml(),
6459            Err(PhoneXmlError::InvalidField { .. })
6460        ));
6461        invalid = complete_menu();
6462        invalid.soft_keys[0].position = PhoneSoftKeyPosition::new(16).unwrap();
6463        assert!(invalid.to_xml().is_ok());
6464        invalid = complete_menu();
6465        invalid.soft_keys = vec![invalid.soft_keys[0].clone(); 17];
6466        assert!(matches!(
6467            invalid.to_xml(),
6468            Err(PhoneXmlError::LimitExceeded { .. })
6469        ));
6470        invalid = complete_menu();
6471        invalid.key_items = vec![invalid.key_items[0].clone(); 33];
6472        assert!(matches!(
6473            invalid.to_xml(),
6474            Err(PhoneXmlError::LimitExceeded { .. })
6475        ));
6476
6477        let item = CiscoIpPhoneIconMenuItem {
6478            name: Some("Item".into()),
6479            url: Some("SoftKey:Select".into()),
6480            icon_index: Some(0),
6481        };
6482        let icon = CiscoIpPhoneIconItem {
6483            index: 0,
6484            width: 1,
6485            height: 1,
6486            depth: 1,
6487            data: Some("00".into()),
6488        };
6489        let mut icon_menu =
6490            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![item.clone()], vec![icon.clone()])
6491                .unwrap();
6492        icon_menu.items = vec![item.clone(); PHONE_ICON_MENU_MAX_ITEMS + 1];
6493        assert!(matches!(
6494            icon_menu.to_xml(),
6495            Err(PhoneXmlError::LimitExceeded { .. })
6496        ));
6497        icon_menu =
6498            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![item.clone()], vec![icon.clone()])
6499                .unwrap();
6500        icon_menu.icons = vec![icon.clone(); PHONE_ICON_MENU_MAX_ICONS + 1];
6501        assert!(matches!(
6502            icon_menu.to_xml(),
6503            Err(PhoneXmlError::LimitExceeded { .. })
6504        ));
6505
6506        for invalid_icon in [
6507            CiscoIpPhoneIconItem {
6508                width: 0,
6509                ..icon.clone()
6510            },
6511            CiscoIpPhoneIconItem {
6512                height: 11,
6513                ..icon.clone()
6514            },
6515            CiscoIpPhoneIconItem {
6516                depth: 3,
6517                ..icon.clone()
6518            },
6519            CiscoIpPhoneIconItem {
6520                data: Some("0".into()),
6521                ..icon.clone()
6522            },
6523            CiscoIpPhoneIconItem {
6524                data: Some("GG".into()),
6525                ..icon.clone()
6526            },
6527            CiscoIpPhoneIconItem {
6528                data: Some("00".repeat(41)),
6529                ..icon
6530            },
6531        ] {
6532            assert!(
6533                CiscoIpPhoneIconMenu::new(
6534                    "Icons",
6535                    "Choose",
6536                    vec![item.clone()],
6537                    vec![invalid_icon]
6538                )
6539                .is_err()
6540            );
6541        }
6542        let mut invalid_item = item;
6543        invalid_item.icon_index = Some(10);
6544        assert!(
6545            CiscoIpPhoneIconMenu::new("Icons", "Choose", vec![invalid_item], vec![icon]).is_err()
6546        );
6547
6548        let mut file_menu = CiscoIpPhoneIconFileMenu {
6549            keypad_target: None,
6550            application_id: None,
6551            on_focus_lost: None,
6552            on_focus_gained: None,
6553            on_minimized: None,
6554            on_closed: None,
6555            icon_index: None,
6556            title: None,
6557            prompt: None,
6558            soft_keys: Vec::new(),
6559            key_items: Vec::new(),
6560            items: Vec::new(),
6561            icons: vec![CiscoIpPhoneIconFileItem {
6562                index: 10,
6563                url: "Resource:Icon.Hold".into(),
6564            }],
6565        };
6566        assert!(matches!(
6567            file_menu.to_xml(),
6568            Err(PhoneXmlError::InvalidField { .. })
6569        ));
6570        file_menu.icons[0].index = 0;
6571        file_menu.icons[0].url.clear();
6572        assert!(matches!(
6573            file_menu.to_xml(),
6574            Err(PhoneXmlError::InvalidField { .. })
6575        ));
6576    }
6577
6578    #[test]
6579    fn menu_parsers_reject_wrong_roots_unknown_fields_malformed_input_and_writer_failure() {
6580        assert!(CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneIconMenu/>").is_err());
6581        assert!(CiscoIpPhoneIconMenu::from_xml(b"<CiscoIPPhoneMenu/>").is_err());
6582        assert!(CiscoIpPhoneIconFileMenu::from_xml(b"<CiscoIPPhoneIconMenu/>").is_err());
6583        assert!(
6584            CiscoIpPhoneMenu::from_xml(b"<CiscoIPPhoneMenu><Unknown/></CiscoIPPhoneMenu>",)
6585                .is_err()
6586        );
6587        assert!(CiscoIpPhoneIconMenu::from_xml(b"<CiscoIPPhoneIconMenu>").is_err());
6588        assert!(
6589            CiscoIpPhoneIconFileMenu::from_xml(b"<!DOCTYPE menu><CiscoIPPhoneIconFileMenu/>",)
6590                .is_err()
6591        );
6592        assert!(matches!(
6593            CiscoIpPhoneMenu::from_xml(&[0xff]),
6594            Err(PhoneXmlError::InvalidUtf8(_))
6595        ));
6596        assert!(matches!(
6597            complete_menu().to_xml_with_limit(10),
6598            Err(PhoneXmlError::LimitExceeded { .. })
6599        ));
6600
6601        #[derive(Debug)]
6602        struct FailingWriter;
6603        impl fmt::Write for FailingWriter {
6604            fn write_str(&mut self, _value: &str) -> fmt::Result {
6605                Err(fmt::Error)
6606            }
6607        }
6608        assert!(matches!(
6609            to_writer(FailingWriter, &complete_menu(), PHONE_MENU_MAX_BYTES),
6610            Err(PhoneXmlError::Write(_))
6611        ));
6612    }
6613
6614    #[test]
6615    fn conference_lists_round_trip_menu_and_icon_families_with_typed_actions() {
6616        let conference_id = ConferenceId::new(41);
6617        let participants = [
6618            ConferenceListEntry {
6619                participant_id: ParticipantId::new(7),
6620                name: "Alex <Host> & Co".into(),
6621                number: "2100".into(),
6622                moderator: true,
6623                muted: false,
6624            },
6625            ConferenceListEntry {
6626                participant_id: ParticipantId::new(8),
6627                name: String::new(),
6628                number: "2200".into(),
6629                moderator: false,
6630                muted: true,
6631            },
6632            ConferenceListEntry {
6633                participant_id: ParticipantId::new(9),
6634                name: "Casey".into(),
6635                number: "2300".into(),
6636                moderator: false,
6637                muted: false,
6638            },
6639        ];
6640        for family in [ConferenceMenuFamily::Menu, ConferenceMenuFamily::IconMenu] {
6641            let expected =
6642                ConferenceListDocument::new(conference_id, &participants, family).unwrap();
6643            let xml = expected.to_xml().unwrap();
6644            assert!(xml.contains("Alex &lt;Host&gt; &amp; Co"));
6645            let decoded = ConferenceListDocument::from_xml(xml.as_bytes(), family).unwrap();
6646            assert_eq!(decoded, expected);
6647            assert_eq!(
6648                decoded.actions().collect::<Vec<_>>(),
6649                [
6650                    ConferenceListAction::Participant {
6651                        conference_id,
6652                        participant_id: ParticipantId::new(7),
6653                    },
6654                    ConferenceListAction::Participant {
6655                        conference_id,
6656                        participant_id: ParticipantId::new(8),
6657                    },
6658                    ConferenceListAction::Participant {
6659                        conference_id,
6660                        participant_id: ParticipantId::new(9),
6661                    },
6662                    ConferenceListAction::End { conference_id },
6663                ]
6664            );
6665        }
6666    }
6667
6668    #[test]
6669    fn conference_participant_actions_round_trip_both_families_and_removal_policy() {
6670        let conference_id = ConferenceId::new(41);
6671        let mut participant = ConferenceListEntry {
6672            participant_id: ParticipantId::new(8),
6673            name: "Alex <Admin> & Co".into(),
6674            number: "2200".into(),
6675            moderator: false,
6676            muted: false,
6677        };
6678        for family in [ConferenceMenuFamily::Menu, ConferenceMenuFamily::IconMenu] {
6679            let expected = ConferenceParticipantActionsDocument::new(
6680                conference_id,
6681                &participant,
6682                true,
6683                false,
6684                family,
6685            )
6686            .unwrap();
6687            let xml = expected.to_xml().unwrap();
6688            let decoded =
6689                ConferenceParticipantActionsDocument::from_xml(xml.as_bytes(), family).unwrap();
6690            assert_eq!(decoded, expected);
6691            assert_eq!(
6692                decoded.actions().collect::<Vec<_>>(),
6693                [
6694                    ConferenceListAction::Mute {
6695                        conference_id,
6696                        participant_id: participant.participant_id,
6697                    },
6698                    ConferenceListAction::Remove {
6699                        conference_id,
6700                        participant_id: participant.participant_id,
6701                    },
6702                    ConferenceListAction::Promote {
6703                        conference_id,
6704                        participant_id: participant.participant_id,
6705                    },
6706                ]
6707            );
6708
6709            participant.muted = true;
6710            let not_removable = ConferenceParticipantActionsDocument::new(
6711                conference_id,
6712                &participant,
6713                false,
6714                false,
6715                family,
6716            )
6717            .unwrap();
6718            assert_eq!(
6719                not_removable.actions().collect::<Vec<_>>(),
6720                [
6721                    ConferenceListAction::Unmute {
6722                        conference_id,
6723                        participant_id: participant.participant_id,
6724                    },
6725                    ConferenceListAction::Promote {
6726                        conference_id,
6727                        participant_id: participant.participant_id,
6728                    },
6729                ]
6730            );
6731            participant.moderator = true;
6732            let demotable = ConferenceParticipantActionsDocument::new(
6733                conference_id,
6734                &participant,
6735                false,
6736                true,
6737                family,
6738            )
6739            .unwrap();
6740            assert_eq!(
6741                demotable.actions().collect::<Vec<_>>(),
6742                [ConferenceListAction::Demote {
6743                    conference_id,
6744                    participant_id: participant.participant_id,
6745                }]
6746            );
6747            let sole_moderator = ConferenceParticipantActionsDocument::new(
6748                conference_id,
6749                &participant,
6750                false,
6751                false,
6752                family,
6753            )
6754            .unwrap();
6755            assert!(sole_moderator.actions().next().is_none());
6756            participant.moderator = false;
6757            participant.muted = false;
6758        }
6759    }
6760
6761    #[test]
6762    fn conference_lists_reject_limits_malformed_actions_and_wrong_family() {
6763        let participants = vec![
6764            ConferenceListEntry {
6765                participant_id: ParticipantId::new(1),
6766                name: "Participant".into(),
6767                number: String::new(),
6768                moderator: false,
6769                muted: false,
6770            };
6771            CONFERENCE_LIST_MAX_PARTICIPANTS + 1
6772        ];
6773        assert!(matches!(
6774            ConferenceListDocument::new(
6775                ConferenceId::new(1),
6776                &participants,
6777                ConferenceMenuFamily::Menu,
6778            ),
6779            Err(PhoneXmlError::LimitExceeded {
6780                kind: "conference participants",
6781                ..
6782            })
6783        ));
6784        assert!(ConferenceListAction::parse("conference/1/participant/not-a-number").is_none());
6785        assert!(ConferenceListAction::parse("conference/1/remove/7").is_none());
6786        assert_eq!(
6787            ConferenceListAction::parse("conference/1/participant/7/remove"),
6788            Some(ConferenceListAction::Remove {
6789                conference_id: ConferenceId::new(1),
6790                participant_id: ParticipantId::new(7),
6791            })
6792        );
6793        assert_eq!(
6794            ConferenceListAction::from_route(&[
6795                "conference".into(),
6796                "1".into(),
6797                "participant".into(),
6798                "7".into(),
6799                "remove".into(),
6800            ]),
6801            Some(ConferenceListAction::Remove {
6802                conference_id: ConferenceId::new(1),
6803                participant_id: ParticipantId::new(7),
6804            })
6805        );
6806        for (operation, expected) in [
6807            (
6808                "promote",
6809                ConferenceListAction::Promote {
6810                    conference_id: ConferenceId::new(1),
6811                    participant_id: ParticipantId::new(7),
6812                },
6813            ),
6814            (
6815                "demote",
6816                ConferenceListAction::Demote {
6817                    conference_id: ConferenceId::new(1),
6818                    participant_id: ParticipantId::new(7),
6819                },
6820            ),
6821        ] {
6822            let route = [
6823                "conference".into(),
6824                "1".into(),
6825                "participant".into(),
6826                "7".into(),
6827                operation.into(),
6828            ];
6829            assert_eq!(ConferenceListAction::from_route(&route), Some(expected));
6830        }
6831
6832        let menu = ConferenceListDocument::new(
6833            ConferenceId::new(1),
6834            &participants[..1],
6835            ConferenceMenuFamily::Menu,
6836        )
6837        .unwrap()
6838        .to_xml()
6839        .unwrap();
6840        assert!(
6841            ConferenceListDocument::from_xml(menu.as_bytes(), ConferenceMenuFamily::IconMenu)
6842                .is_err()
6843        );
6844        assert!(
6845            ConferenceListDocument::from_xml(
6846                b"<!DOCTYPE menu><CiscoIPPhoneMenu/>",
6847                ConferenceMenuFamily::Menu,
6848            )
6849            .is_err()
6850        );
6851    }
6852
6853    #[test]
6854    fn directory_schema_round_trips_entries_controls_attributes_and_escaping() {
6855        let expected = CiscoIpPhoneDirectory {
6856            keypad_target: Some(PhoneKeypadTarget::ApplicationCall),
6857            application_id: Some("directory-west".into()),
6858            on_focus_lost: Some("Notify:focus?state=lost&view=all".into()),
6859            on_focus_gained: None,
6860            on_minimized: None,
6861            on_closed: Some("SoftKey:Exit".into()),
6862            title: Some("R&D <West>".into()),
6863            prompt: Some("Choose A & B".into()),
6864            soft_keys: vec![CiscoIpPhoneSoftKeyItem {
6865                name: Some("Next".into()),
6866                position: PhoneSoftKeyPosition::new(3).unwrap(),
6867                url: Some("http://pbx.test/directory?page=2&query=R%26D".into()),
6868                url_down: None,
6869            }],
6870            key_items: vec![CiscoIpPhoneKeyItem {
6871                key: PhoneXmlKey::NavBack,
6872                url: Some("SoftKey:Cancel".into()),
6873                url_down: None,
6874            }],
6875            entries: vec![CiscoIpPhoneDirectoryEntry {
6876                name: Some("Alice <Admin> & Bob".into()),
6877                telephone: Some("1001&2".into()),
6878            }],
6879        };
6880
6881        let xml = expected.to_xml().unwrap();
6882        assert!(xml.starts_with("<CiscoIPPhoneDirectory"));
6883        assert!(xml.contains("keypadTarget=\"applicationCall\""));
6884        assert!(xml.contains("R&amp;D &lt;West&gt;"));
6885        assert!(xml.contains("Alice &lt;Admin&gt; &amp; Bob"));
6886        assert_eq!(
6887            CiscoIpPhoneDirectory::from_xml(xml.as_bytes()).unwrap(),
6888            expected
6889        );
6890    }
6891
6892    #[test]
6893    fn directory_schema_accepts_the_minimal_document_and_optionally_empty_fields() {
6894        let xml = b"<CiscoIPPhoneDirectory><Title/><Prompt/><DirectoryEntry><Name/><Telephone/></DirectoryEntry></CiscoIPPhoneDirectory>";
6895        let document = CiscoIpPhoneDirectory::from_xml(xml).unwrap();
6896        assert_eq!(document.title.as_deref(), Some(""));
6897        assert_eq!(document.prompt.as_deref(), Some(""));
6898        assert_eq!(document.entries.len(), 1);
6899        assert_eq!(document.entries[0].name.as_deref(), Some(""));
6900        assert_eq!(document.entries[0].telephone.as_deref(), Some(""));
6901    }
6902
6903    #[test]
6904    fn directory_schema_enforces_entry_text_control_and_document_bounds() {
6905        let too_many = vec![
6906            CiscoIpPhoneDirectoryEntry {
6907                name: Some("Name".into()),
6908                telephone: Some("1000".into()),
6909            };
6910            PHONE_DIRECTORY_MAX_ENTRIES + 1
6911        ];
6912        assert!(matches!(
6913            CiscoIpPhoneDirectory::new("Directory", "Choose", too_many),
6914            Err(PhoneXmlError::LimitExceeded {
6915                kind: "directory entries",
6916                ..
6917            })
6918        ));
6919
6920        let invalid = CiscoIpPhoneDirectory::new(
6921            "Directory",
6922            "Choose",
6923            vec![CiscoIpPhoneDirectoryEntry {
6924                name: Some("x".repeat(PHONE_DIRECTORY_TEXT_MAX_CHARS + 1)),
6925                telephone: Some("1000".into()),
6926            }],
6927        )
6928        .unwrap_err();
6929        assert!(matches!(invalid, PhoneXmlError::InvalidField { .. }));
6930
6931        assert!(PhoneSoftKeyPosition::new(0).is_err());
6932        assert!(PhoneSoftKeyPosition::new(-1).is_ok());
6933        assert!(PhoneSoftKeyPosition::new(16).is_ok());
6934        assert!(PhoneSoftKeyPosition::new(17).is_err());
6935
6936        assert!(
6937            CiscoIpPhoneDirectory::from_xml(b"<!DOCTYPE directory><CiscoIPPhoneDirectory/>",)
6938                .is_err()
6939        );
6940        assert!(matches!(
6941            CiscoIpPhoneDirectory::from_xml(&vec![b'x'; PHONE_DIRECTORY_MAX_BYTES + 1]),
6942            Err(PhoneXmlError::LimitExceeded { .. })
6943        ));
6944    }
6945}