rustlavel_http/
headers.rs1use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
9pub struct Headers {
10 entries: BTreeMap<String, Vec<String>>,
11}
12
13fn sanitise(text: &str) -> String {
32 text.chars().filter(|c| *c == '\t' || !c.is_control()).collect()
33}
34
35impl Headers {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn set(&mut self, name: &str, value: impl Into<String>) {
42 self.entries.insert(sanitise(name).to_ascii_lowercase(), vec![sanitise(&value.into())]);
43 }
44
45 pub fn append(&mut self, name: &str, value: impl Into<String>) {
48 self.entries
49 .entry(sanitise(name).to_ascii_lowercase())
50 .or_default()
51 .push(sanitise(&value.into()));
52 }
53
54 pub fn get(&self, name: &str) -> Option<&str> {
55 self.entries.get(&name.to_ascii_lowercase()).and_then(|v| v.first()).map(String::as_str)
56 }
57
58 pub fn get_all(&self, name: &str) -> &[String] {
59 self.entries.get(&name.to_ascii_lowercase()).map_or(&[], Vec::as_slice)
60 }
61
62 pub fn contains(&self, name: &str) -> bool {
63 self.entries.contains_key(&name.to_ascii_lowercase())
64 }
65
66 pub fn remove(&mut self, name: &str) {
67 self.entries.remove(&name.to_ascii_lowercase());
68 }
69
70 pub fn is_empty(&self) -> bool {
71 self.entries.is_empty()
72 }
73
74 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
76 self.entries
77 .iter()
78 .flat_map(|(name, values)| values.iter().map(move |value| (name.as_str(), value.as_str())))
79 }
80
81 pub fn content_length(&self) -> Option<usize> {
83 self.get("content-length")?.trim().parse().ok()
84 }
85
86 pub fn content_type(&self) -> Option<&str> {
88 let value = self.get("content-type")?;
89 Some(value.split(';').next().unwrap_or(value).trim())
90 }
91}
92
93#[cfg(test)]
94mod tests {
95
96 #[test]
102 fn a_newline_cannot_be_smuggled_into_a_header() {
103 let mut headers = Headers::new();
104 headers.set("location", "/next\r\nset-cookie: session=attacker\r\n\r\n<html>");
105
106 let value = headers.get("location").expect("the header is still set");
107 assert!(!value.contains('\r') && !value.contains('\n'), "{value:?}");
108 assert!(!value.contains("\r\nset-cookie"), "a second header was smuggled: {value:?}");
109 assert_eq!(value, "/nextset-cookie: session=attacker<html>");
110 }
111
112 #[test]
113 fn append_is_guarded_too_and_so_is_the_name() {
114 let mut headers = Headers::new();
115 headers.append("set-cookie", "a=1\nx-evil: yes");
116 headers.set("x-name\r\ninjected", "fine");
117
118 assert!(!headers.get("set-cookie").unwrap().contains('\n'));
119 assert!(headers.get("x-nameinjected").is_some(), "the name was not cleaned");
120 }
121
122 #[test]
125 fn a_tab_survives() {
126 let mut headers = Headers::new();
127 headers.set("x-thing", "a\tb");
128 assert_eq!(headers.get("x-thing"), Some("a\tb"));
129 }
130 use super::*;
131
132 #[test]
133 fn lookups_ignore_case() {
134 let mut headers = Headers::new();
135 headers.set("Content-Type", "application/json");
136
137 assert_eq!(headers.get("content-type"), Some("application/json"));
138 assert_eq!(headers.get("CONTENT-TYPE"), Some("application/json"));
139 assert!(headers.contains("Content-Type"));
140 }
141
142 #[test]
143 fn set_replaces_while_append_accumulates() {
144 let mut headers = Headers::new();
145 headers.set("x-tag", "one");
146 headers.set("x-tag", "two");
147 assert_eq!(headers.get_all("x-tag"), ["two"]);
148
149 headers.append("set-cookie", "a=1");
150 headers.append("set-cookie", "b=2");
151 assert_eq!(headers.get_all("set-cookie").len(), 2);
152 }
153
154 #[test]
155 fn parses_content_metadata() {
156 let mut headers = Headers::new();
157 headers.set("content-type", "application/json; charset=utf-8");
158 headers.set("content-length", "42");
159
160 assert_eq!(headers.content_type(), Some("application/json"));
161 assert_eq!(headers.content_length(), Some(42));
162 }
163}