Skip to main content

csp_parse/
parser.rs

1//! Generic top-level splitting: policy-list -> policy -> directive.
2//!
3//! Phase 02 scope (see `plan/02-directive-splitting.md`). Infallible by
4//! design (see `plan/DECISIONS.md`, 2026-08-22): CSP's own imperative
5//! parsing algorithm never rejects a whole policy (list) for syntax
6//! reasons at this level, it only ever drops or ignores individual
7//! empty/malformed pieces.
8
9use crate::ast::{Directive, Policy, PolicyList};
10
11/// Parses a serialized CSP or comma-separated CSP list (as sent in the
12/// `Content-Security-Policy` HTTP header) into a [`PolicyList`].
13///
14/// This only performs the generic, directive-independent split described
15/// by CSP3 §2.2/§2.3 (`serialized-policy-list` / `serialized-policy` /
16/// `serialized-directive`) -- directive values are kept raw. Directive
17/// name/value byte-class conformance is available per directive via
18/// [`Directive::name_is_valid`]/[`Directive::value_is_valid`].
19pub fn parse_policy_list(input: &str) -> PolicyList {
20    PolicyList {
21        policies: input.split(',').map(parse_policy).collect(),
22    }
23}
24
25fn parse_policy(segment: &str) -> Policy {
26    let directives = segment
27        .trim_ascii()
28        .split(';')
29        .map(str::trim_ascii)
30        .filter(|token| !token.is_empty())
31        .map(parse_directive)
32        .collect();
33    Policy { directives }
34}
35
36fn parse_directive(token: &str) -> Directive {
37    match token.find(|c: char| c.is_ascii_whitespace()) {
38        Some(split_at) => {
39            let name = &token[..split_at];
40            let rest = token[split_at..].trim_ascii_start();
41            let raw_value = if rest.is_empty() {
42                None
43            } else {
44                Some(rest.to_string())
45            };
46            Directive {
47                name: name.to_string(),
48                raw_value,
49            }
50        }
51        None => Directive {
52            name: token.to_string(),
53            raw_value: None,
54        },
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    fn directive(name: &str, raw_value: Option<&str>) -> Directive {
63        Directive {
64            name: name.to_string(),
65            raw_value: raw_value.map(str::to_string),
66        }
67    }
68
69    #[test]
70    fn single_policy_multiple_directives() {
71        let result = parse_policy_list(
72            "default-src 'self'; img-src *; object-src 'none'; script-src example.com",
73        );
74        assert_eq!(
75            result,
76            PolicyList {
77                policies: vec![Policy {
78                    directives: vec![
79                        directive("default-src", Some("'self'")),
80                        directive("img-src", Some("*")),
81                        directive("object-src", Some("'none'")),
82                        directive("script-src", Some("example.com")),
83                    ],
84                }],
85            }
86        );
87    }
88
89    #[test]
90    fn comma_separated_policy_list() {
91        let result = parse_policy_list("default-src 'self', script-src 'none'");
92        assert_eq!(result.policies.len(), 2);
93        assert_eq!(
94            result.policies[0].directives,
95            vec![directive("default-src", Some("'self'"))]
96        );
97        assert_eq!(
98            result.policies[1].directives,
99            vec![directive("script-src", Some("'none'"))]
100        );
101    }
102
103    #[test]
104    fn empty_string_yields_single_empty_policy() {
105        let result = parse_policy_list("");
106        assert_eq!(
107            result,
108            PolicyList {
109                policies: vec![Policy { directives: vec![] }],
110            }
111        );
112    }
113
114    #[test]
115    fn whitespace_only_yields_empty_policy() {
116        let result = parse_policy_list("   \t  ");
117        assert_eq!(result.policies, vec![Policy { directives: vec![] }]);
118    }
119
120    #[test]
121    fn semicolons_only_yield_empty_policy() {
122        let result = parse_policy_list(";;;");
123        assert_eq!(result.policies, vec![Policy { directives: vec![] }]);
124    }
125
126    #[test]
127    fn empty_segments_between_commas_yield_empty_policies() {
128        let result = parse_policy_list("default-src 'self',,report-to endpoint");
129        assert_eq!(result.policies.len(), 3);
130        assert_eq!(
131            result.policies[0].directives,
132            vec![directive("default-src", Some("'self'"))]
133        );
134        assert_eq!(result.policies[1].directives, vec![]);
135        assert_eq!(
136            result.policies[2].directives,
137            vec![directive("report-to", Some("endpoint"))]
138        );
139    }
140
141    #[test]
142    fn multiple_whitespace_between_name_and_value_is_stripped() {
143        let result = parse_policy_list("script-src    'self'");
144        assert_eq!(
145            result.policies[0].directives,
146            vec![directive("script-src", Some("'self'"))]
147        );
148    }
149
150    #[test]
151    fn valueless_directive() {
152        let result = parse_policy_list("upgrade-insecure-requests");
153        assert_eq!(
154            result.policies[0].directives,
155            vec![directive("upgrade-insecure-requests", None)]
156        );
157    }
158
159    #[test]
160    fn directive_name_with_digits_and_hyphen() {
161        let result = parse_policy_list("script-src-elem 'self'");
162        assert_eq!(
163            result.policies[0].directives,
164            vec![directive("script-src-elem", Some("'self'"))]
165        );
166    }
167
168    #[test]
169    fn leading_and_trailing_whitespace_around_directive_is_trimmed() {
170        let result = parse_policy_list("  script-src 'self'  ;  img-src *  ");
171        assert_eq!(
172            result.policies[0].directives,
173            vec![
174                directive("script-src", Some("'self'")),
175                directive("img-src", Some("*")),
176            ]
177        );
178    }
179
180    #[test]
181    fn fault_tolerance_malformed_directive_next_to_valid_ones() {
182        // A control byte (0x01) in the value is outside directive-value's
183        // allowed byte classes -- still parsed structurally, but flagged
184        // as invalid via `value_is_valid`, not dropped or turned into a
185        // parse error (see plan/DECISIONS.md, 2026-08-22).
186        let result = parse_policy_list("default-src 'self'; script-src 'self' \u{1}bad");
187        let directives = &result.policies[0].directives;
188        assert_eq!(directives.len(), 2);
189        assert!(directives[0].name_is_valid());
190        assert!(directives[0].value_is_valid());
191        assert!(directives[1].name_is_valid());
192        assert!(!directives[1].value_is_valid());
193    }
194
195    #[test]
196    fn invalid_directive_name_is_still_parsed_but_flagged() {
197        let result = parse_policy_list("bad_name 'self'");
198        let directive = &result.policies[0].directives[0];
199        assert_eq!(directive.name, "bad_name");
200        assert!(!directive.name_is_valid());
201    }
202
203    #[test]
204    fn duplicate_directive_names_are_both_kept() {
205        // Deduplication is deliberately out of scope for this phase --
206        // see plan/DECISIONS.md, 2026-08-22.
207        let result = parse_policy_list("default-src 'self'; default-src 'none'");
208        assert_eq!(
209            result.policies[0].directives,
210            vec![
211                directive("default-src", Some("'self'")),
212                directive("default-src", Some("'none'")),
213            ]
214        );
215    }
216}