1use crate::hash::{HashExpression, parse_hash_expression};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub enum SourceList {
19 None,
23 Sources(Vec<SourceListEntry>),
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
30#[non_exhaustive]
31pub struct SourceListEntry {
32 pub raw: String,
34 pub expression: Option<SourceExpression>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum SourceExpression {
43 Scheme(String),
45 Host(HostSource),
47 Keyword(Keyword),
49 Nonce(String),
52 Hash(HashExpression),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub struct HostSource {
60 pub scheme: Option<String>,
62 pub host: HostPart,
64 pub port: Option<PortPart>,
66 pub path: Option<String>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72#[non_exhaustive]
73pub enum HostPart {
74 AnyHost,
76 Named {
78 wildcard_prefix: bool,
80 labels: Vec<String>,
83 trailing_dot: bool,
85 },
86}
87
88#[derive(Debug, Clone, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum PortPart {
94 Number(String),
96 Wildcard,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[non_exhaustive]
106pub enum Keyword {
107 SelfKeyword,
109 UnsafeInline,
111 UnsafeEval,
113 StrictDynamic,
115 UnsafeHashes,
117 ReportSample,
119 UnsafeAllowRedirects,
121 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
145pub 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
185fn 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
248fn 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); 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); assert_eq!(sources("'nonce-bad value'").len(), 2); }
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}