Skip to main content

xmpp_parsers/
disco.rs

1// Copyright (c) 2017 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
2//
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7use xso::{AsXml, FromXml};
8
9use crate::data_forms::DataForm;
10use crate::iq::{IqGetPayload, IqResultPayload};
11use crate::ns;
12use crate::rsm::{SetQuery, SetResult};
13use alloc::collections::BTreeSet;
14use jid::Jid;
15
16/// Structure representing a `<query xmlns='http://jabber.org/protocol/disco#info'/>` element.
17///
18/// It should only be used in an `<iq type='get'/>`, as it can only represent
19/// the request, and not a result.
20#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
21#[xml(namespace = ns::DISCO_INFO, name = "query")]
22pub struct DiscoInfoQuery {
23    /// Node on which we are doing the discovery.
24    #[xml(attribute(default))]
25    pub node: Option<String>,
26}
27
28impl IqGetPayload for DiscoInfoQuery {}
29
30/// Structure representing an `<identity xmlns='http://jabber.org/protocol/disco#info'/>` element.
31#[derive(FromXml, AsXml, Debug, Clone, PartialEq, Eq, Hash)]
32#[xml(namespace = ns::DISCO_INFO, name = "identity")]
33pub struct Identity {
34    /// Category of this identity.
35    // TODO: use an enum here.
36    #[xml(attribute)]
37    pub category: String,
38
39    /// Type of this identity.
40    // TODO: use an enum here.
41    #[xml(attribute = "type")]
42    pub type_: String,
43
44    /// Lang of the name of this identity.
45    #[xml(lang(default))]
46    pub lang: Option<String>,
47
48    /// Name of this identity.
49    #[xml(attribute(default))]
50    pub name: Option<String>,
51}
52
53impl Identity {
54    /// Create a new `<identity/>`.
55    pub fn new<C, T, L, N>(category: C, type_: T, lang: L, name: N) -> Identity
56    where
57        C: Into<String>,
58        T: Into<String>,
59        L: Into<String>,
60        N: Into<String>,
61    {
62        Identity {
63            category: category.into(),
64            type_: type_.into(),
65            lang: Some(lang.into()),
66            name: Some(name.into()),
67        }
68    }
69
70    /// Create a new `<identity/>` without a name.
71    pub fn new_anonymous<C, T, L, N>(category: C, type_: T) -> Identity
72    where
73        C: Into<String>,
74        T: Into<String>,
75    {
76        Identity {
77            category: category.into(),
78            type_: type_.into(),
79            lang: None,
80            name: None,
81        }
82    }
83}
84
85/// Structure representing a `<query xmlns='http://jabber.org/protocol/disco#info'/>` element.
86///
87/// It should only be used in an `<iq type='result'/>`, as it can only
88/// represent the result, and not a request.
89#[derive(FromXml, AsXml, Debug, Clone)]
90#[xml(namespace = ns::DISCO_INFO, name = "query")]
91pub struct DiscoInfoResult {
92    /// Node on which we have done this discovery.
93    #[xml(attribute(default))]
94    pub node: Option<String>,
95
96    /// List of identities exposed by this entity.
97    #[xml(child(n = ..))]
98    pub identities: Vec<Identity>,
99
100    /// List of features supported by this entity.
101    #[xml(extract(n = .., name = "feature", fields(attribute(name = "var", type_ = String))))]
102    pub features: BTreeSet<String>,
103
104    /// List of extensions reported by this entity.
105    #[xml(child(n = ..))]
106    pub extensions: Vec<DataForm>,
107}
108
109impl IqResultPayload for DiscoInfoResult {}
110
111/// Structure representing a `<query xmlns='http://jabber.org/protocol/disco#items'/>` element.
112///
113/// It should only be used in an `<iq type='get'/>`, as it can only represent
114/// the request, and not a result.
115#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
116#[xml(namespace = ns::DISCO_ITEMS, name = "query")]
117pub struct DiscoItemsQuery {
118    /// Node on which we are doing the discovery.
119    #[xml(attribute(default))]
120    pub node: Option<String>,
121
122    /// Optional paging via Result Set Management
123    #[xml(child(default))]
124    pub rsm: Option<SetQuery>,
125}
126
127impl IqGetPayload for DiscoItemsQuery {}
128
129/// Structure representing an `<item xmlns='http://jabber.org/protocol/disco#items'/>` element.
130#[derive(FromXml, AsXml, Debug, Clone, PartialEq)]
131#[xml(namespace = ns::DISCO_ITEMS, name = "item")]
132pub struct Item {
133    /// JID of the entity pointed by this item.
134    #[xml(attribute)]
135    pub jid: Jid,
136
137    /// Node of the entity pointed by this item.
138    #[xml(attribute(default))]
139    pub node: Option<String>,
140
141    /// Name of the entity pointed by this item.
142    #[xml(attribute(default))]
143    pub name: Option<String>,
144}
145
146/// Structure representing a `<query
147/// xmlns='http://jabber.org/protocol/disco#items'/>` element.
148///
149/// It should only be used in an `<iq type='result'/>`, as it can only
150/// represent the result, and not a request.
151#[derive(FromXml, AsXml, PartialEq, Debug, Clone)]
152#[xml(namespace = ns::DISCO_ITEMS, name = "query")]
153pub struct DiscoItemsResult {
154    /// Node on which we have done this discovery.
155    #[xml(attribute(default))]
156    pub node: Option<String>,
157
158    /// List of items pointed by this entity.
159    #[xml(child(n = ..))]
160    pub items: Vec<Item>,
161
162    /// Optional paging via Result Set Management
163    #[xml(child(default))]
164    pub rsm: Option<SetResult>,
165}
166
167impl IqResultPayload for DiscoItemsResult {}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use jid::BareJid;
173    use minidom::Element;
174    use xso::error::{Error, FromElementError};
175
176    #[cfg(target_pointer_width = "32")]
177    #[test]
178    fn test_size() {
179        assert_size!(Identity, 48);
180        assert_size!(DiscoInfoQuery, 12);
181        assert_size!(DiscoInfoResult, 48);
182
183        assert_size!(Item, 40);
184        assert_size!(DiscoItemsQuery, 52);
185        assert_size!(DiscoItemsResult, 64);
186    }
187
188    #[cfg(target_pointer_width = "64")]
189    #[test]
190    fn test_size() {
191        assert_size!(Identity, 96);
192        assert_size!(DiscoInfoQuery, 24);
193        assert_size!(DiscoInfoResult, 96);
194
195        assert_size!(Item, 80);
196        assert_size!(DiscoItemsQuery, 104);
197        assert_size!(DiscoItemsResult, 128);
198    }
199
200    #[test]
201    fn test_simple() {
202        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><identity category='client' type='pc'/><feature var='http://jabber.org/protocol/disco#info'/></query>".parse().unwrap();
203        let query = DiscoInfoResult::try_from(elem).unwrap();
204        assert!(query.node.is_none());
205        assert_eq!(query.identities.len(), 1);
206        assert_eq!(query.features.len(), 1);
207        assert!(query.extensions.is_empty());
208    }
209
210    #[test]
211    fn test_identity_after_feature() {
212        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><feature var='http://jabber.org/protocol/disco#info'/><identity category='client' type='pc'/></query>".parse().unwrap();
213        let query = DiscoInfoResult::try_from(elem).unwrap();
214        assert_eq!(query.identities.len(), 1);
215        assert_eq!(query.features.len(), 1);
216        assert!(query.extensions.is_empty());
217    }
218
219    #[test]
220    fn test_feature_after_dataform() {
221        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><identity category='client' type='pc'/><x xmlns='jabber:x:data' type='result'><field var='FORM_TYPE' type='hidden'><value>coucou</value></field></x><feature var='http://jabber.org/protocol/disco#info'/></query>".parse().unwrap();
222        let query = DiscoInfoResult::try_from(elem).unwrap();
223        assert_eq!(query.identities.len(), 1);
224        assert_eq!(query.features.len(), 1);
225        assert_eq!(query.extensions.len(), 1);
226    }
227
228    #[test]
229    fn test_extension() {
230        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><identity category='client' type='pc'/><feature var='http://jabber.org/protocol/disco#info'/><x xmlns='jabber:x:data' type='result'><field var='FORM_TYPE' type='hidden'><value>example</value></field></x></query>".parse().unwrap();
231        let elem1 = elem.clone();
232        let query = DiscoInfoResult::try_from(elem).unwrap();
233        assert!(query.node.is_none());
234        assert_eq!(query.identities.len(), 1);
235        assert_eq!(query.features.len(), 1);
236        assert_eq!(query.extensions.len(), 1);
237        assert_eq!(query.extensions[0].form_type(), Some("example"));
238
239        let elem2 = query.into();
240        assert_eq!(elem1, elem2);
241    }
242
243    #[test]
244    #[cfg_attr(feature = "disable-validation", should_panic = "Result::unwrap_err")]
245    fn test_invalid() {
246        let elem: Element =
247            "<query xmlns='http://jabber.org/protocol/disco#info'><coucou/></query>"
248                .parse()
249                .unwrap();
250        let error = DiscoInfoResult::try_from(elem).unwrap_err();
251        let message = match error {
252            FromElementError::Invalid(Error::Other(string)) => string,
253            _ => panic!(),
254        };
255        assert_eq!(message, "Unknown child in DiscoInfoResult element.");
256    }
257
258    #[test]
259    fn test_invalid_identity() {
260        let elem: Element =
261            "<query xmlns='http://jabber.org/protocol/disco#info'><identity/></query>"
262                .parse()
263                .unwrap();
264        let error = DiscoInfoResult::try_from(elem).unwrap_err();
265        let message = match error {
266            FromElementError::Invalid(Error::Other(string)) => string,
267            _ => panic!(),
268        };
269        assert_eq!(
270            message,
271            "Required attribute field 'category' on Identity element missing."
272        );
273
274        let elem: Element =
275            "<query xmlns='http://jabber.org/protocol/disco#info'><identity type='coucou'/></query>"
276                .parse()
277                .unwrap();
278        let error = DiscoInfoResult::try_from(elem).unwrap_err();
279        let message = match error {
280            FromElementError::Invalid(Error::Other(string)) => string,
281            _ => panic!(),
282        };
283        assert_eq!(
284            message,
285            "Required attribute field 'category' on Identity element missing."
286        );
287
288        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><identity category='coucou'/></query>".parse().unwrap();
289        let error = DiscoInfoResult::try_from(elem).unwrap_err();
290        let message = match error {
291            FromElementError::Invalid(Error::Other(string)) => string,
292            _ => panic!(),
293        };
294        assert_eq!(
295            message,
296            "Required attribute field 'type_' on Identity element missing."
297        );
298    }
299
300    #[test]
301    fn test_invalid_feature() {
302        let elem: Element =
303            "<query xmlns='http://jabber.org/protocol/disco#info'><feature/></query>"
304                .parse()
305                .unwrap();
306        let error = DiscoInfoResult::try_from(elem).unwrap_err();
307        let message = match error {
308            FromElementError::Invalid(Error::Other(string)) => string,
309            _ => panic!(),
310        };
311        // TODO: Make xso generate a better error message, with s/unnamed field 0/'var'/ for
312        // instance.
313        assert_eq!(
314            message,
315            "Required attribute unnamed field 0 on extraction for field 'features' in DiscoInfoResult element missing."
316        );
317    }
318
319    // TODO: We stopped validating that there are enough identities and features in this result,
320    // this is a limitation of xso which accepts n = .. only, and not n = 1.., so let’s wait until
321    // xso implements this to reenable this test.
322    #[test]
323    #[ignore]
324    fn test_invalid_result() {
325        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'/>"
326            .parse()
327            .unwrap();
328        let error = DiscoInfoResult::try_from(elem).unwrap_err();
329        let message = match error {
330            FromElementError::Invalid(Error::Other(string)) => string,
331            _ => panic!(),
332        };
333        assert_eq!(
334            message,
335            "There must be at least one identity in disco#info."
336        );
337
338        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><identity category='client' type='pc'/></query>".parse().unwrap();
339        let error = DiscoInfoResult::try_from(elem).unwrap_err();
340        let message = match error {
341            FromElementError::Invalid(Error::Other(string)) => string,
342            _ => panic!(),
343        };
344        assert_eq!(message, "There must be at least one feature in disco#info.");
345    }
346
347    #[test]
348    fn test_simple_items() {
349        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#items'/>"
350            .parse()
351            .unwrap();
352        let query = DiscoItemsQuery::try_from(elem).unwrap();
353        assert!(query.node.is_none());
354
355        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#items' node='coucou'/>"
356            .parse()
357            .unwrap();
358        let query = DiscoItemsQuery::try_from(elem).unwrap();
359        assert_eq!(query.node, Some(String::from("coucou")));
360    }
361
362    #[test]
363    fn test_simple_items_result() {
364        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#items'/>"
365            .parse()
366            .unwrap();
367        let query = DiscoItemsResult::try_from(elem).unwrap();
368        assert!(query.node.is_none());
369        assert!(query.items.is_empty());
370
371        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#items' node='coucou'/>"
372            .parse()
373            .unwrap();
374        let query = DiscoItemsResult::try_from(elem).unwrap();
375        assert_eq!(query.node, Some(String::from("coucou")));
376        assert!(query.items.is_empty());
377    }
378
379    #[test]
380    fn test_answers_items_result() {
381        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#items'><item jid='component'/><item jid='component2' node='test' name='A component'/></query>".parse().unwrap();
382        let query = DiscoItemsResult::try_from(elem).unwrap();
383        let elem2 = Element::from(query);
384        let query = DiscoItemsResult::try_from(elem2).unwrap();
385        assert_eq!(query.items.len(), 2);
386        assert_eq!(query.items[0].jid, BareJid::new("component").unwrap());
387        assert_eq!(query.items[0].node, None);
388        assert_eq!(query.items[0].name, None);
389        assert_eq!(query.items[1].jid, BareJid::new("component2").unwrap());
390        assert_eq!(query.items[1].node, Some(String::from("test")));
391        assert_eq!(query.items[1].name, Some(String::from("A component")));
392    }
393
394    // WORKAROUND FOR PROSODY BUG 1664, DO NOT REMOVE BEFORE 2028-12-17 (5 YEARS AFTER FIX)
395    // https://issues.prosody.im/1664
396    // See also:
397    // https://gitlab.com/xmpp-rs/xmpp-rs/-/issues/128
398    // https://gitlab.com/xmpp-rs/xmpp-rs/-/merge_requests/302
399    #[test]
400    fn test_missing_disco_info_feature_workaround() {
401        let elem: Element = "<query xmlns='http://jabber.org/protocol/disco#info'><identity category='client' type='pc'/><feature var='http://jabber.org/protocol/muc#user'/></query>".parse().unwrap();
402        let query = DiscoInfoResult::try_from(elem).unwrap();
403        assert_eq!(query.identities.len(), 1);
404        assert_eq!(query.features.len(), 1);
405        assert!(query.extensions.is_empty());
406    }
407}