diff --git a/parsers/src/data_forms.rs b/parsers/src/data_forms/data_form.rs similarity index 92% rename from parsers/src/data_forms.rs rename to parsers/src/data_forms/data_form.rs index 24a631de..75dd8f35 100644 --- a/parsers/src/data_forms.rs +++ b/parsers/src/data_forms/data_form.rs @@ -4,6 +4,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. +use crate::data_forms::Validate; use crate::media_element::MediaElement; use crate::ns; use crate::Element; @@ -93,6 +94,9 @@ pub struct Field { /// A list of media related to this field. pub media: Vec, + + /// Validation rules for this field. + pub validate: Option, } impl Field { @@ -107,6 +111,7 @@ impl Field { options: Vec::new(), media: Vec::new(), values: Vec::new(), + validate: None, } } @@ -182,6 +187,7 @@ impl TryFrom for Field { options: vec![], values: vec![], media: vec![], + validate: None, }; if field.type_ != FieldType::Fixed && field.var.is_none() { @@ -213,6 +219,13 @@ impl TryFrom for Field { check_no_children!(element, "desc"); check_no_attributes!(element, "desc"); field.desc = Some(element.text()); + } else if element.is("validate", ns::XDATA_VALIDATE) { + if field.validate.is_some() { + return Err(Error::ParseError( + "More than one validate element in field.", + )); + } + field.validate = Some(Validate::try_from(element.clone())?); } else { return Err( Error::Other("Field child isn’t a value, option or media element.").into(), @@ -242,6 +255,7 @@ impl From for Element { .map(|value| Element::builder("value", ns::DATA_FORMS).append(value)), ) .append_all(field.media.iter().cloned().map(Element::from)) + .append_all(field.validate) .build() } } @@ -375,13 +389,14 @@ impl From for Element { #[cfg(test)] mod tests { use super::*; + use crate::data_forms::{Datatype, Validate}; #[cfg(target_pointer_width = "32")] #[test] fn test_size() { assert_size!(Option_, 24); assert_size!(FieldType, 1); - assert_size!(Field, 76); + assert_size!(Field, 132); assert_size!(DataFormType, 1); assert_size!(DataForm, 52); } @@ -391,7 +406,7 @@ mod tests { fn test_size() { assert_size!(Option_, 48); assert_size!(FieldType, 1); - assert_size!(Field, 152); + assert_size!(Field, 264); assert_size!(DataFormType, 1); assert_size!(DataForm, 104); } @@ -439,6 +454,7 @@ mod tests { options: vec![], values: vec!["Section 1: Bot Info".to_string()], media: vec![], + validate: None, }] ); } @@ -463,6 +479,40 @@ mod tests { options: vec![], values: vec![], media: vec![], + validate: None, + }] + ); + } + + #[test] + fn test_validate() { + let elem: Element = r#" + + + 2003-10-06T11:22:00-07:00 + + "# + .parse() + .unwrap(); + let form = DataForm::try_from(elem).unwrap(); + assert_eq!(form.type_, DataFormType::Form); + assert!(form.form_type.is_none()); + assert_eq!( + form.fields, + vec![Field { + var: Some("evt.date".to_string()), + type_: FieldType::TextSingle, + label: Some("Event Date/Time".to_string()), + required: false, + desc: None, + options: vec![], + values: vec!["2003-10-06T11:22:00-07:00".to_string()], + media: vec![], + validate: Some(Validate { + datatype: Some(Datatype::DateTime), + method: None, + list_range: None, + }), }] ); } diff --git a/parsers/src/data_forms/mod.rs b/parsers/src/data_forms/mod.rs new file mode 100644 index 00000000..ea584746 --- /dev/null +++ b/parsers/src/data_forms/mod.rs @@ -0,0 +1,14 @@ +// Copyright (c) 2024 xmpp-rs contributors. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +pub use data_form::*; +pub use validate::*; + +/// XEP-0004: Data Forms +pub mod data_form; + +/// XEP-0122: Data Forms Validation +pub mod validate; diff --git a/parsers/src/data_forms/validate.rs b/parsers/src/data_forms/validate.rs new file mode 100644 index 00000000..4d0471d2 --- /dev/null +++ b/parsers/src/data_forms/validate.rs @@ -0,0 +1,486 @@ +// Copyright (c) 2024 xmpp-rs contributors. +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +// XEP-0122: Data Forms Validation +// https://xmpp.org/extensions/xep-0122.html#usecases-datatypes + +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +use minidom::{Element, IntoAttributeValue}; + +use crate::ns::XDATA_VALIDATE; +use crate::Error; + +/// Validation Method +#[derive(Debug, Clone, PartialEq)] +pub enum Method { + /// … to indicate that the value(s) should simply match the field type and datatype constraints, + /// the element shall contain a child element. Using validation, + /// the form interpreter MUST follow the validation rules of the datatype (if understood) and + /// the field type. + /// + /// https://xmpp.org/extensions/xep-0122.html#usercases-validation.basic + Basic, + + /// For "list-single" or "list-multi", to indicate that the user may enter a custom value + /// (matching the datatype constraints) or choose from the predefined values, the + /// element shall contain an child element. The validation method applies to + /// "text-multi" differently; it hints that each value for a "text-multi" field shall be + /// validated separately. This effectively turns "text-multi" fields into an open-ended + /// "list-multi", with no options and all values automatically selected. + /// + /// https://xmpp.org/extensions/xep-0122.html#usercases-validation.open + Open, + + /// To indicate that the value should fall within a certain range, the element shall + /// contain a child element. The 'min' and 'max' attributes of the element + /// specify the minimum and maximum values allowed, respectively. + /// + /// The 'max' attribute specifies the maximum allowable value. This attribute is OPTIONAL. + /// The value depends on the datatype in use. + /// + /// The 'min' attribute specifies the minimum allowable value. This attribute is OPTIONAL. + /// The value depends on the datatype in use. + /// + /// The element SHOULD possess either a 'min' or 'max' attribute, and MAY possess both. + /// If neither attribute is included, the processor MUST assume that there are no range + /// constraints. + /// + /// https://xmpp.org/extensions/xep-0122.html#usercases-validation.range + Range { + /// The 'min' attribute specifies the minimum allowable value. + min: Option, + /// The 'max' attribute specifies the maximum allowable value. + max: Option, + }, + + /// To indicate that the value should be restricted to a regular expression, the + /// element shall contain a child element. The XML character data of this element is + /// the pattern to apply. The syntax of this content MUST be that defined for POSIX extended + /// regular expressions, including support for Unicode. The element MUST contain + /// character data only. + /// + /// https://xmpp.org/extensions/xep-0122.html#usercases-validatoin.regex + Regex(String), +} + +generate_element!( + /// Selection Ranges in "list-multi" + ListRange, "list-range", XDATA_VALIDATE, + attributes: [ + /// The 'min' attribute specifies the minimum allowable number of selected/entered values. + min: Option = "min", + /// The 'max' attribute specifies the maximum allowable number of selected/entered values. + max: Option = "max", + ] +); + +/// Data Forms Validation Datatypes +/// +/// https://xmpp.org/registrar/xdv-datatypes.html +#[derive(Debug, Clone, PartialEq)] +pub enum Datatype { + /// A Uniform Resource Identifier Reference (URI) + AnyUri, + + /// An integer with the specified min/max + /// Min: -128, Max: 127 + Byte, + + /// A calendar date + Date, + + /// A specific instant of time + DateTime, + + /// An arbitrary-precision decimal number + Decimal, + + /// An IEEE double-precision 64-bit floating point type + Double, + + /// An integer with the specified min/max + /// Min: -2147483648, Max: 2147483647 + Int, + + /// A decimal number with no fraction digits + Integer, + + /// A language identifier as defined by RFC 1766 + Language, + + /// An integer with the specified min/max + /// Min: -9223372036854775808, Max: 9223372036854775807 + Long, + + /// An integer with the specified min/max + /// Min: -32768, Max: 32767 + Short, + + /// A character strings in XML + String, + + /// An instant of time that recurs every day + Time, + + /// A user-defined datatype + UserDefined(String), + + /// A non-standard datatype + Other { + /// The prefix of the specified datatype. Should be registered with the XMPP Registrar. + prefix: String, + /// The actual value of the specified datatype. E.g. "lat" in the case of "geo:lat". + value: String, + }, +} + +/// Validation rules for a DataForms Field. +#[derive(Debug, Clone, PartialEq)] +pub struct Validate { + /// The 'datatype' attribute specifies the datatype. This attribute is OPTIONAL, and defaults + /// to "xs:string". It MUST meet one of the following conditions: + /// + /// - Start with "xs:", and be one of the "built-in" datatypes defined in XML Schema Part 2 + /// - Start with a prefix registered with the XMPP Registrar + /// - Start with "x:", and specify a user-defined datatype. + /// + /// Note that while "x:" allows for ad-hoc definitions, its use is NOT RECOMMENDED. + pub datatype: Option, + + /// The validation method. If no validation method is specified, form processors MUST + /// assume validation. The element SHOULD include one of the above + /// validation method elements, and MUST NOT include more than one. + /// + /// Any validation method applied to a field of type "list-multi", "list-single", or "text-multi" + /// (other than ) MUST imply the same behavior as , with the additional constraints + /// defined by that method. + /// + /// https://xmpp.org/extensions/xep-0122.html#usecases-validation + pub method: Option, + + /// For "list-multi", validation can indicate (via the element) that a minimum + /// and maximum number of options should be selected and/or entered. This selection range + /// MAY be combined with the other methods to provide more flexibility. + /// The element SHOULD be included only when the is of type "list-multi" + /// and SHOULD be ignored otherwise. + /// + /// The element SHOULD possess either a 'min' or 'max' attribute, and MAY possess + /// both. If neither attribute is included, the processor MUST assume that there are no + /// selection constraints. + /// + /// https://xmpp.org/extensions/xep-0122.html#usecases-ranges + pub list_range: Option, +} + +impl TryFrom for Validate { + type Error = Error; + + fn try_from(elem: Element) -> Result { + check_self!(elem, "validate", XDATA_VALIDATE); + check_no_unknown_attributes!(elem, "item", ["datatype"]); + + let mut validate = Validate { + datatype: elem.attr("datatype").map(Datatype::from_str).transpose()?, + method: None, + list_range: None, + }; + + for child in elem.children() { + match child { + _ if child.is("list-range", XDATA_VALIDATE) => { + let list_range = ListRange::try_from(child.clone())?; + if validate.list_range.is_some() { + return Err(Error::ParseError( + "Encountered unsupported number (n > 1) of list-range in validate element.", + )); + } + validate.list_range = Some(list_range); + } + _ => { + let method = Method::try_from(child.clone())?; + if validate.method.is_some() { + return Err(Error::ParseError( + "Encountered unsupported number (n > 1) of validation methods in validate element.", + )); + } + validate.method = Some(method); + } + } + } + + Ok(validate) + } +} + +impl From for Element { + fn from(value: Validate) -> Self { + Element::builder("validate", XDATA_VALIDATE) + .attr("datatype", value.datatype) + .append_all(value.method) + .append_all(value.list_range) + .build() + } +} + +impl TryFrom for Method { + type Error = Error; + + fn try_from(elem: Element) -> Result { + let method = match elem { + _ if elem.is("basic", XDATA_VALIDATE) => { + check_no_attributes!(elem, "basic"); + Method::Basic + } + _ if elem.is("open", XDATA_VALIDATE) => { + check_no_attributes!(elem, "open"); + Method::Open + } + _ if elem.is("range", XDATA_VALIDATE) => { + check_no_unknown_attributes!(elem, "range", ["min", "max"]); + Method::Range { + min: elem.attr("min").map(ToString::to_string), + max: elem.attr("max").map(ToString::to_string), + } + } + _ if elem.is("regex", XDATA_VALIDATE) => { + check_no_attributes!(elem, "regex"); + check_no_children!(elem, "regex"); + Method::Regex(elem.text()) + } + _ => return Err(Error::ParseError("Encountered invalid validation method.")), + }; + Ok(method) + } +} + +impl From for Element { + fn from(value: Method) -> Self { + match value { + Method::Basic => Element::builder("basic", XDATA_VALIDATE), + Method::Open => Element::builder("open", XDATA_VALIDATE), + Method::Range { min, max } => Element::builder("range", XDATA_VALIDATE) + .attr("min", min) + .attr("max", max), + Method::Regex(regex) => Element::builder("regex", XDATA_VALIDATE).append(regex), + } + .build() + } +} + +impl FromStr for Datatype { + type Err = Error; + + fn from_str(s: &str) -> Result { + let mut parts = s.splitn(2, ":"); + + let Some(prefix) = parts.next() else { + return Err(Error::ParseError( + "Encountered invalid validation datatype which is missing a prefix.", + )); + }; + + match prefix { + "xs" => (), + "x" => { + return Ok(Datatype::UserDefined( + parts.next().unwrap_or_default().to_string(), + )) + } + _ => { + return Ok(Datatype::Other { + prefix: prefix.to_string(), + value: parts.next().unwrap_or_default().to_string(), + }) + } + } + + let Some(datatype) = parts.next() else { + return Err(Error::ParseError( + "Encountered invalid validation datatype.", + )); + }; + + let parsed_datatype = match datatype.to_ascii_lowercase().as_str() { + "anyuri" => Datatype::AnyUri, + "byte" => Datatype::Byte, + "date" => Datatype::Date, + "datetime" => Datatype::DateTime, + "decimal" => Datatype::Decimal, + "double" => Datatype::Double, + "int" => Datatype::Int, + "integer" => Datatype::Integer, + "language" => Datatype::Language, + "long" => Datatype::Long, + "short" => Datatype::Short, + "string" => Datatype::String, + "time" => Datatype::Time, + _ => { + return Err(Error::ParseError( + "Encountered invalid validation datatype.", + )) + } + }; + + Ok(parsed_datatype) + } +} + +impl Display for Datatype { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let value = match self { + Datatype::AnyUri => "xs:anyURI", + Datatype::Byte => "xs:byte", + Datatype::Date => "xs:date", + Datatype::DateTime => "xs:dateTime", + Datatype::Decimal => "xs:decimal", + Datatype::Double => "xs:double", + Datatype::Int => "xs:int", + Datatype::Integer => "xs:integer", + Datatype::Language => "xs:language", + Datatype::Long => "xs:long", + Datatype::Short => "xs:short", + Datatype::String => "xs:string", + Datatype::Time => "xs:time", + Datatype::UserDefined(value) => &format!("x:{value}"), + Datatype::Other { prefix, value } => &format!("{prefix}:{value}"), + }; + write!(f, "{value}") + } +} + +impl IntoAttributeValue for Datatype { + fn into_attribute_value(self) -> Option { + Some(self.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_datatype() -> Result<(), Error> { + assert_eq!(Datatype::AnyUri, "xs:anyURI".parse()?); + assert_eq!(Datatype::AnyUri, "xs:anyuri".parse()?); + assert!("xs:".parse::().is_err()); + assert_eq!( + Datatype::AnyUri.into_attribute_value(), + Some("xs:anyURI".to_string()) + ); + + assert_eq!(Datatype::UserDefined("id".to_string()), "x:id".parse()?); + assert_eq!(Datatype::UserDefined("".to_string()), "x:".parse()?); + assert_eq!( + Datatype::UserDefined("id".to_string()).into_attribute_value(), + Some("x:id".to_string()) + ); + + assert_eq!( + Datatype::Other { + prefix: "geo".to_string(), + value: "lat".to_string() + }, + "geo:lat".parse()? + ); + assert_eq!( + Datatype::Other { + prefix: "geo".to_string(), + value: "".to_string() + }, + "geo:".parse()? + ); + assert_eq!( + Datatype::Other { + prefix: "geo".to_string(), + value: "lat".to_string() + } + .into_attribute_value(), + Some("geo:lat".to_string()) + ); + + Ok(()) + } + + #[test] + fn test_parse_validate_element() -> Result<(), Error> { + let cases = [ + ( + r#""#, + Validate { + datatype: None, + method: None, + list_range: None, + }, + ), + ( + r#""#, + Validate { + datatype: Some(Datatype::String), + method: Some(Method::Basic), + list_range: Some(ListRange { + min: Some(1), + max: Some(3), + }), + }, + ), + ( + r#"([0-9]{3})-([0-9]{2})-([0-9]{4})"#, + Validate { + datatype: Some(Datatype::String), + method: Some(Method::Regex( + "([0-9]{3})-([0-9]{2})-([0-9]{4})".to_string(), + )), + list_range: None, + }, + ), + ( + r#""#, + Validate { + datatype: Some(Datatype::DateTime), + method: Some(Method::Range { + min: Some("2003-10-05T00:00:00-07:00".to_string()), + max: Some("2003-10-24T23:59:59-07:00".to_string()), + }), + list_range: None, + }, + ), + ]; + + for case in cases { + let parsed_element: Validate = case + .0 + .parse::() + .expect(&format!("Failed to parse {}", case.0)) + .try_into()?; + + assert_eq!(parsed_element, case.1); + + let xml = String::from(&Element::from(parsed_element)); + assert_eq!(xml, case.0); + } + + Ok(()) + } + + #[test] + fn test_fails_with_invalid_children() -> Result<(), Error> { + let cases = [ + r#""#, + r#""#, + ]; + + for case in cases { + let element = case + .parse::() + .expect(&format!("Failed to parse {}", case)); + assert!(Validate::try_from(element).is_err()); + } + + Ok(()) + } +} diff --git a/parsers/src/ns.rs b/parsers/src/ns.rs index 03ff089d..88dbe67d 100644 --- a/parsers/src/ns.rs +++ b/parsers/src/ns.rs @@ -290,6 +290,9 @@ pub const OID: &str = "urn:xmpp:occupant-id:0"; /// XEP-0444: Message Reactions pub const REACTIONS: &str = "urn:xmpp:reactions:0"; +/// XEP-0122: Data Forms Validation +pub const XDATA_VALIDATE: &str = "http://jabber.org/protocol/xdata-validate"; + /// Alias for the main namespace of the stream, that is "jabber:client" when /// the component feature isn’t enabled. #[cfg(not(feature = "component"))] diff --git a/parsers/src/server_info.rs b/parsers/src/server_info.rs index d0327e6a..3595155f 100644 --- a/parsers/src/server_info.rs +++ b/parsers/src/server_info.rs @@ -95,6 +95,7 @@ pub fn generate_address_field>(var: S, values: Vec) -> F options: vec![], values, media: vec![], + validate: None, } }