Skip to main content

leyline/audit/compare/
headers.rs

1use serde::Deserialize;
2
3use super::FieldOutcome;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6#[non_exhaustive]
7pub struct HeaderOutcome {
8    pub name: String,
9    pub outcome: FieldOutcome,
10}
11
12#[derive(Deserialize, Default)]
13pub(super) struct EchoHttp1 {
14    #[serde(default)]
15    headers: Vec<String>,
16}
17
18#[derive(Deserialize, Default)]
19pub(super) struct EchoFrame {
20    #[serde(default)]
21    headers: Vec<String>,
22}
23
24pub(super) fn echoed(http2: &[EchoFrame], http1: &EchoHttp1) -> Vec<(String, String)> {
25    http2
26        .iter()
27        .flat_map(|frame| frame.headers.iter())
28        .chain(http1.headers.iter())
29        .filter_map(|line| split_line(line))
30        .collect()
31}
32
33fn split_line(line: &str) -> Option<(String, String)> {
34    if line.starts_with(':') {
35        return None;
36    }
37    let (name, value) = line.split_once(':')?;
38    let name = name.trim();
39    (!name.is_empty()).then(|| (name.to_ascii_lowercase(), value.trim().to_owned()))
40}
41
42pub(super) fn compare(
43    sent: &[(String, String)],
44    observed: &[(String, String)],
45) -> Vec<HeaderOutcome> {
46    let mut names: Vec<String> = Vec::new();
47    for name in field_names(sent) {
48        if !names.contains(&name) {
49            names.push(name);
50        }
51    }
52    names
53        .into_iter()
54        .map(|name| {
55            let outcome = header_outcome(&name, sent, observed);
56            HeaderOutcome { name, outcome }
57        })
58        .collect()
59}
60
61pub(super) fn order(sent: &[(String, String)], observed: &[(String, String)]) -> FieldOutcome {
62    if observed.is_empty() {
63        return FieldOutcome::NotReported;
64    }
65    let expected = shared_names(sent, observed).join(",");
66    let seen = shared_names(observed, sent).join(",");
67    if expected == seen {
68        FieldOutcome::Match
69    } else {
70        FieldOutcome::Mismatch {
71            expected,
72            observed: seen,
73        }
74    }
75}
76
77fn field_names(list: &[(String, String)]) -> impl Iterator<Item = String> + '_ {
78    list.iter()
79        .filter(|(name, _)| !name.starts_with(':'))
80        .map(|(name, _)| name.to_ascii_lowercase())
81}
82
83fn shared_names(from: &[(String, String)], other: &[(String, String)]) -> Vec<String> {
84    field_names(from)
85        .filter(|name| {
86            other
87                .iter()
88                .any(|(held, _)| held.eq_ignore_ascii_case(name))
89        })
90        .collect()
91}
92
93fn values<'a>(name: &str, list: &'a [(String, String)]) -> Vec<&'a str> {
94    list.iter()
95        .filter(|(held, _)| held.eq_ignore_ascii_case(name))
96        .map(|(_, value)| value.trim())
97        .collect()
98}
99
100const COOKIE: &str = "cookie";
101
102fn joined_cookie(list: &[(String, String)]) -> Vec<&str> {
103    values(COOKIE, list)
104        .into_iter()
105        .flat_map(|value| value.split(';'))
106        .map(str::trim)
107        .filter(|pair| !pair.is_empty())
108        .collect()
109}
110
111fn header_outcome(
112    name: &str,
113    sent: &[(String, String)],
114    observed: &[(String, String)],
115) -> FieldOutcome {
116    if observed.is_empty() {
117        return FieldOutcome::NotReported;
118    }
119    let (expected, seen) = if name.eq_ignore_ascii_case(COOKIE) {
120        (joined_cookie(sent), joined_cookie(observed))
121    } else {
122        (values(name, sent), values(name, observed))
123    };
124    if seen.is_empty() {
125        return FieldOutcome::Absent {
126            expected: expected.join(", "),
127        };
128    }
129    if seen == expected {
130        return FieldOutcome::Match;
131    }
132    FieldOutcome::Mismatch {
133        expected: expected.join(", "),
134        observed: seen.join(", "),
135    }
136}