Skip to main content

csp_parse/
source_list.rs

1//! The `serialized-source-list` value grammar (CSP3 §2.3.1) shared by
2//! most directives (`default-src`, `script-src`, …, `base-uri`,
3//! `form-action`, and others -- see `plan/04-directive-registry.md` for
4//! which directives use it). Operates on a directive's raw value string
5//! (e.g. [`crate::Directive::raw_value`]).
6//!
7//! Parsing is infallible and lenient, consistent with
8//! [`crate::parse_policy_list`] (see `plan/DECISIONS.md`, 2026-08-22): a
9//! token that doesn't match any `source-expression` alternative is kept
10//! as an unrecognized [`SourceListEntry`] rather than failing the whole
11//! parse.
12
13use crate::hash::{HashExpression, parse_hash_expression};
14
15/// A parsed `serialized-source-list` (CSP3 §2.3.1).
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum SourceList {
19    /// The sole `'none'` keyword -- the only form in which `'none'` may
20    /// appear (it is an alternative to, not a member of, a list of other
21    /// `source-expression`s).
22    None,
23    /// One or more whitespace-separated `source-expression` tokens.
24    Sources(Vec<SourceListEntry>),
25}
26
27/// A single whitespace-separated token from a [`SourceList::Sources`]
28/// list, together with its recognized [`SourceExpression`] (if any).
29#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct SourceListEntry {
32    /// The original token text, unmodified.
33    pub raw: String,
34    /// The recognized `source-expression`, or `None` if `raw` doesn't
35    /// match any of the five alternatives.
36    pub expression: Option<SourceExpression>,
37}
38
39/// One recognized `source-expression` alternative (CSP3 §2.3.1).
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum SourceExpression {
43    /// A `scheme-source` (e.g. `https:`), without the trailing `:`.
44    Scheme(String),
45    /// A `host-source`.
46    Host(HostSource),
47    /// A `keyword-source` (e.g. `'self'`).
48    Keyword(Keyword),
49    /// The base64-value from a `'nonce-...'` token, without the
50    /// `'nonce-`/`'` wrapper.
51    Nonce(String),
52    /// A `hash-source` (e.g. `'sha256-...'`).
53    Hash(HashExpression),
54}
55
56/// A parsed `host-source` (CSP3 §2.3.1).
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct HostSource {
60    /// The `scheme-part` before `://`, if present.
61    pub scheme: Option<String>,
62    /// The `host-part`.
63    pub host: HostPart,
64    /// The `port-part`, if present.
65    pub port: Option<PortPart>,
66    /// The raw `path-part`, if present, including its leading `/`.
67    pub path: Option<String>,
68}
69
70/// A parsed `host-part` (CSP3 §2.3.1).
71#[derive(Debug, Clone, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum HostPart {
74    /// Bare `*` -- matches any host.
75    AnyHost,
76    /// `[ "*." ] 1*host-char *( "." 1*host-char ) [ "." ]`.
77    Named {
78        /// Whether the host started with the `*.` wildcard-label prefix.
79        wildcard_prefix: bool,
80        /// The dot-separated labels, in order (the `*.` prefix, if any,
81        /// is not itself a label).
82        labels: Vec<String>,
83        /// Whether the host ended with a trailing `.`.
84        trailing_dot: bool,
85    },
86}
87
88/// A parsed `port-part` (CSP3 §2.3.1). Kept as a digit string rather
89/// than a numeric type: the ABNF (`1*DIGIT`) does not bound the value to
90/// a valid 16-bit port number, and this crate does not normalize.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum PortPart {
94    /// `1*DIGIT`, kept as a string (see this enum's docs).
95    Number(String),
96    /// `*`.
97    Wildcard,
98}
99
100/// All `keyword-source` values (CSP3 §2.3.1). See
101/// `plan/03-source-list-grammar.md`: this list was pulled from an
102/// automated spec fetch, not verified character-for-character against
103/// the current spec text -- re-check before treating it as exhaustive.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum Keyword {
107    /// `'self'`.
108    SelfKeyword,
109    /// `'unsafe-inline'`.
110    UnsafeInline,
111    /// `'unsafe-eval'`.
112    UnsafeEval,
113    /// `'strict-dynamic'`.
114    StrictDynamic,
115    /// `'unsafe-hashes'`.
116    UnsafeHashes,
117    /// `'report-sample'`.
118    ReportSample,
119    /// `'unsafe-allow-redirects'`.
120    UnsafeAllowRedirects,
121    /// `'wasm-unsafe-eval'`.
122    WasmUnsafeEval,
123}
124
125impl Keyword {
126    const ALL: [(&'static str, Keyword); 8] = [
127        ("self", Keyword::SelfKeyword),
128        ("unsafe-inline", Keyword::UnsafeInline),
129        ("unsafe-eval", Keyword::UnsafeEval),
130        ("strict-dynamic", Keyword::StrictDynamic),
131        ("unsafe-hashes", Keyword::UnsafeHashes),
132        ("report-sample", Keyword::ReportSample),
133        ("unsafe-allow-redirects", Keyword::UnsafeAllowRedirects),
134        ("wasm-unsafe-eval", Keyword::WasmUnsafeEval),
135    ];
136
137    fn from_unquoted(s: &str) -> Option<Keyword> {
138        Self::ALL
139            .iter()
140            .find(|(name, _)| *name == s)
141            .map(|(_, keyword)| *keyword)
142    }
143}
144
145/// Parses a directive's raw value as a `serialized-source-list`.
146pub fn parse_source_list(raw: &str) -> SourceList {
147    let tokens: Vec<&str> = raw.trim_ascii().split_ascii_whitespace().collect();
148    if tokens.as_slice() == ["'none'"] {
149        return SourceList::None;
150    }
151    SourceList::Sources(
152        tokens
153            .into_iter()
154            .map(|token| SourceListEntry {
155                raw: token.to_string(),
156                expression: classify_source_expression(token),
157            })
158            .collect(),
159    )
160}
161
162fn classify_source_expression(token: &str) -> Option<SourceExpression> {
163    if token.len() >= 2 && token.starts_with('\'') && token.ends_with('\'') {
164        let inner = &token[1..token.len() - 1];
165        if let Some(keyword) = Keyword::from_unquoted(inner) {
166            return Some(SourceExpression::Keyword(keyword));
167        }
168        if let Some(value) = inner.strip_prefix("nonce-") {
169            return is_valid_nonce_value(value).then(|| SourceExpression::Nonce(value.to_string()));
170        }
171        return parse_hash_expression(inner).map(SourceExpression::Hash);
172    }
173    if let Some(scheme) = token.strip_suffix(':')
174        && is_scheme(scheme)
175    {
176        return Some(SourceExpression::Scheme(scheme.to_string()));
177    }
178    parse_host_source(token).map(SourceExpression::Host)
179}
180
181fn is_valid_nonce_value(s: &str) -> bool {
182    crate::hash::is_valid_base64_value(s)
183}
184
185/// `scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` (RFC 3986 §3.1).
186fn is_scheme(s: &str) -> bool {
187    let mut chars = s.chars();
188    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic())
189        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
190}
191
192fn is_host_char(b: u8) -> bool {
193    b.is_ascii_alphanumeric() || b == b'-'
194}
195
196fn parse_host_source(token: &str) -> Option<HostSource> {
197    let mut rest = token;
198    let mut scheme = None;
199    if let Some(pos) = rest.find("://") {
200        let candidate = &rest[..pos];
201        if !is_scheme(candidate) {
202            return None;
203        }
204        scheme = Some(candidate.to_string());
205        rest = &rest[pos + 3..];
206    }
207    let (host, rest) = split_host_part(rest)?;
208    let (port, rest) = split_port_part(rest)?;
209    let path = match rest {
210        "" => None,
211        p if p.starts_with('/') => Some(p.to_string()),
212        _ => return None,
213    };
214    Some(HostSource {
215        scheme,
216        host,
217        port,
218        path,
219    })
220}
221
222fn split_host_part(s: &str) -> Option<(HostPart, &str)> {
223    if let Some(rest) = s.strip_prefix("*.") {
224        let (labels, trailing_dot, rest) = scan_labels(rest)?;
225        return Some((
226            HostPart::Named {
227                wildcard_prefix: true,
228                labels,
229                trailing_dot,
230            },
231            rest,
232        ));
233    }
234    if let Some(rest) = s.strip_prefix('*') {
235        return Some((HostPart::AnyHost, rest));
236    }
237    let (labels, trailing_dot, rest) = scan_labels(s)?;
238    Some((
239        HostPart::Named {
240            wildcard_prefix: false,
241            labels,
242            trailing_dot,
243        },
244        rest,
245    ))
246}
247
248/// Scans a leading `1*host-char *( "." 1*host-char ) [ "." ]` prefix of
249/// `s`, returning its dot-separated labels, whether a trailing `.` was
250/// present, and the unconsumed remainder.
251fn scan_labels(s: &str) -> Option<(Vec<String>, bool, &str)> {
252    let end = s
253        .bytes()
254        .take_while(|&b| is_host_char(b) || b == b'.')
255        .count();
256    if end == 0 {
257        return None;
258    }
259    let (matched, rest) = s.split_at(end);
260    let (label_str, trailing_dot) = match matched.strip_suffix('.') {
261        Some(stripped) => (stripped, true),
262        None => (matched, false),
263    };
264    if label_str.is_empty() {
265        return None;
266    }
267    let labels: Vec<String> = label_str.split('.').map(str::to_string).collect();
268    if labels.iter().any(String::is_empty) {
269        return None;
270    }
271    Some((labels, trailing_dot, rest))
272}
273
274fn split_port_part(s: &str) -> Option<(Option<PortPart>, &str)> {
275    let Some(rest) = s.strip_prefix(':') else {
276        return Some((None, s));
277    };
278    if let Some(rest) = rest.strip_prefix('*') {
279        return Some((Some(PortPart::Wildcard), rest));
280    }
281    let digits_len = rest.bytes().take_while(u8::is_ascii_digit).count();
282    if digits_len == 0 {
283        return None;
284    }
285    let (digits, rest) = rest.split_at(digits_len);
286    Some((Some(PortPart::Number(digits.to_string())), rest))
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn sources(raw: &str) -> Vec<Option<SourceExpression>> {
294        match parse_source_list(raw) {
295            SourceList::None => panic!("expected Sources, got None"),
296            SourceList::Sources(entries) => entries.into_iter().map(|e| e.expression).collect(),
297        }
298    }
299
300    #[test]
301    fn none_is_exclusive() {
302        assert_eq!(parse_source_list("'none'"), SourceList::None);
303    }
304
305    #[test]
306    fn none_mixed_with_other_tokens_is_not_the_none_variant() {
307        match parse_source_list("'none' 'self'") {
308            SourceList::Sources(entries) => {
309                assert_eq!(entries.len(), 2);
310                assert_eq!(entries[0].expression, None); // 'none' alone is not a source-expression
311                assert!(entries[1].expression.is_some());
312            }
313            SourceList::None => panic!("must not collapse to None when combined with other tokens"),
314        }
315    }
316
317    #[test]
318    fn wildcard_hosts() {
319        assert_eq!(
320            sources("* *.example.com"),
321            vec![
322                Some(SourceExpression::Host(HostSource {
323                    scheme: None,
324                    host: HostPart::AnyHost,
325                    port: None,
326                    path: None,
327                })),
328                Some(SourceExpression::Host(HostSource {
329                    scheme: None,
330                    host: HostPart::Named {
331                        wildcard_prefix: true,
332                        labels: vec!["example".to_string(), "com".to_string()],
333                        trailing_dot: false,
334                    },
335                    port: None,
336                    path: None,
337                })),
338            ]
339        );
340    }
341
342    #[test]
343    fn host_with_numeric_and_wildcard_port() {
344        assert_eq!(
345            sources("example.com:443 example.com:*"),
346            vec![
347                Some(SourceExpression::Host(HostSource {
348                    scheme: None,
349                    host: HostPart::Named {
350                        wildcard_prefix: false,
351                        labels: vec!["example".to_string(), "com".to_string()],
352                        trailing_dot: false,
353                    },
354                    port: Some(PortPart::Number("443".to_string())),
355                    path: None,
356                })),
357                Some(SourceExpression::Host(HostSource {
358                    scheme: None,
359                    host: HostPart::Named {
360                        wildcard_prefix: false,
361                        labels: vec!["example".to_string(), "com".to_string()],
362                        trailing_dot: false,
363                    },
364                    port: Some(PortPart::Wildcard),
365                    path: None,
366                })),
367            ]
368        );
369    }
370
371    #[test]
372    fn host_with_path() {
373        let result = sources("example.com/path/to/thing");
374        match &result[0] {
375            Some(SourceExpression::Host(HostSource { path, .. })) => {
376                assert_eq!(path.as_deref(), Some("/path/to/thing"));
377            }
378            other => panic!("expected Host with path, got {other:?}"),
379        }
380    }
381
382    #[test]
383    fn host_with_scheme_and_path() {
384        let result = sources("https://example.com/a");
385        match &result[0] {
386            Some(SourceExpression::Host(HostSource { scheme, path, .. })) => {
387                assert_eq!(scheme.as_deref(), Some("https"));
388                assert_eq!(path.as_deref(), Some("/a"));
389            }
390            other => panic!("expected Host with scheme+path, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn scheme_only() {
396        assert_eq!(
397            sources("https: data:"),
398            vec![
399                Some(SourceExpression::Scheme("https".to_string())),
400                Some(SourceExpression::Scheme("data".to_string())),
401            ]
402        );
403    }
404
405    #[test]
406    fn all_keywords() {
407        let raw = "'self' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' \
408                   'unsafe-hashes' 'report-sample' 'unsafe-allow-redirects' \
409                   'wasm-unsafe-eval'";
410        let expected = vec![
411            Keyword::SelfKeyword,
412            Keyword::UnsafeInline,
413            Keyword::UnsafeEval,
414            Keyword::StrictDynamic,
415            Keyword::UnsafeHashes,
416            Keyword::ReportSample,
417            Keyword::UnsafeAllowRedirects,
418            Keyword::WasmUnsafeEval,
419        ];
420        for (entry, keyword) in sources(raw).into_iter().zip(expected) {
421            assert_eq!(entry, Some(SourceExpression::Keyword(keyword)));
422        }
423    }
424
425    #[test]
426    fn nonce_valid_and_invalid() {
427        assert_eq!(
428            sources("'nonce-abc123+/=='")[0],
429            Some(SourceExpression::Nonce("abc123+/==".to_string()))
430        );
431        assert_eq!(sources("'nonce-'")[0], None); // empty base64-value
432        assert_eq!(sources("'nonce-bad value'").len(), 2); // whitespace splits the token itself
433    }
434
435    #[test]
436    fn hashes_with_and_without_padding() {
437        assert_eq!(
438            sources("'sha256-abc123=='")[0],
439            Some(SourceExpression::Hash(HashExpression {
440                algorithm: crate::hash::HashAlgorithm::Sha256,
441                value: "abc123==".to_string(),
442            }))
443        );
444        assert_eq!(
445            sources("'sha384-abc123'")[0],
446            Some(SourceExpression::Hash(HashExpression {
447                algorithm: crate::hash::HashAlgorithm::Sha384,
448                value: "abc123".to_string(),
449            }))
450        );
451        assert_eq!(
452            sources("'sha512-abc123'")[0],
453            Some(SourceExpression::Hash(HashExpression {
454                algorithm: crate::hash::HashAlgorithm::Sha512,
455                value: "abc123".to_string(),
456            }))
457        );
458    }
459
460    #[test]
461    fn unrecognized_tokens_are_kept_but_unclassified() {
462        match parse_source_list("'self' not-a-valid-source!") {
463            SourceList::Sources(entries) => {
464                assert_eq!(entries.len(), 2);
465                assert!(entries[0].expression.is_some());
466                assert_eq!(entries[1].raw, "not-a-valid-source!");
467                assert_eq!(entries[1].expression, None);
468            }
469            SourceList::None => panic!("expected Sources"),
470        }
471    }
472
473    #[test]
474    fn malformed_quotes_are_unrecognized() {
475        assert_eq!(sources("'unknown-keyword'")[0], None);
476        assert_eq!(sources("'self")[0], None);
477    }
478}