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