sectxtlib/
lib.rs

1mod fields;
2mod parse_error;
3mod parsers;
4mod pgpcleartextmessage;
5mod raw_field;
6mod securitytxt;
7mod securitytxt_options;
8
9pub use fields::{
10    AcknowledgmentsField, CanonicalField, ContactField, EncryptionField, ExpiresField, ExtensionField, HiringField,
11    PolicyField, PreferredLanguagesField,
12};
13pub use parse_error::ParseError;
14pub use securitytxt::SecurityTxt;
15pub use securitytxt_options::SecurityTxtOptions;
16
17#[cfg(test)]
18mod tests {
19    use crate::fields::CsafField;
20
21    use super::*;
22    use chrono::{DateTime, Datelike, Duration, SecondsFormat, TimeZone, Utc};
23    use std::{fs, path::PathBuf};
24
25    const URL: &str = "https://securitytxt.org/";
26    const INSECURE_URL: &str = "http://securitytxt.org/";
27
28    fn some_datetime() -> DateTime<Utc> {
29        DateTime::parse_from_rfc3339("2023-01-01T08:19:03.000Z").unwrap().into()
30    }
31
32    fn future_expires_str() -> String {
33        (Utc::now() + Duration::days(365)).to_rfc3339_opts(SecondsFormat::Millis, true)
34    }
35
36    fn expires_dt(expires: &str) -> ExpiresField {
37        ExpiresField::new(expires, some_datetime()).unwrap()
38    }
39
40    fn get_parse_options() -> SecurityTxtOptions {
41        SecurityTxtOptions {
42            now: some_datetime(),
43            strict: true,
44        }
45    }
46
47    fn get_tests_dir(category: &str) -> PathBuf {
48        let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
49        d.push(format!("resources/test/{category}"));
50        d
51    }
52
53    #[test]
54    fn test_contact_and_expires() {
55        let expires = future_expires_str();
56        let file = format!("Contact: {URL}\nExpires: {expires}\n");
57        let sec = SecurityTxt {
58            acknowledgments: vec![],
59            canonical: vec![],
60            contact: vec![ContactField::new(URL).unwrap()],
61            csaf: vec![],
62            encryption: vec![],
63            expires: expires_dt(&expires),
64            extension: vec![],
65            hiring: vec![],
66            policy: vec![],
67            preferred_languages: None,
68        };
69
70        assert_eq!(file.parse(), Ok(sec));
71    }
72
73    #[test]
74    fn test_comment() {
75        let expires = future_expires_str();
76        let file = format!("# this is a comment\n#\nContact: {URL}\nExpires: {expires}\n#\n");
77        let sec = SecurityTxt {
78            acknowledgments: vec![],
79            canonical: vec![],
80            contact: vec![ContactField::new(URL).unwrap()],
81            csaf: vec![],
82            encryption: vec![],
83            expires: expires_dt(&expires),
84            extension: vec![],
85            hiring: vec![],
86            policy: vec![],
87            preferred_languages: None,
88        };
89
90        assert_eq!(file.parse(), Ok(sec));
91    }
92
93    #[test]
94    fn test_newlines() {
95        let expires = future_expires_str();
96        let file = format!("\n\n\nContact: {URL}\n\n\nExpires: {expires}\n\n\n");
97        let sec = SecurityTxt {
98            acknowledgments: vec![],
99            canonical: vec![],
100            contact: vec![ContactField::new(URL).unwrap()],
101            csaf: vec![],
102            encryption: vec![],
103            expires: expires_dt(&expires),
104            extension: vec![],
105            hiring: vec![],
106            policy: vec![],
107            preferred_languages: None,
108        };
109
110        assert_eq!(file.parse(), Ok(sec));
111    }
112
113    #[test]
114    fn test_acknowledgements() {
115        let expires = future_expires_str();
116        let file = format!("Contact: {URL}\nExpires: {expires}\nAcknowledgments: {URL}\n");
117        let sec = SecurityTxt {
118            acknowledgments: vec![AcknowledgmentsField::new(URL).unwrap()],
119            canonical: vec![],
120            contact: vec![ContactField::new(URL).unwrap()],
121            csaf: vec![],
122            encryption: vec![],
123            expires: expires_dt(&expires),
124            extension: vec![],
125            hiring: vec![],
126            policy: vec![],
127            preferred_languages: None,
128        };
129
130        assert_eq!(file.parse(), Ok(sec));
131    }
132
133    #[test]
134    fn test_csaf() {
135        let expires = future_expires_str();
136        let file = format!("Contact: {URL}\nExpires: {expires}\nCSAF: {URL}\n");
137        let sec = SecurityTxt {
138            acknowledgments: vec![],
139            canonical: vec![],
140            contact: vec![ContactField::new(URL).unwrap()],
141            csaf: vec![CsafField::new(URL).unwrap()],
142            encryption: vec![],
143            expires: expires_dt(&expires),
144            extension: vec![],
145            hiring: vec![],
146            policy: vec![],
147            preferred_languages: None,
148        };
149
150        assert_eq!(file.parse(), Ok(sec));
151    }
152
153    #[test]
154    fn test_contact_missing() {
155        let expires = future_expires_str();
156        let file = format!("Expires: {expires}\n");
157
158        assert_eq!(file.parse::<SecurityTxt>(), Err(ParseError::ContactFieldMissing));
159    }
160
161    #[test]
162    fn test_expires_missing() {
163        let file = format!("Contact: {URL}\n");
164
165        assert_eq!(file.parse::<SecurityTxt>(), Err(ParseError::ExpiresFieldMissing));
166    }
167
168    #[test]
169    fn test_trailing_content() {
170        let expires = future_expires_str();
171        let file = format!("Contact: {URL}\nExpires: {expires}\nfoo");
172
173        assert_eq!(file.parse::<SecurityTxt>(), Err(ParseError::Malformed));
174    }
175
176    #[test]
177    fn test_preferred_languages() {
178        let expires = future_expires_str();
179        let file = format!("Contact: {URL}\nExpires: {expires}\nPreferred-Languages: en, fr\n");
180        let sec = SecurityTxt {
181            acknowledgments: vec![],
182            canonical: vec![],
183            contact: vec![ContactField::new(URL).unwrap()],
184            csaf: vec![],
185            encryption: vec![],
186            expires: expires_dt(&expires),
187            extension: vec![],
188            hiring: vec![],
189            policy: vec![],
190            preferred_languages: Some(PreferredLanguagesField::new("en, fr").unwrap()),
191        };
192
193        assert_eq!(file.parse::<SecurityTxt>(), Ok(sec));
194    }
195
196    #[test]
197    fn test_preferred_languages_multiple() {
198        let expires = future_expires_str();
199        let file = format!("Contact: {URL}\nExpires: {expires}\nPreferred-Languages: en\nPreferred-Languages: de\n");
200
201        assert_eq!(
202            file.parse::<SecurityTxt>(),
203            Err(ParseError::PreferredLanguagesFieldMultiple)
204        );
205    }
206
207    #[test]
208    fn test_expires_multiple() {
209        let expires = future_expires_str();
210        let file = format!("Contact: {URL}\nExpires: {expires}\nExpires: {expires}\n");
211
212        assert_eq!(file.parse::<SecurityTxt>(), Err(ParseError::ExpiresFieldMultiple));
213    }
214
215    #[test]
216    fn test_insecure_http() {
217        let expires = future_expires_str();
218        let file = format!("Contact: {INSECURE_URL}\nExpires: {expires}\n");
219
220        assert_eq!(file.parse::<SecurityTxt>(), Err(ParseError::InsecureHTTP));
221    }
222
223    #[test]
224    fn test_signed_contact() {
225        let expires = future_expires_str();
226        let file = format!(
227            "-----BEGIN PGP SIGNED MESSAGE-----\r
228Hash: SHA256\r
229\r
230Contact: {URL}
231Contact: {URL}\r
232Expires: {expires}\r
233-----BEGIN PGP SIGNATURE-----\r
234Version: GnuPG v2.2\r
235\r
236abcdefABCDEF/+==\r
237-----END PGP SIGNATURE-----\r
238"
239        );
240        let sec = SecurityTxt {
241            acknowledgments: vec![],
242            canonical: vec![],
243            contact: vec![ContactField::new(URL).unwrap(), ContactField::new(URL).unwrap()],
244            csaf: vec![],
245            encryption: vec![],
246            expires: expires_dt(&expires),
247            extension: vec![],
248            hiring: vec![],
249            policy: vec![],
250            preferred_languages: None,
251        };
252
253        assert_eq!(file.parse(), Ok(sec));
254    }
255
256    fn _test_category(category: &str) {
257        let paths = get_tests_dir(category).read_dir().unwrap();
258
259        for path in paths {
260            let buf = fs::read_to_string(path.unwrap().path()).unwrap();
261            let parse_options = get_parse_options();
262            let txt = SecurityTxt::parse_with(&buf, &parse_options);
263            assert_eq!(txt.is_ok(), true);
264        }
265    }
266
267    #[test]
268    fn test_category_valid_unsigned() {
269        _test_category("valid_unsigned")
270    }
271
272    #[test]
273    fn test_category_valid_signed() {
274        _test_category("valid_signed")
275    }
276
277    #[test]
278    fn test_category_gen_unsigned() {
279        _test_category("gen_unsigned")
280    }
281
282    #[test]
283    fn test_expires_non_z_time() {
284        let next_year = Utc::now().year() + 1;
285        let test_times = [
286            (
287                format!("{next_year}-08-30T00:00:00+00:00"),
288                Utc.with_ymd_and_hms(next_year, 8, 30, 0, 0, 0),
289            ),
290            (
291                format!("{next_year}-08-30T12:34:56+00:00"),
292                Utc.with_ymd_and_hms(next_year, 8, 30, 12, 34, 56),
293            ),
294            (
295                format!("{next_year}-08-30T02:00:00+02:00"),
296                Utc.with_ymd_and_hms(next_year, 8, 30, 0, 0, 0),
297            ),
298            (
299                format!("{next_year}-08-30T02:00:00-02:00"),
300                Utc.with_ymd_and_hms(next_year, 8, 30, 4, 0, 0),
301            ),
302        ];
303
304        for (expires_str, expected_dt) in &test_times {
305            let file = format!("Contact: {URL}\nExpires: {expires_str}\n");
306            let sec = SecurityTxt {
307                acknowledgments: vec![],
308                canonical: vec![],
309                contact: vec![ContactField::new(URL).unwrap()],
310                csaf: vec![],
311                encryption: vec![],
312                expires: ExpiresField::new(expires_str, some_datetime()).unwrap(),
313                extension: vec![],
314                hiring: vec![],
315                policy: vec![],
316                preferred_languages: None,
317            };
318
319            let parsed: SecurityTxt = file.parse().unwrap();
320            assert_eq!(parsed, sec);
321            let expected_dt = expected_dt.single().unwrap();
322            assert_eq!(parsed.expires.datetime.timestamp(), expected_dt.timestamp());
323            assert_eq!(
324                parsed.expires.datetime.timestamp_subsec_millis(),
325                expected_dt.timestamp_subsec_millis()
326            );
327        }
328    }
329}