xapi_rs/data/
validate.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use crate::{data::ObjectType, emit_error};
4use iri_string::{convert::MappedToUri, format::ToDedicatedString, types::IriStr};
5use std::{any::type_name, borrow::Cow};
6use thiserror::Error;
7use tracing::error;
8use url::Url;
9
10/// xAPI mandates certain constraints on the values of some properties of types
11/// it defines. Our API binding structures however limit the Rust type of almost
12/// all fields to be Strings or derivative types based on Strings. This is to
13/// allow deserializing all types from the wire even when their values violate
14/// those constraints.
15pub trait Validate: ToString {
16    /// Validate the instance and return a potentially empty collection of
17    /// [ValidationError].
18    fn validate(&self) -> Vec<ValidationError>;
19
20    /// Convenience method to quickly assert if the type implementing this
21    /// trait is indeed valid.
22    ///
23    /// Return TRUE if calling `validate()` did not return any [ValidationError].
24    /// Return FALSE otherwise.
25    fn is_valid(&self) -> bool {
26        let result = self.validate();
27        if result.is_empty() {
28            true
29        } else {
30            error!("[VALIDATION] {:?}", result);
31            false
32        }
33    }
34
35    /// Convenience method that checks the validity of a [Validate] instance and
36    /// raises a [ValidationError] if it was found to be invalid.
37    fn check_validity(&self) -> Result<(), ValidationError> {
38        if self.is_valid() {
39            Ok(())
40        } else {
41            Err(ValidationError::ConstraintViolation(
42                format!("Instance of '{}' is invalid", type_name::<Self>()).into(),
43            ))
44        }
45    }
46}
47
48/// An error that denotes a validation constraint violation.
49#[derive(Debug, Error)]
50pub enum ValidationError {
51    #[doc(hidden)]
52    #[error("Empty string: '{0}'")]
53    Empty(Cow<'static, str>),
54
55    #[doc(hidden)]
56    #[error("Invalid IRI: '{0}'")]
57    InvalidIRI(Cow<'static, str>),
58
59    #[doc(hidden)]
60    #[error("Invalid URI: '{0}'")]
61    InvalidURI(Cow<'static, str>),
62
63    #[doc(hidden)]
64    #[error("Invalid IRL: <{0}>")]
65    InvalidIRL(Cow<'static, str>),
66
67    #[doc(hidden)]
68    #[error("Invalid URL: <{0}>")]
69    InvalidURL(url::ParseError),
70
71    #[doc(hidden)]
72    #[error("Not a Normalized IRI: \"{0}\"")]
73    NotNormalizedIRI(Cow<'static, str>),
74
75    #[doc(hidden)]
76    #[error("Not UTC timezone: \"{0}\"")]
77    NotUTC(Cow<'static, str>),
78
79    #[doc(hidden)]
80    #[error("Wrong 'objectType'. Expected {expected} but found {found}")]
81    WrongObjectType {
82        expected: ObjectType,
83        found: Cow<'static, str>,
84    },
85
86    #[doc(hidden)]
87    #[error("SHA-1 sum string contains non hex characters or has wrong characters count")]
88    InvalidSha1String,
89
90    #[doc(hidden)]
91    #[error("SHA-2 hash string contains non hex characters or has wrong characters count")]
92    InvalidSha2String,
93
94    #[doc(hidden)]
95    #[error("Empty anonymous group")]
96    EmptyAnonymousGroup,
97
98    #[doc(hidden)]
99    #[error("Invalid timestamp: {0}")]
100    InvalidDateTime(
101        #[doc(hidden)]
102        #[from]
103        chrono::format::ParseError,
104    ),
105
106    #[doc(hidden)]
107    #[error("Invalid ISO-8601 duration: {0}")]
108    DurationParseError(speedate::ParseError),
109
110    #[doc(hidden)]
111    #[error("Invalid Language Tag: {0}")]
112    InvalidLanguageTag(Cow<'static, str>),
113
114    #[doc(hidden)]
115    #[error("{0} must have at least one IFI")]
116    MissingIFI(Cow<'static, str>),
117
118    #[doc(hidden)]
119    #[error("Missing '{0}'")]
120    MissingField(Cow<'static, str>),
121
122    #[doc(hidden)]
123    #[error("Invalid '{0}'")]
124    InvalidField(Cow<'static, str>),
125
126    #[doc(hidden)]
127    #[error("General constraint violation: {0}")]
128    ConstraintViolation(Cow<'static, str>),
129}
130
131/// Raise [ValidationError] if the `val` cannot be translated into a valid URL
132/// --as per [RFC-3987][1].
133///
134/// [1]: https://www.ietf.org/rfc/rfc3987.txt
135pub(crate) fn validate_irl(val: &IriStr) -> Result<(), ValidationError> {
136    if val.is_empty() {
137        emit_error!(ValidationError::InvalidIRL(val.to_string().into()))
138    }
139
140    let uri = MappedToUri::from(val).to_dedicated_string();
141    let normalized_uri = uri.normalize().to_dedicated_string();
142    let s = normalized_uri.as_str();
143    match Url::parse(s) {
144        Ok(_) => Ok(()),
145        Err(x) => emit_error!(ValidationError::InvalidURL(x)),
146    }
147}
148
149/// Raise [InvalidSHA1HexString][ValidationError#variant.InvalidSha1String]
150/// if the argument is not 40 characters long or contains non hexadecimal
151/// characters.
152///
153/// Used when validating Actor's `mbox_sha1sum` field.
154pub(crate) fn validate_sha1sum(val: &str) -> Result<(), ValidationError> {
155    if val.chars().count() != 40 || !val.chars().all(|x| x.is_ascii_hexdigit()) {
156        emit_error!(ValidationError::InvalidSha1String)
157    } else {
158        Ok(())
159    }
160}
161
162/// Raise [InvalidSha2String][ValidationError#variant.InvalidSha2String]
163/// if the argument's character count is not w/in the range 32..64 incl. or it
164/// contains non hexadecimal characters.
165///
166/// Used when validating Attachment's `sha2` field.
167pub(crate) fn validate_sha2(val: &str) -> Result<(), ValidationError> {
168    if !(32..65).contains(&val.chars().count()) || !val.chars().all(|x| x.is_ascii_hexdigit()) {
169        emit_error!(ValidationError::InvalidSha2String)
170    } else {
171        Ok(())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use tracing::info;
179    use tracing_test::traced_test;
180    use url::Url;
181
182    #[test]
183    fn test_validate_irl() {
184        const PASS: &str = "http://résumé.example.org/foo/../";
185        let r1 = IriStr::new(PASS);
186        assert!(r1.is_ok());
187        // should also be a valid IRL
188        assert!(validate_irl(r1.unwrap()).is_ok());
189
190        const FAIL: &str = "résumé/bar";
191        let r2 = IriStr::new(FAIL);
192        assert!(r2.is_err());
193    }
194
195    #[test]
196    fn test_validate_sha1sum() {
197        assert!(validate_sha1sum("ebd31e95054c018b10727ccffd2ef2ec3a016ee9").is_ok());
198
199        const H1: &str = "ebd31e95054c018b10727ccffd2ef2ec3a016ee9ab";
200        let r1 = validate_sha1sum(H1);
201        assert!(r1.is_err_and(|x| matches!(x, ValidationError::InvalidSha1String)));
202
203        const H2: &str = "ebd31x95054c018b10727ccffd2ef2ec3a016ee9";
204        let r2 = validate_sha1sum(H2);
205        assert!(r2.is_err_and(|x| matches!(x, ValidationError::InvalidSha1String)));
206    }
207
208    #[test]
209    fn test_validate_sha2() {
210        assert!(
211            validate_sha2("495395e777cd98da653df9615d09c0fd6bb2f8d4788394cd53c56a3bfdcd848a")
212                .is_ok()
213        );
214
215        const H1: &str = "1234567890123456789012345678901";
216        let r1 = validate_sha2(H1);
217        assert!(r1.is_err_and(|x| matches!(x, ValidationError::InvalidSha2String)));
218
219        const H2: &str = "x95395e777cd98da653df9615d09c0fd6bb2f8d4788394cd53c56a3bfdcd848a";
220        let r2 = validate_sha2(H2);
221        assert!(r2.is_err_and(|x| matches!(x, ValidationError::InvalidSha2String)));
222    }
223
224    #[traced_test]
225    #[test]
226    fn test_rfc3987_with_url_crate() {
227        // lifted from
228        // https://github.com/lo48576/iri-string/blob/develop/tests/string_types_interop.rs
229        const URIS: &[&str] = &[
230            // --- absolute URIs w/o fragment...
231            // RFC 3987 itself.
232            "https://tools.ietf.org/html/rfc3987",
233            "https://datatracker.ietf.org/doc/html/rfc3987",
234            // RFC 3987 section 3.1.
235            "http://xn--rsum-bpad.example.org",
236            "http://r%C3%A9sum%C3%A9.example.org",
237            // RFC 3987 section 3.2.
238            "http://example.com/%F0%90%8C%80%F0%90%8C%81%F0%90%8C%82",
239            // RFC 3987 section 3.2.1.
240            "http://www.example.org/r%C3%A9sum%C3%A9.html",
241            "http://www.example.org/r%E9sum%E9.html",
242            "http://www.example.org/D%C3%BCrst",
243            "http://www.example.org/D%FCrst",
244            "http://xn--99zt52a.example.org/%e2%80%ae",
245            "http://xn--99zt52a.example.org/%E2%80%AE",
246            // RFC 3987 section 4.4.
247            "http://ab.CDEFGH.ij/kl/mn/op.html",
248            "http://ab.CDE.FGH/ij/kl/mn/op.html",
249            "http://AB.CD.ef/gh/IJ/KL.html",
250            "http://ab.cd.EF/GH/ij/kl.html",
251            "http://ab.CD.EF/GH/IJ/kl.html",
252            "http://ab.CDE123FGH.ij/kl/mn/op.html",
253            "http://ab.cd.ef/GH1/2IJ/KL.html",
254            "http://ab.cd.ef/GH%31/%32IJ/KL.html",
255            "http://ab.CDEFGH.123/kl/mn/op.html",
256            // RFC 3987 section 5.3.2.
257            "eXAMPLE://a/./b/../b/%63/%7bfoo%7d/ros%C3%A9",
258            // RFC 3987 section 5.3.2.1.
259            "HTTP://www.EXAMPLE.com/",
260            "http://www.example.com/",
261            // RFC 3987 section 5.3.2.3.
262            "http://example.org/~user",
263            "http://example.org/%7euser",
264            "http://example.org/%7Euser",
265            // RFC 3987 section 5.3.3.
266            "http://example.com",
267            "http://example.com/",
268            "http://example.com:/",
269            "http://example.com:80/",
270            // RFC 3987 section 5.3.4.
271            "http://example.com/data",
272            "http://example.com/data/",
273            // --- absolute URIs w/ fragment...
274            // RFC 3987 section 3.1.
275            "http://www.example.org/red%09ros%C3%A9#red",
276            // RFC 3987 section 4.4.
277            "http://AB.CD.EF/GH/IJ/KL?MN=OP;QR=ST#UV",
278            // --- absolute IRIs w/o fragment...
279            // RFC 3987 section 3.1.
280            "http://r\u{E9}sum\u{E9}.example.org",
281            // RFC 3987 section 3.2.
282            "http://example.com/\u{10300}\u{10301}\u{10302}",
283            "http://www.example.org/D\u{FC}rst",
284            "http://\u{7D0D}\u{8C46}.example.org/%E2%80%AE",
285            // RFC 3987 section 5.2.
286            "http://example.org/ros\u{E9}",
287            // RFC 3987 section 5.3.2.
288            "example://a/b/c/%7Bfoo%7D/ros\u{E9}",
289            // RFC 3987 section 5.3.2.2.
290            "http://www.example.org/r\u{E9}sum\u{E9}.html",
291            "http://www.example.org/re\u{301}sume\u{301}.html",
292            // ----- absolute IRIs w/o fragment...
293            // RFC 3987 section 6.4.
294            "http://www.example.org/r%E9sum%E9.xml#r\u{E9}sum\u{E9}",
295        ];
296
297        for data in URIS {
298            let uri = Url::parse(data);
299            match uri {
300                Ok(_) => {}
301                Err(x) => {
302                    error!("Failed <{}>: {}", data, x);
303                    // should pass iri_string test...
304                    let iri = IriStr::new(data);
305                    match iri {
306                        Ok(_) => info!("...but passed iri_string!"),
307                        Err(x) => error!("...and iri_string: {}", x),
308                    }
309                }
310            }
311        }
312    }
313}