1use 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
22pub const CONFERENCE_LIST_MAX_PARTICIPANTS: usize = 16;
24pub const CONFERENCE_LIST_MAX_BYTES: usize = 2_000;
26pub const PHONE_DIRECTORY_MAX_ENTRIES: usize = 32;
28pub const PHONE_DIRECTORY_MAX_BYTES: usize = 8_192;
30pub const PHONE_MENU_MAX_ITEMS: usize = 100;
32pub const PHONE_ICON_MENU_MAX_ITEMS: usize = 32;
34pub const PHONE_ICON_MENU_MAX_ICONS: usize = 10;
36pub const PHONE_MENU_MAX_BYTES: usize = 64 * 1_024;
38pub const PHONE_TEXT_MAX_CHARS: usize = 4_000;
40pub const PHONE_TEXT_MAX_BYTES: usize = 32 * 1_024;
42pub const PHONE_TEXT_LEGACY_MAX_CHARS: usize = 1_024;
44pub const PHONE_TEXT_APPLICATION_ID: u32 = 9_089;
46pub const PHONE_INPUT_MAX_ITEMS: usize = 5;
48pub const PHONE_INPUT_MAX_BYTES: usize = 32 * 1_024;
50pub const PHONE_EXECUTE_MAX_ITEMS: usize = 3;
52pub const PHONE_EXECUTE_MAX_BYTES: usize = 8 * 1_024;
54pub const PHONE_IMAGE_BITMAP_MAX_BYTES: usize = 2_162;
56pub const PHONE_GRAPHIC_MENU_MAX_ITEMS: usize = 12;
58pub const PHONE_GRAPHIC_FILE_MENU_MAX_ITEMS: usize = 32;
60pub const PHONE_IMAGE_MAX_BYTES: usize = 64 * 1_024;
62pub const PHONE_STATUS_BITMAP_MAX_BYTES: usize = 557;
64pub const PHONE_STATUS_MAX_BYTES: usize = 8 * 1_024;
66pub const PHONE_ALARM_MAX_BYTES: usize = 2_048;
68pub const PHONE_LOCATION_MAX_BYTES: usize = 2_404;
70pub const PHONE_BACKGROUND_APPLICATION_ID: u32 = 9_086;
72pub const PHONE_BACKGROUND_LIST_MAX_ITEMS: usize = 50;
74pub const PHONE_BACKGROUND_LIST_MAX_BYTES: usize = 32 * 1_024;
76pub const PHONE_BACKGROUND_CONTROL_MAX_BYTES: usize = 2_000;
78pub const PHONE_RINGTONE_APPLICATION_ID: u32 = 9_087;
80pub const PHONE_RINGTONE_MAX_BYTES: usize = 2_000;
82pub 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#[derive(Debug, Error)]
89pub enum PhoneXmlError {
90 #[error("{kind} has {actual} entries or bytes; maximum is {maximum}")]
92 LimitExceeded {
93 kind: &'static str,
94 actual: usize,
95 maximum: usize,
96 },
97 #[error("phone XML is not valid UTF-8: {0}")]
99 InvalidUtf8(#[source] std::str::Utf8Error),
100 #[error("phone XML document types and entity declarations are not allowed")]
102 DocumentTypeForbidden,
103 #[error("phone XML contains an invalid or undeclared entity reference")]
105 InvalidEntity,
106 #[error("supported phone alarm does not match its typed schema")]
108 InvalidAlarmSchema,
109 #[error("supported phone location information does not match its typed schema")]
111 InvalidLocationSchema,
112 #[error("phone XML nesting exceeds the maximum depth of {maximum}")]
114 NestingTooDeep { maximum: usize },
115 #[error("phone XML is malformed: {0}")]
117 Malformed(#[source] quick_xml::Error),
118 #[error("phone XML does not match its typed schema: {0}")]
120 Deserialize(#[source] quick_xml::DeError),
121 #[error("phone XML could not be serialized: {0}")]
123 Serialize(#[source] quick_xml::SeError),
124 #[error("phone XML could not be written: {0}")]
126 Write(#[source] fmt::Error),
127 #[error("{field} must be {expected}")]
129 InvalidField {
130 field: &'static str,
131 expected: &'static str,
132 },
133}
134
135#[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#[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#[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
190pub struct PhoneSoftKeyPosition(i8);
191
192impl PhoneSoftKeyPosition {
193 pub const APPLICATION: Self = Self(-1);
195
196 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 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#[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#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
288pub struct PhoneXmlRefresh {
289 delay_seconds: u32,
290 url: String,
291}
292
293impl PhoneXmlRefresh {
294 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 pub fn http_header_value(&self) -> String {
330 format!("{};url={}", self.delay_seconds, self.url)
331 }
332}
333
334#[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 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 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
431 <Self as PhoneXmlDocument>::parse_xml(document)
432 }
433
434 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
444 <Self as PhoneXmlDocument>::serialize_xml(self)
445 }
446
447 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#[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 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#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
523#[serde(transparent)]
524pub struct PhoneInputParameterName(String);
525
526impl PhoneInputParameterName {
527 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#[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#[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 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 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
692 <Self as PhoneXmlDocument>::parse_xml(document)
693 }
694
695 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
705 <Self as PhoneXmlDocument>::serialize_xml(self)
706 }
707
708 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#[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 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#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
760pub enum PhoneActionKind {
761 Http,
763 Internal,
765}
766
767#[derive(Clone, Debug, Eq, Hash, PartialEq)]
769pub struct PhoneExecuteUrl {
770 value: String,
771 kind: PhoneActionKind,
772}
773
774impl PhoneExecuteUrl {
775 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#[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 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 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#[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 pub fn new(items: Vec<CiscoIpPhoneExecuteItem>) -> Result<Self, PhoneXmlError> {
858 let document = Self { items };
859 document.validate()?;
860 Ok(document)
861 }
862
863 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
893 <Self as PhoneXmlDocument>::parse_xml(document)
894 }
895
896 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
906 <Self as PhoneXmlDocument>::serialize_xml(self)
907 }
908
909 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#[derive(Clone, Debug, Eq, PartialEq)]
921pub struct PhoneBitmapData(Vec<u8>);
922
923impl PhoneBitmapData {
924 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#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
1001#[serde(transparent)]
1002pub struct PhoneImageUrl(String);
1003
1004impl PhoneImageUrl {
1005 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#[derive(Clone, Eq, Hash, PartialEq)]
1033pub struct PhoneBackgroundTftpUrl(String);
1034
1035impl PhoneBackgroundTftpUrl {
1036 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#[derive(Clone, Eq, Hash, PartialEq)]
1146pub struct PhoneBackgroundHttpUrl(String);
1147
1148impl PhoneBackgroundHttpUrl {
1149 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#[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#[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 pub fn new(items: Vec<CiscoIpPhoneImageListItem>) -> Result<Self, PhoneXmlError> {
1242 let document = Self { items };
1243 document.validate()?;
1244 Ok(document)
1245 }
1246
1247 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
1258 <Self as PhoneXmlDocument>::parse_xml(document)
1259 }
1260
1261 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1263 <Self as PhoneXmlDocument>::serialize_xml(self)
1264 }
1265
1266 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#[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#[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 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1316 to_string(self, PHONE_BACKGROUND_CONTROL_MAX_BYTES)
1317 }
1318}
1319
1320#[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 pub const fn new(image_url: PhoneBackgroundHttpUrl) -> Self {
1331 Self { image_url }
1332 }
1333
1334 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1349 to_string(self, PHONE_BACKGROUND_CONTROL_MAX_BYTES)
1350 }
1351}
1352
1353#[derive(Clone, Debug, Eq, PartialEq)]
1355pub enum PhoneBackgroundControlDocument {
1356 Set(CiscoIpPhoneSetBackground),
1357 Preview(CiscoIpPhoneSetBackgroundPreview),
1358}
1359
1360impl PhoneBackgroundControlDocument {
1361 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1381 self.to_xml_with_limit(PHONE_BACKGROUND_CONTROL_MAX_BYTES)
1382 }
1383
1384 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#[derive(Clone, Eq, Hash, PartialEq)]
1395pub struct PhoneRingtoneUrl(String);
1396
1397impl PhoneRingtoneUrl {
1398 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#[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 pub const fn new(ringtone_url: PhoneRingtoneUrl) -> Self {
1460 Self { ringtone_url }
1461 }
1462
1463 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1477 self.to_xml_with_limit(PHONE_RINGTONE_MAX_BYTES)
1478 }
1479
1480 pub fn to_xml_with_limit(&self, maximum_bytes: usize) -> Result<String, PhoneXmlError> {
1482 to_string(self, maximum_bytes)
1483 }
1484}
1485
1486#[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#[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#[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 pub location_x: Option<i16>,
1559 #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1560 pub location_y: Option<i16>,
1562 #[serde(rename = "Width")]
1563 pub width: u16,
1565 #[serde(rename = "Height")]
1566 pub height: u16,
1568 #[serde(rename = "Depth")]
1569 pub depth: u16,
1571 #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
1572 pub data: Option<PhoneBitmapData>,
1573}
1574
1575impl CiscoIpPhoneImage {
1576 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1615 self.validate()?;
1616 to_string(self, PHONE_IMAGE_MAX_BYTES)
1617 }
1618}
1619
1620#[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 pub location_x: Option<i16>,
1667 #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1668 pub location_y: Option<i16>,
1670 #[serde(rename = "URL")]
1671 pub url: PhoneImageUrl,
1672}
1673
1674impl CiscoIpPhoneImageFile {
1675 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1707 self.validate()?;
1708 to_string(self, PHONE_IMAGE_MAX_BYTES)
1709 }
1710}
1711
1712#[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 pub location_x: Option<i16>,
1759 #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1760 pub location_y: Option<i16>,
1762 #[serde(rename = "Width")]
1763 pub width: u16,
1765 #[serde(rename = "Height")]
1766 pub height: u16,
1768 #[serde(rename = "Depth")]
1769 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 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1833 self.validate()?;
1834 to_string(self, PHONE_IMAGE_MAX_BYTES)
1835 }
1836}
1837
1838#[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 pub location_x: Option<i16>,
1885 #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
1886 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 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1943 self.validate()?;
1944 to_string(self, PHONE_IMAGE_MAX_BYTES)
1945 }
1946}
1947
1948#[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 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
1993 self.to_xml_with_limit(PHONE_IMAGE_MAX_BYTES)
1994 }
1995
1996 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#[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 pub timer_seconds: Option<u16>,
2017 #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
2018 pub location_x: Option<i16>,
2020 #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
2021 pub location_y: Option<i16>,
2023 #[serde(rename = "Width")]
2024 pub width: u16,
2026 #[serde(rename = "Height")]
2027 pub height: u16,
2029 #[serde(rename = "Depth")]
2030 pub depth: u16,
2032 #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
2033 pub data: Option<PhoneBitmapData>,
2034}
2035
2036impl CiscoIpPhoneStatus {
2037 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2088 <Self as PhoneXmlDocument>::parse_xml(document)
2089 }
2090
2091 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2101 <Self as PhoneXmlDocument>::serialize_xml(self)
2102 }
2103
2104 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#[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 pub timer_seconds: Option<u16>,
2119 #[serde(rename = "LocationX", default, skip_serializing_if = "Option::is_none")]
2120 pub location_x: Option<i16>,
2122 #[serde(rename = "LocationY", default, skip_serializing_if = "Option::is_none")]
2123 pub location_y: Option<i16>,
2125 #[serde(rename = "URL")]
2126 pub url: PhoneImageUrl,
2127}
2128
2129impl CiscoIpPhoneStatusFile {
2130 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2156 <Self as PhoneXmlDocument>::parse_xml(document)
2157 }
2158
2159 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2169 <Self as PhoneXmlDocument>::serialize_xml(self)
2170 }
2171
2172 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#[derive(Clone, Debug, Eq, PartialEq)]
2180pub enum PhoneStatusDocument {
2181 Bitmap(CiscoIpPhoneStatus),
2182 File(CiscoIpPhoneStatusFile),
2183}
2184
2185impl PhoneStatusDocument {
2186 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2213 self.to_xml_with_limit(PHONE_STATUS_MAX_BYTES)
2214 }
2215
2216 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#[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 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#[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#[derive(Clone, serde::Deserialize, Eq, PartialEq, Serialize)]
2271pub enum CiscoIpPhoneAlarmParameter {
2272 #[serde(rename = "String")]
2274 String(CiscoIpPhoneAlarmString),
2275 #[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#[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#[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#[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 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 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2409 self.validate()?;
2410 to_string(self, PHONE_ALARM_MAX_BYTES)
2411 }
2412
2413 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 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 pub fn reason_for_out_of_service(&self) -> Option<i32> {
2441 self.enumeration("ReasonForOutOfService")
2442 }
2443}
2444
2445#[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#[derive(Clone, Eq, PartialEq)]
2470pub enum PhoneAlarmTelemetry {
2471 LastOutOfService(CiscoIpPhoneAlarm),
2473 Opaque(OpaquePhoneAlarm),
2475}
2476
2477#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2479pub enum PhoneAlarmKind {
2480 LastOutOfService,
2481}
2482
2483#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2485pub struct PhoneAlarmSummary {
2486 pub kind: PhoneAlarmKind,
2487 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 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
2518pub 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#[derive(Clone, Copy, Eq, Hash, PartialEq)]
2567pub struct PhoneBssid([u8; 6]);
2568
2569impl PhoneBssid {
2570 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 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#[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#[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 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#[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 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 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 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#[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#[derive(Clone, Eq, PartialEq)]
2804pub enum PhoneLocationTelemetry {
2805 WirelessInterface(CiscoIpPhoneLocationInformation),
2807 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2822pub enum PhoneLocationKind {
2823 WirelessInterface,
2824}
2825
2826#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2828pub struct PhoneLocationSummary {
2829 pub kind: PhoneLocationKind,
2830 pub off_premises: bool,
2832}
2833
2834impl PhoneLocationTelemetry {
2835 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
2851pub 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#[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#[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 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
2970 <Self as PhoneXmlDocument>::parse_xml(document)
2971 }
2972
2973 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
2975 <Self as PhoneXmlDocument>::serialize_xml(self)
2976 }
2977
2978 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#[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#[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 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 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
3428 <Self as PhoneXmlDocument>::parse_xml(document)
3429 }
3430
3431 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
3441 <Self as PhoneXmlDocument>::serialize_xml(self)
3442 }
3443
3444 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#[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 pub width: u16,
3459 #[serde(rename = "Height")]
3460 pub height: u16,
3462 #[serde(rename = "Depth")]
3463 pub depth: u16,
3465 #[serde(rename = "Data", default, skip_serializing_if = "Option::is_none")]
3466 pub data: Option<String>,
3468}
3469
3470#[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 pub url: String,
3479}
3480
3481#[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 pub icon_index: Option<u16>,
3492}
3493
3494#[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 pub icon_index: Option<u16>,
3505 #[serde(rename = "$text", default)]
3506 pub text: String,
3507}
3508
3509#[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 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 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
3614 <Self as PhoneXmlDocument>::parse_xml(document)
3615 }
3616
3617 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 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
3627 <Self as PhoneXmlDocument>::serialize_xml(self)
3628 }
3629
3630 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#[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 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 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 pub fn from_xml(document: &[u8]) -> Result<Self, PhoneXmlError> {
3729 <Self as PhoneXmlDocument>::parse_xml(document)
3730 }
3731
3732 pub fn to_xml(&self) -> Result<String, PhoneXmlError> {
3734 <Self as PhoneXmlDocument>::serialize_xml(self)
3735 }
3736}
3737
3738#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3740pub enum ConferenceMenuFamily {
3741 Menu,
3742 IconMenu,
3743}
3744
3745#[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3757pub enum ConferenceListAction {
3758 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 pub const APPLICATION_ID: u32 = 9091;
3791
3792 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
3949pub enum ConferenceListDocument {
3950 Menu(CiscoIpPhoneMenu),
3951 IconMenu(CiscoIpPhoneIconMenu),
3952}
3953
3954impl ConferenceListDocument {
3955 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 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 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 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#[derive(Clone, Debug, Eq, PartialEq)]
4063pub enum ConferenceParticipantActionsDocument {
4064 Menu(CiscoIpPhoneMenu),
4065 IconMenu(CiscoIpPhoneIconMenu),
4066}
4067
4068impl ConferenceParticipantActionsDocument {
4069 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 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 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 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
4230pub 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 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
4283pub 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
4299pub 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 <East> & 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 <East> & West"));
4514 assert!(xml.contains("Café <ready> & waiting"));
4515 assert!(xml.contains("id=7&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 <guest>"));
4683 assert!(xml.contains("Enter name & number"));
4684 assert!(xml.contains("NAME&TEAM"));
4685 assert!(xml.contains("Café <guest>"));
4686 assert!(xml.contains("source=phone&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é&view=<all>"/>"#
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&Stars.png"));
5086 assert!(xml.find("Fountain.png").unwrap() < xml.find("Moon&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&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&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&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é <map> & 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 <file>"));
5554 assert!(xml.contains("id=7&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 <wing>"));
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 & 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é <ready> & 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 <file> & refresh"));
5787 assert!(xml.contains(&format!("<Timer>{}</Timer>", u16::MAX)));
5788 assert!(xml.contains("id=7&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 <call-id> & 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></Value></vendor-alarm>").is_err());
6098 assert!(parse_phone_alarm(b"<vendor-alarm value=\"\"/>").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é <voice> & data</SSID>"));
6158 assert!(xml.contains("<APName>West wing <3></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></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 <East> & West"));
6349 assert!(xml.contains("Alice <Admin> & Bob"));
6350 assert!(xml.contains("x=1&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 & 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 & secure</Title>"));
6418 assert!(xml.contains("shade=dark&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 <Host> & 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&D <West>"));
6885 assert!(xml.contains("Alice <Admin> & 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}