1use std::collections::HashMap;
20use std::fmt;
21use std::sync::Mutex;
22
23use reqwest::cookie::CookieStore;
24use reqwest::header::HeaderValue;
25use url::Url;
26
27#[derive(Default)]
31pub struct NameKeyedJar {
32 inner: Mutex<HashMap<String, String>>,
36}
37
38impl fmt::Debug for NameKeyedJar {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
45 let mut names: Vec<&str> = guard.keys().map(String::as_str).collect();
46 names.sort_unstable();
47 f.debug_struct("NameKeyedJar")
48 .field("len", &guard.len())
49 .field("names", &names)
50 .finish()
51 }
52}
53
54fn is_unsafe_byte(b: u8) -> bool {
59 b < 0x20 || b == 0x7F
60}
61
62fn is_safe(s: &str) -> bool {
63 !s.bytes().any(is_unsafe_byte)
64}
65
66impl NameKeyedJar {
67 #[must_use]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn set_pairs<I, S>(&self, pairs: I)
76 where
77 I: IntoIterator<Item = S>,
78 S: AsRef<str>,
79 {
80 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
81 for raw in pairs {
82 let trimmed = raw.as_ref().trim();
83 if trimmed.is_empty() {
84 continue;
85 }
86 if let Some((name, value)) = trimmed.split_once('=') {
87 let name = name.trim();
88 let value = value.split(';').next().unwrap_or("").trim();
89 if name.is_empty() || !is_safe(name) || !is_safe(value) {
96 continue;
97 }
98 guard.insert(name.to_owned(), value.to_owned());
99 }
100 }
101 }
102
103 pub fn clear(&self) {
106 self.inner.lock().unwrap_or_else(|e| e.into_inner()).clear();
107 }
108
109 #[must_use]
111 pub fn len(&self) -> usize {
112 self.inner.lock().unwrap_or_else(|e| e.into_inner()).len()
113 }
114
115 #[must_use]
117 pub fn is_empty(&self) -> bool {
118 self.len() == 0
119 }
120
121 #[must_use]
124 pub fn snapshot(&self) -> Vec<(String, String)> {
125 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
126 let mut entries: Vec<_> = guard.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
127 entries.sort_by(|a, b| a.0.cmp(&b.0));
128 entries
129 }
130}
131
132impl CookieStore for NameKeyedJar {
133 fn set_cookies(&self, cookie_headers: &mut dyn Iterator<Item = &HeaderValue>, _url: &Url) {
134 let mut guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
135 for header in cookie_headers {
136 let Ok(s) = header.to_str() else { continue };
137 let primary = s.split(';').next().unwrap_or("").trim();
140 if primary.is_empty() {
141 continue;
142 }
143 if let Some((name, value)) = primary.split_once('=') {
144 let name = name.trim();
145 let value = value.trim();
146 if name.is_empty() || !is_safe(name) || !is_safe(value) {
150 continue;
151 }
152 guard.insert(name.to_owned(), value.to_owned());
153 }
154 }
155 }
156
157 fn cookies(&self, _url: &Url) -> Option<HeaderValue> {
158 let guard = self.inner.lock().unwrap_or_else(|e| e.into_inner());
159 if guard.is_empty() {
160 return None;
161 }
162 let mut buf = String::new();
163 for (i, (name, value)) in guard.iter().enumerate() {
164 if i > 0 {
165 buf.push_str("; ");
166 }
167 buf.push_str(name);
168 buf.push('=');
169 buf.push_str(value);
170 }
171 HeaderValue::from_str(&buf).ok()
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn url() -> Url {
180 Url::parse("https://localhost:5000/").unwrap()
181 }
182
183 #[test]
184 fn set_cookies_replaces_by_name() {
185 let jar = NameKeyedJar::new();
186 let h1 = HeaderValue::from_static("JSESSIONID=OLD; Path=/sso; HttpOnly");
187 let h2 = HeaderValue::from_static("JSESSIONID=NEW; Path=/; Secure");
188 jar.set_cookies(&mut [h1].iter(), &url());
189 jar.set_cookies(&mut [h2].iter(), &url());
190 assert_eq!(jar.snapshot(), vec![("JSESSIONID".into(), "NEW".into())]);
191 }
192
193 #[test]
194 fn set_pairs_replaces_by_name() {
195 let jar = NameKeyedJar::new();
196 jar.set_pairs(["X=A"]);
197 jar.set_pairs(["X=B"]);
198 assert_eq!(jar.snapshot(), vec![("X".into(), "B".into())]);
199 }
200
201 #[test]
202 fn set_pairs_strips_attributes() {
203 let jar = NameKeyedJar::new();
204 jar.set_pairs(["JSESSIONID=ABC; Domain=.ibkr.com; Secure"]);
205 assert_eq!(jar.snapshot(), vec![("JSESSIONID".into(), "ABC".into())]);
206 }
207
208 #[test]
209 fn cookies_combines_pairs_with_semicolon_separator() {
210 let jar = NameKeyedJar::new();
211 jar.set_pairs(["b=2", "a=1", "c=3"]);
212 let s = jar.cookies(&url()).unwrap().to_str().unwrap().to_owned();
215 for needle in ["a=1", "b=2", "c=3"] {
216 assert!(s.contains(needle), "expected {needle} in {s:?}");
217 }
218 assert_eq!(s.matches("; ").count(), 2);
219 }
220
221 #[test]
222 fn cookies_single_entry_has_no_trailing_separator() {
223 let jar = NameKeyedJar::new();
224 jar.set_pairs(["only=one"]);
225 let header = jar.cookies(&url()).unwrap();
226 assert_eq!(header.to_str().unwrap(), "only=one");
227 }
228
229 #[test]
230 fn empty_jar_returns_none() {
231 let jar = NameKeyedJar::new();
232 assert!(jar.cookies(&url()).is_none());
233 assert!(jar.is_empty());
234 assert_eq!(jar.len(), 0);
235 }
236
237 #[test]
238 fn clear_empties_jar() {
239 let jar = NameKeyedJar::new();
240 jar.set_pairs(["a=1", "b=2"]);
241 assert_eq!(jar.len(), 2);
242 jar.clear();
243 assert!(jar.is_empty());
244 assert!(jar.cookies(&url()).is_none());
245 }
246
247 #[test]
248 fn snapshot_returns_sorted_by_name() {
249 let jar = NameKeyedJar::new();
250 jar.set_pairs(["c=3", "a=1", "b=2"]);
251 assert_eq!(
252 jar.snapshot(),
253 vec![
254 ("a".into(), "1".into()),
255 ("b".into(), "2".into()),
256 ("c".into(), "3".into()),
257 ]
258 );
259 }
260
261 #[test]
262 fn set_cookies_handles_iterator_of_multiple_values() {
263 let jar = NameKeyedJar::new();
264 let h1 = HeaderValue::from_static("a=1; Path=/");
265 let h2 = HeaderValue::from_static("b=2; HttpOnly");
266 let h3 = HeaderValue::from_static("c=3");
267 let mut iter = [&h1, &h2, &h3].into_iter();
268 jar.set_cookies(&mut iter, &url());
269 assert_eq!(jar.len(), 3);
270 }
271
272 #[test]
273 fn set_cookies_skips_non_ascii_header_values() {
274 let jar = NameKeyedJar::new();
275 let bad = HeaderValue::from_bytes(b"name=\xff").unwrap();
277 let good = HeaderValue::from_static("kept=yes");
278 jar.set_cookies(&mut [&bad, &good].into_iter(), &url());
279 assert_eq!(jar.snapshot(), vec![("kept".into(), "yes".into())]);
280 }
281
282 #[test]
283 fn set_pairs_skips_malformed_inputs() {
284 let jar = NameKeyedJar::new();
285 jar.set_pairs([
286 "", " ", "novalue", "=onlyvalue", "name=", "n=a=b", ]);
293 let snap = jar.snapshot();
294 assert!(snap.contains(&("name".into(), String::new())));
297 assert!(snap.contains(&("n".into(), "a=b".into())));
298 assert_eq!(snap.len(), 2);
299 }
300
301 #[test]
302 fn set_pairs_rejects_crlf_injection_in_value() {
303 let jar = NameKeyedJar::new();
304 jar.set_pairs(["X=foo\r\nInjected: yes", "kept=yes"]);
314 assert_eq!(jar.snapshot(), vec![("kept".into(), "yes".into())]);
315 let header = jar.cookies(&url()).unwrap();
316 assert_eq!(header.to_str().unwrap(), "kept=yes");
317 }
318
319 #[test]
320 fn rejects_control_chars_in_name() {
321 let jar = NameKeyedJar::new();
322 jar.set_pairs(["bad\rname=v", "ok=v"]);
323 assert_eq!(jar.snapshot(), vec![("ok".into(), "v".into())]);
324 }
325
326 #[test]
327 fn debug_does_not_leak_cookie_values() {
328 let jar = NameKeyedJar::new();
329 jar.set_pairs(["JSESSIONID=SECRET-VALUE-DO-NOT-LOG"]);
330 let rendered = format!("{jar:?}");
331 assert!(
332 !rendered.contains("SECRET-VALUE"),
333 "Debug must not leak cookie values, got: {rendered}"
334 );
335 assert!(rendered.contains("JSESSIONID"));
336 assert!(rendered.contains("len"));
337 }
338}