1use std::fmt::Write as _;
16
17pub const DIALOG_INFO_TYPE: &str = "application/dialog-info+xml";
19pub const REGINFO_TYPE: &str = "application/reginfo+xml";
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DialogState {
29 Trying,
31 Proceeding,
33 Early,
35 Confirmed,
37 Terminated,
39}
40
41impl DialogState {
42 #[must_use]
44 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::Trying => "trying",
47 Self::Proceeding => "proceeding",
48 Self::Early => "early",
49 Self::Confirmed => "confirmed",
50 Self::Terminated => "terminated",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Direction {
58 Initiator,
60 Recipient,
62}
63
64impl Direction {
65 #[must_use]
67 pub fn as_str(self) -> &'static str {
68 match self {
69 Self::Initiator => "initiator",
70 Self::Recipient => "recipient",
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct Dialog {
78 pub id: String,
80 pub state: DialogState,
82 pub direction: Direction,
84}
85
86#[derive(Debug)]
90pub struct DialogWatch {
91 entity: String,
92 version: u32,
93 sent_full: bool,
95}
96
97impl DialogWatch {
98 #[must_use]
100 pub fn new(entity: impl Into<String>) -> Self {
101 Self {
102 entity: entity.into(),
103 version: 0,
104 sent_full: false,
105 }
106 }
107
108 #[must_use]
110 pub fn package() -> &'static str {
111 "dialog"
112 }
113
114 #[must_use]
116 pub fn version(&self) -> u32 {
117 self.version
118 }
119
120 pub fn document(&mut self, dialogs: &[Dialog]) -> String {
129 let full = !self.sent_full;
130 self.sent_full = true;
131 let version = self.version;
132 self.version = self.version.saturating_add(1);
135
136 let mut out = String::with_capacity(256);
137 out.push_str("<?xml version=\"1.0\"?>\n");
138 let _ = write!(
139 out,
140 "<dialog-info xmlns=\"urn:ietf:params:xml:ns:dialog-info\" version=\"{version}\" \
141 state=\"{}\" entity=\"{}\">",
142 if full { "full" } else { "partial" },
143 escape(&self.entity)
144 );
145 for dialog in dialogs {
146 let _ = write!(
147 out,
148 "\n <dialog id=\"{}\" direction=\"{}\">\n <state>{}</state>\n </dialog>",
149 escape(&dialog.id),
150 dialog.direction.as_str(),
151 dialog.state.as_str()
152 );
153 }
154 out.push_str("\n</dialog-info>\n");
155 out
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ContactEvent {
162 Registered,
164 Refreshed,
166 Expired,
168 Unregistered,
170}
171
172impl ContactEvent {
173 #[must_use]
175 pub fn as_str(self) -> &'static str {
176 match self {
177 Self::Registered => "registered",
178 Self::Refreshed => "refreshed",
179 Self::Expired => "expired",
180 Self::Unregistered => "unregistered",
181 }
182 }
183
184 #[must_use]
190 pub fn still_bound(self) -> bool {
191 matches!(self, Self::Registered | Self::Refreshed)
192 }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Contact {
198 pub id: String,
200 pub uri: String,
202 pub event: ContactEvent,
204}
205
206#[derive(Debug)]
208pub struct RegistrationWatch {
209 entity: String,
210 version: u32,
211 sent_full: bool,
212}
213
214impl RegistrationWatch {
215 #[must_use]
217 pub fn new(entity: impl Into<String>) -> Self {
218 Self {
219 entity: entity.into(),
220 version: 0,
221 sent_full: false,
222 }
223 }
224
225 #[must_use]
227 pub fn package() -> &'static str {
228 "reg"
229 }
230
231 #[must_use]
233 pub fn version(&self) -> u32 {
234 self.version
235 }
236
237 pub fn document(&mut self, contacts: &[Contact]) -> String {
239 let full = !self.sent_full;
240 self.sent_full = true;
241 let version = self.version;
242 self.version = self.version.saturating_add(1);
243
244 let mut out = String::with_capacity(256);
245 out.push_str("<?xml version=\"1.0\"?>\n");
246 let _ = write!(
247 out,
248 "<reginfo xmlns=\"urn:ietf:params:xml:ns:reginfo\" version=\"{version}\" \
249 state=\"{}\">",
250 if full { "full" } else { "partial" }
251 );
252 let bound = contacts.iter().any(|contact| contact.event.still_bound());
253 let _ = write!(
254 out,
255 "\n <registration aor=\"{}\" id=\"0\" state=\"{}\">",
256 escape(&self.entity),
257 if bound { "active" } else { "terminated" }
258 );
259 for contact in contacts {
260 let _ = write!(
261 out,
262 "\n <contact id=\"{}\" state=\"{}\" event=\"{}\">\n <uri>{}</uri>\n </contact>",
263 escape(&contact.id),
264 if contact.event.still_bound() {
265 "active"
266 } else {
267 "terminated"
268 },
269 contact.event.as_str(),
270 escape(&contact.uri)
271 );
272 }
273 out.push_str("\n </registration>\n</reginfo>\n");
274 out
275 }
276}
277
278fn escape(value: &str) -> String {
283 let mut out = String::with_capacity(value.len());
284 for character in value.chars() {
285 match character {
286 '&' => out.push_str("&"),
287 '<' => out.push_str("<"),
288 '>' => out.push_str(">"),
289 '"' => out.push_str("""),
290 '\'' => out.push_str("'"),
291 other => out.push(other),
292 }
293 }
294 out
295}
296
297#[cfg(test)]
298#[allow(
299 clippy::unwrap_used,
300 clippy::expect_used,
301 clippy::panic,
302 clippy::indexing_slicing
303)]
304mod tests {
305 use super::*;
306
307 fn ringing() -> Dialog {
308 Dialog {
309 id: "d1".to_owned(),
310 state: DialogState::Early,
311 direction: Direction::Recipient,
312 }
313 }
314
315 #[test]
318 fn the_first_document_is_full_and_the_rest_are_partial() {
319 let mut watch = DialogWatch::new("sip:alice@sipx.test");
320 let first = watch.document(&[ringing()]);
321 assert!(first.contains("version=\"0\""), "{first}");
322 assert!(first.contains("state=\"full\""), "{first}");
323
324 let second = watch.document(&[ringing()]);
325 assert!(second.contains("version=\"1\""), "{second}");
326 assert!(
327 second.contains("state=\"partial\""),
328 "a watcher is given the whole picture once and told about changes after: {second}"
329 );
330 }
331
332 #[test]
335 fn two_watchers_each_count_from_zero() {
336 let mut one = DialogWatch::new("sip:alice@sipx.test");
337 let mut other = DialogWatch::new("sip:alice@sipx.test");
338 let _ = one.document(&[ringing()]);
339 let _ = one.document(&[ringing()]);
340 assert_eq!(one.version(), 2);
341 assert_eq!(other.version(), 0, "a second watcher starts its own count");
342 assert!(other.document(&[ringing()]).contains("version=\"0\""));
343 }
344
345 #[test]
346 fn the_version_increases_monotonically() {
347 let mut watch = DialogWatch::new("sip:alice@sipx.test");
348 let mut seen = Vec::new();
349 for _ in 0..5u32 {
350 let document = watch.document(&[ringing()]);
351 let version: u32 = document
354 .split("<dialog-info")
355 .nth(1)
356 .and_then(|element| element.split("version=\"").nth(1))
357 .and_then(|rest| rest.split('"').next())
358 .and_then(|text| text.parse().ok())
359 .expect("a version on the dialog-info element");
360 seen.push(version);
361 }
362 assert_eq!(seen, vec![0, 1, 2, 3, 4]);
363 }
364
365 #[test]
370 fn a_watcher_sees_a_dialog_reach_confirmed_and_then_terminate() {
371 let mut watch = DialogWatch::new("sip:alice@sipx.test");
372
373 let ringing = watch.document(&[Dialog {
374 id: "d1".to_owned(),
375 state: DialogState::Early,
376 direction: Direction::Recipient,
377 }]);
378 assert!(ringing.contains("<state>early</state>"), "{ringing}");
379
380 let answered = watch.document(&[Dialog {
381 id: "d1".to_owned(),
382 state: DialogState::Confirmed,
383 direction: Direction::Recipient,
384 }]);
385 assert!(answered.contains("<state>confirmed</state>"), "{answered}");
386
387 let ended = watch.document(&[Dialog {
388 id: "d1".to_owned(),
389 state: DialogState::Terminated,
390 direction: Direction::Recipient,
391 }]);
392 assert!(ended.contains("<state>terminated</state>"), "{ended}");
393
394 for document in [&ringing, &answered, &ended] {
396 assert!(document.contains("id=\"d1\""), "{document}");
397 }
398 }
399
400 #[test]
401 fn a_dialog_document_names_its_namespace_and_entity() {
402 let mut watch = DialogWatch::new("sip:alice@sipx.test");
403 let document = watch.document(&[]);
404 assert!(
405 document.contains("xmlns=\"urn:ietf:params:xml:ns:dialog-info\""),
406 "{document}"
407 );
408 assert!(
409 document.contains("entity=\"sip:alice@sipx.test\""),
410 "{document}"
411 );
412 assert_eq!(DIALOG_INFO_TYPE, "application/dialog-info+xml");
413 }
414
415 #[test]
416 fn every_dialog_state_has_the_spelling_the_rfc_gives() {
417 for (state, token) in [
418 (DialogState::Trying, "trying"),
419 (DialogState::Proceeding, "proceeding"),
420 (DialogState::Early, "early"),
421 (DialogState::Confirmed, "confirmed"),
422 (DialogState::Terminated, "terminated"),
423 ] {
424 assert_eq!(state.as_str(), token);
425 }
426 }
427
428 #[test]
430 fn a_registration_document_reports_the_event_that_changed_a_contact() {
431 let mut watch = RegistrationWatch::new("sip:alice@sipx.test");
432 let document = watch.document(&[Contact {
433 id: "c1".to_owned(),
434 uri: "sip:alice@192.0.2.5".to_owned(),
435 event: ContactEvent::Registered,
436 }]);
437 assert!(
438 document.contains("xmlns=\"urn:ietf:params:xml:ns:reginfo\""),
439 "{document}"
440 );
441 assert!(document.contains("event=\"registered\""), "{document}");
442 assert!(document.contains("state=\"active\""), "{document}");
443 assert!(
444 document.contains("<uri>sip:alice@192.0.2.5</uri>"),
445 "{document}"
446 );
447 assert_eq!(REGINFO_TYPE, "application/reginfo+xml");
448 }
449
450 #[test]
453 fn an_expired_contact_and_an_unregistered_one_are_both_gone_and_not_the_same() {
454 assert!(!ContactEvent::Expired.still_bound());
455 assert!(!ContactEvent::Unregistered.still_bound());
456 assert!(ContactEvent::Registered.still_bound());
457 assert!(ContactEvent::Refreshed.still_bound());
458 assert_ne!(ContactEvent::Expired, ContactEvent::Unregistered);
459
460 let mut watch = RegistrationWatch::new("sip:alice@sipx.test");
461 let document = watch.document(&[Contact {
462 id: "c1".to_owned(),
463 uri: "sip:alice@192.0.2.5".to_owned(),
464 event: ContactEvent::Expired,
465 }]);
466 assert!(document.contains("event=\"expired\""), "{document}");
467 assert!(
468 document.contains("state=\"terminated\""),
469 "a contact that expired is not active: {document}"
470 );
471 }
472
473 #[test]
476 fn a_uri_containing_xml_metacharacters_does_not_break_the_document() {
477 let mut watch = RegistrationWatch::new("sip:alice@sipx.test");
478 let document = watch.document(&[Contact {
479 id: "c1".to_owned(),
480 uri: "sip:alice@host?X=1&Y=<2>".to_owned(),
481 event: ContactEvent::Registered,
482 }]);
483 assert!(document.contains("&"), "{document}");
484 assert!(document.contains("<2>"), "{document}");
485 assert!(
486 !document.contains("Y=<2>"),
487 "the raw angle brackets must not survive: {document}"
488 );
489 }
490
491 #[test]
492 fn both_packages_are_named_the_way_a_subscriber_asks_for_them() {
493 assert_eq!(DialogWatch::package(), "dialog");
494 assert_eq!(RegistrationWatch::package(), "reg");
495 }
496}