1use std::fmt::Write as _;
19use std::time::Duration;
20
21pub const PIDF_TYPE: &str = "application/pidf+xml";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Basic {
31 Open,
33 Closed,
35}
36
37impl Basic {
38 #[must_use]
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::Open => "open",
43 Self::Closed => "closed",
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct Tuple {
51 pub id: String,
53 pub status: Basic,
55 pub contact: Option<String>,
57 pub priority: Option<f32>,
59 pub note: Option<String>,
61}
62
63impl Tuple {
64 #[must_use]
66 pub fn open(id: impl Into<String>) -> Self {
67 Self {
68 id: id.into(),
69 status: Basic::Open,
70 contact: None,
71 priority: None,
72 note: None,
73 }
74 }
75
76 #[must_use]
78 pub fn closed(id: impl Into<String>) -> Self {
79 Self {
80 id: id.into(),
81 status: Basic::Closed,
82 contact: None,
83 priority: None,
84 note: None,
85 }
86 }
87
88 #[must_use]
90 pub fn at(mut self, contact: impl Into<String>) -> Self {
91 self.contact = Some(contact.into());
92 self
93 }
94
95 #[must_use]
97 pub fn with_priority(mut self, priority: f32) -> Self {
98 self.priority = Some(priority.clamp(0.0, 1.0));
101 self
102 }
103
104 #[must_use]
106 pub fn with_note(mut self, note: impl Into<String>) -> Self {
107 self.note = Some(note.into());
108 self
109 }
110}
111
112#[derive(Debug, Clone, PartialEq)]
117pub struct Pidf {
118 pub entity: String,
120 pub tuples: Vec<Tuple>,
122}
123
124impl Pidf {
125 #[must_use]
127 pub fn new(entity: impl Into<String>) -> Self {
128 Self {
129 entity: entity.into(),
130 tuples: Vec::new(),
131 }
132 }
133
134 #[must_use]
136 pub fn with(mut self, tuple: Tuple) -> Self {
137 self.tuples.push(tuple);
138 self
139 }
140
141 #[must_use]
143 pub fn to_xml(&self) -> String {
144 let mut out = String::with_capacity(256);
145 out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
146 let _ = write!(
147 out,
148 "<presence xmlns=\"urn:ietf:params:xml:ns:pidf\" entity=\"{}\">",
149 escape(&self.entity)
150 );
151 for tuple in &self.tuples {
152 let _ = write!(out, "\n <tuple id=\"{}\">", escape(&tuple.id));
153 let _ = write!(
154 out,
155 "\n <status>\n <basic>{}</basic>\n </status>",
156 tuple.status.as_str()
157 );
158 if let Some(contact) = &tuple.contact {
159 match tuple.priority {
160 Some(priority) => {
161 let _ = write!(
162 out,
163 "\n <contact priority=\"{priority}\">{}</contact>",
164 escape(contact)
165 );
166 }
167 None => {
168 let _ = write!(out, "\n <contact>{}</contact>", escape(contact));
169 }
170 }
171 }
172 if let Some(note) = &tuple.note {
173 let _ = write!(out, "\n <note>{}</note>", escape(note));
174 }
175 out.push_str("\n </tuple>");
176 }
177 out.push_str("\n</presence>\n");
178 out
179 }
180}
181
182#[derive(Debug, Clone, PartialEq)]
188pub enum Publish {
189 Initial {
191 body: String,
193 expires: Duration,
195 },
196 Refresh {
198 tag: String,
200 expires: Duration,
202 },
203 Modify {
205 tag: String,
207 body: String,
209 expires: Duration,
211 },
212 Remove {
214 tag: String,
216 },
217 Empty,
222}
223
224impl Publish {
225 #[must_use]
227 pub fn read(tag: Option<String>, body: Option<String>, expires: Duration) -> Self {
228 match (tag, body) {
229 (Some(tag), _) if expires.is_zero() => Self::Remove { tag },
230 (Some(tag), Some(body)) => Self::Modify { tag, body, expires },
231 (Some(tag), None) => Self::Refresh { tag, expires },
232 (None, Some(body)) => Self::Initial { body, expires },
233 (None, None) => Self::Empty,
234 }
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum Published {
241 Accepted {
247 tag: String,
249 expires: Duration,
251 },
252 Removed {
255 tag: String,
257 },
258 ConditionFailed,
265 Invalid,
267 Unavailable,
269}
270
271pub const CONDITIONAL_REQUEST_FAILED: u16 = 412;
273
274#[derive(Debug)]
279pub struct Compositor {
280 held: Vec<Entry>,
281 next_tag: u64,
282 maximum: Duration,
283}
284
285#[derive(Debug)]
286struct Entry {
287 tag: String,
288 entity: String,
289 body: String,
290 expires_at: u64,
291}
292
293impl Compositor {
294 #[must_use]
296 pub fn new(maximum: Duration) -> Self {
297 Self {
298 held: Vec::new(),
299 next_tag: 0,
300 maximum,
301 }
302 }
303
304 #[must_use]
306 pub fn len(&self) -> usize {
307 self.held.len()
308 }
309
310 #[must_use]
312 pub fn is_empty(&self) -> bool {
313 self.held.is_empty()
314 }
315
316 #[must_use]
318 pub fn document(&self, entity: &str) -> Option<&str> {
319 self.held
320 .iter()
321 .find(|entry| entry.entity == entity)
322 .map(|entry| entry.body.as_str())
323 }
324
325 pub fn apply(&mut self, entity: &str, publish: Publish, now: u64) -> Published {
327 match publish {
328 Publish::Empty => Published::Invalid,
329 Publish::Initial { body, expires } => {
330 let expires = expires.min(self.maximum);
331 let Some(tag) = self.mint() else {
332 return Published::Unavailable;
333 };
334 self.held.retain(|entry| entry.entity != entity);
338 self.held.push(Entry {
339 tag: tag.clone(),
340 entity: entity.to_owned(),
341 body,
342 expires_at: now.saturating_add(expires.as_secs()),
343 });
344 Published::Accepted { tag, expires }
345 }
346 Publish::Refresh { tag, expires } => {
347 let expires = expires.min(self.maximum);
348 let Some(index) = self.find(entity, &tag, now) else {
349 return Published::ConditionFailed;
350 };
351 let Some(fresh) = self.mint() else {
352 return Published::Unavailable;
353 };
354 let Some(entry) = self.held.get_mut(index) else {
355 return Published::ConditionFailed;
356 };
357 entry.tag.clone_from(&fresh);
358 entry.expires_at = now.saturating_add(expires.as_secs());
359 Published::Accepted {
360 tag: fresh,
361 expires,
362 }
363 }
364 Publish::Modify { tag, body, expires } => {
365 let expires = expires.min(self.maximum);
366 let Some(index) = self.find(entity, &tag, now) else {
367 return Published::ConditionFailed;
368 };
369 let Some(fresh) = self.mint() else {
370 return Published::Unavailable;
371 };
372 let Some(entry) = self.held.get_mut(index) else {
373 return Published::ConditionFailed;
374 };
375 entry.tag.clone_from(&fresh);
376 entry.body = body;
377 entry.expires_at = now.saturating_add(expires.as_secs());
378 Published::Accepted {
379 tag: fresh,
380 expires,
381 }
382 }
383 Publish::Remove { tag } => {
384 let Some(index) = self.find(entity, &tag, now) else {
385 return Published::ConditionFailed;
386 };
387 let Some(fresh) = self.mint() else {
388 return Published::Unavailable;
389 };
390 self.held.remove(index);
391 Published::Removed { tag: fresh }
392 }
393 }
394 }
395
396 pub fn expire(&mut self, now: u64) -> usize {
398 let before = self.held.len();
399 self.held.retain(|entry| entry.expires_at > now);
400 before - self.held.len()
401 }
402
403 fn find(&self, entity: &str, tag: &str, now: u64) -> Option<usize> {
409 self.held
410 .iter()
411 .position(|entry| entry.entity == entity && entry.tag == tag && entry.expires_at > now)
412 }
413
414 fn mint(&mut self) -> Option<String> {
415 self.next_tag = self.next_tag.checked_add(1)?;
416 Some(format!("sipx-{:016x}", self.next_tag))
417 }
418}
419
420fn escape(value: &str) -> String {
421 let mut out = String::with_capacity(value.len());
422 for character in value.chars() {
423 match character {
424 '&' => out.push_str("&"),
425 '<' => out.push_str("<"),
426 '>' => out.push_str(">"),
427 '"' => out.push_str("""),
428 '\'' => out.push_str("'"),
429 other => out.push(other),
430 }
431 }
432 out
433}
434
435#[cfg(test)]
436#[allow(
437 clippy::unwrap_used,
438 clippy::expect_used,
439 clippy::panic,
440 clippy::indexing_slicing
441)]
442mod tests {
443 use super::*;
444 use sipx_sip::event::Packages;
445
446 const NOW: u64 = 1_700_000_000;
447
448 fn document() -> String {
449 Pidf::new("sip:alice@sipx.test")
450 .with(Tuple::open("t1").at("sip:alice@192.0.2.5"))
451 .to_xml()
452 }
453
454 fn compositor() -> Compositor {
455 Compositor::new(Duration::from_secs(3600))
456 }
457
458 #[test]
463 fn a_published_presence_document_reaches_a_subscriber() {
464 let packages = Packages::new().with("presence");
466 assert!(packages.serves("presence"));
467
468 let mut compositor = compositor();
469 let published = compositor.apply(
470 "sip:alice@sipx.test",
471 Publish::read(None, Some(document()), Duration::from_secs(600)),
472 NOW,
473 );
474 assert!(matches!(published, Published::Accepted { .. }));
475
476 let notified = compositor
478 .document("sip:alice@sipx.test")
479 .expect("a subscriber gets the document that was published");
480 assert!(notified.contains("<basic>open</basic>"), "{notified}");
481 assert!(
482 notified.contains("sip:alice@192.0.2.5"),
483 "and the contact it named: {notified}"
484 );
485 }
486
487 #[test]
489 fn the_operation_is_read_from_the_tag_the_body_and_the_expiry() {
490 let hour = Duration::from_secs(3600);
491 assert!(matches!(
492 Publish::read(None, Some("doc".to_owned()), hour),
493 Publish::Initial { .. }
494 ));
495 assert!(matches!(
496 Publish::read(Some("t".to_owned()), None, hour),
497 Publish::Refresh { .. }
498 ));
499 assert!(matches!(
500 Publish::read(Some("t".to_owned()), Some("doc".to_owned()), hour),
501 Publish::Modify { .. }
502 ));
503 assert!(matches!(
504 Publish::read(Some("t".to_owned()), None, Duration::ZERO),
505 Publish::Remove { .. }
506 ));
507 assert_eq!(Publish::read(None, None, hour), Publish::Empty);
510 }
511
512 #[test]
515 fn a_tag_this_server_does_not_hold_is_refused_with_412() {
516 let mut compositor = compositor();
517 assert_eq!(
518 compositor.apply(
519 "sip:alice@sipx.test",
520 Publish::read(
521 Some("nobody-issued-this".to_owned()),
522 None,
523 Duration::from_secs(600)
524 ),
525 NOW,
526 ),
527 Published::ConditionFailed
528 );
529 assert_eq!(CONDITIONAL_REQUEST_FAILED, 412);
530 }
531
532 #[test]
536 fn a_refresh_of_expired_state_is_refused_rather_than_accepted_as_new() {
537 let mut compositor = compositor();
538 let Published::Accepted { tag, .. } = compositor.apply(
539 "sip:alice@sipx.test",
540 Publish::read(None, Some(document()), Duration::from_secs(60)),
541 NOW,
542 ) else {
543 panic!("the first publication is accepted");
544 };
545
546 let refresh = Publish::read(Some(tag), None, Duration::from_secs(60));
547 assert_eq!(
548 compositor.apply("sip:alice@sipx.test", refresh, NOW + 61),
549 Published::ConditionFailed,
550 "the state had lapsed; the publisher must start again"
551 );
552 }
553
554 #[test]
557 fn expiry_is_judged_on_the_clock_and_not_on_whether_a_sweep_has_happened() {
558 let mut compositor = compositor();
559 let Published::Accepted { tag, .. } = compositor.apply(
560 "sip:alice@sipx.test",
561 Publish::read(None, Some(document()), Duration::from_secs(60)),
562 NOW,
563 ) else {
564 panic!("accepted");
565 };
566 assert_eq!(compositor.len(), 1, "nothing has swept it yet");
567 assert_eq!(
568 compositor.apply(
569 "sip:alice@sipx.test",
570 Publish::read(Some(tag), None, Duration::from_secs(60)),
571 NOW + 61
572 ),
573 Published::ConditionFailed
574 );
575 }
576
577 #[test]
580 fn every_acceptance_issues_a_new_tag() {
581 let mut compositor = compositor();
582 let Published::Accepted { tag: first, .. } = compositor.apply(
583 "sip:alice@sipx.test",
584 Publish::read(None, Some(document()), Duration::from_secs(600)),
585 NOW,
586 ) else {
587 panic!("accepted");
588 };
589 let Published::Accepted { tag: second, .. } = compositor.apply(
590 "sip:alice@sipx.test",
591 Publish::read(Some(first.clone()), None, Duration::from_secs(600)),
592 NOW + 10,
593 ) else {
594 panic!("the refresh is accepted");
595 };
596 assert_ne!(first, second, "a refresh issues a fresh tag");
597
598 assert_eq!(
600 compositor.apply(
601 "sip:alice@sipx.test",
602 Publish::read(Some(first), None, Duration::from_secs(600)),
603 NOW + 20
604 ),
605 Published::ConditionFailed
606 );
607 }
608
609 #[test]
611 fn expires_zero_removes_the_publication() {
612 let mut compositor = compositor();
613 let Published::Accepted { tag, .. } = compositor.apply(
614 "sip:alice@sipx.test",
615 Publish::read(None, Some(document()), Duration::from_secs(600)),
616 NOW,
617 ) else {
618 panic!("accepted");
619 };
620 assert!(matches!(
621 compositor.apply(
622 "sip:alice@sipx.test",
623 Publish::read(Some(tag), None, Duration::ZERO),
624 NOW
625 ),
626 Published::Removed { tag: _ }
627 ));
628 assert!(compositor.is_empty());
629 assert!(compositor.document("sip:alice@sipx.test").is_none());
630 }
631
632 #[test]
633 fn a_modification_replaces_the_document() {
634 let mut compositor = compositor();
635 let Published::Accepted { tag, .. } = compositor.apply(
636 "sip:alice@sipx.test",
637 Publish::read(None, Some(document()), Duration::from_secs(600)),
638 NOW,
639 ) else {
640 panic!("accepted");
641 };
642 let away = Pidf::new("sip:alice@sipx.test")
643 .with(Tuple::closed("t1").with_note("in a meeting"))
644 .to_xml();
645 assert!(matches!(
646 compositor.apply(
647 "sip:alice@sipx.test",
648 Publish::read(Some(tag), Some(away), Duration::from_secs(600)),
649 NOW
650 ),
651 Published::Accepted { .. }
652 ));
653 let held = compositor
654 .document("sip:alice@sipx.test")
655 .expect("a document");
656 assert!(held.contains("<basic>closed</basic>"), "{held}");
657 assert!(held.contains("in a meeting"), "{held}");
658 }
659
660 #[test]
662 fn a_publication_with_nothing_in_it_is_invalid() {
663 let mut compositor = compositor();
664 assert_eq!(
665 compositor.apply(
666 "sip:alice@sipx.test",
667 Publish::read(None, None, Duration::from_secs(600)),
668 NOW
669 ),
670 Published::Invalid
671 );
672 assert!(compositor.is_empty());
673 }
674
675 #[test]
676 fn a_pidf_document_is_typed_rather_than_concatenated() {
677 let xml = Pidf::new("sip:alice@sipx.test")
678 .with(
679 Tuple::open("t1")
680 .at("sip:alice@192.0.2.5")
681 .with_priority(0.8)
682 .with_note("at my desk"),
683 )
684 .to_xml();
685 assert!(
686 xml.contains("xmlns=\"urn:ietf:params:xml:ns:pidf\""),
687 "{xml}"
688 );
689 assert!(xml.contains("entity=\"sip:alice@sipx.test\""), "{xml}");
690 assert!(xml.contains("<basic>open</basic>"), "{xml}");
691 assert!(xml.contains("priority=\"0.8\""), "{xml}");
692 assert!(xml.contains("<note>at my desk</note>"), "{xml}");
693 assert_eq!(PIDF_TYPE, "application/pidf+xml");
694 }
695
696 #[test]
699 fn a_priority_outside_the_range_is_clamped_rather_than_emitted() {
700 let tuple = Tuple::open("t1").at("sip:a@b").with_priority(7.5);
701 assert_eq!(tuple.priority, Some(1.0));
702 let low = Tuple::open("t1").at("sip:a@b").with_priority(-3.0);
703 assert_eq!(low.priority, Some(0.0));
704 }
705
706 #[test]
707 fn xml_metacharacters_in_a_note_do_not_break_the_document() {
708 let xml = Pidf::new("sip:alice@sipx.test")
709 .with(Tuple::open("t1").with_note("tea & <biscuits>"))
710 .to_xml();
711 assert!(xml.contains("tea & <biscuits>"), "{xml}");
712 assert!(!xml.contains("<biscuits>"), "{xml}");
713 }
714
715 #[test]
716 fn a_second_publication_for_one_presentity_replaces_the_first() {
717 let mut compositor = compositor();
718 let _ = compositor.apply(
719 "sip:alice@sipx.test",
720 Publish::read(None, Some(document()), Duration::from_secs(600)),
721 NOW,
722 );
723 let _ = compositor.apply(
724 "sip:alice@sipx.test",
725 Publish::read(None, Some(document()), Duration::from_secs(600)),
726 NOW,
727 );
728 assert_eq!(
729 compositor.len(),
730 1,
731 "one presentity, one published document"
732 );
733 }
734
735 #[test]
736 fn the_compositor_shortens_a_generous_expiry_to_its_maximum() {
737 let mut compositor = Compositor::new(Duration::from_secs(300));
738 let Published::Accepted { expires, .. } = compositor.apply(
739 "sip:alice@sipx.test",
740 Publish::read(None, Some(document()), Duration::from_secs(86400)),
741 NOW,
742 ) else {
743 panic!("accepted");
744 };
745 assert_eq!(expires, Duration::from_secs(300));
746 }
747
748 #[test]
749 fn expiring_forgets_what_has_run_out() {
750 let mut compositor = compositor();
751 let _ = compositor.apply(
752 "sip:alice@sipx.test",
753 Publish::read(None, Some(document()), Duration::from_secs(60)),
754 NOW,
755 );
756 assert_eq!(compositor.expire(NOW + 30), 0, "not yet");
757 assert_eq!(compositor.expire(NOW + 61), 1);
758 assert!(compositor.is_empty());
759 }
760}