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