we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement cookie jar: Set-Cookie parsing, storage, and document.cookie

Adds a full RFC 6265bis cookie implementation:
- Set-Cookie header parsing with all attributes (Domain, Path, Expires,
Max-Age, Secure, HttpOnly, SameSite)
- CookieJar with domain/path matching, expiry, per-domain and total limits
- Automatic cookie attachment to outgoing HTTP requests and storage from
responses in HttpClient
- document.cookie getter/setter in the JS-DOM bridge (HttpOnly cookies
hidden from JS, JS cannot set HttpOnly cookies)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+1391 -4
+73
crates/js/src/dom_bridge.rs
··· 1136 1136 } 1137 1137 } 1138 1138 1139 + // ── Document-level dynamic properties (e.g. document.cookie) ────── 1140 + 1141 + /// Check whether `gc_ref` is the document object (has nodeType === 9 and 1142 + /// nodeName === "#document"). 1143 + fn is_document_object(gc: &Gc<HeapObject>, gc_ref: GcRef) -> bool { 1144 + if let Some(HeapObject::Object(data)) = gc.get(gc_ref) { 1145 + if let Some(prop) = data.properties.get("nodeType") { 1146 + if let Value::Number(n) = &prop.value { 1147 + if *n == 9.0 { 1148 + return true; 1149 + } 1150 + } 1151 + } 1152 + } 1153 + false 1154 + } 1155 + 1156 + /// Resolve a dynamic property on the `document` object itself (not a node wrapper). 1157 + /// 1158 + /// Currently handles `document.cookie`. 1159 + pub fn resolve_document_get( 1160 + gc: &Gc<HeapObject>, 1161 + bridge: &Rc<DomBridge>, 1162 + gc_ref: GcRef, 1163 + key: &str, 1164 + ) -> Option<Value> { 1165 + if key != "cookie" { 1166 + return None; 1167 + } 1168 + if !is_document_object(gc, gc_ref) { 1169 + return None; 1170 + } 1171 + 1172 + let url = bridge.document_url.borrow(); 1173 + let cookie_str = match url.as_ref() { 1174 + Some(u) => bridge.cookie_jar.borrow_mut().document_cookie_get(u), 1175 + None => String::new(), 1176 + }; 1177 + Some(Value::String(cookie_str)) 1178 + } 1179 + 1180 + /// Handle a property set on the `document` object (e.g. `document.cookie = "..."`)`. 1181 + /// 1182 + /// Returns `true` if the property was intercepted. 1183 + pub fn handle_document_set( 1184 + bridge: &Rc<DomBridge>, 1185 + gc_ref: GcRef, 1186 + key: &str, 1187 + val: &Value, 1188 + gc: &Gc<HeapObject>, 1189 + ) -> bool { 1190 + if key != "cookie" { 1191 + return false; 1192 + } 1193 + if !is_document_object(gc, gc_ref) { 1194 + return false; 1195 + } 1196 + 1197 + let cookie_str = match val { 1198 + Value::String(s) => s.clone(), 1199 + other => other.to_string(), 1200 + }; 1201 + 1202 + let url = bridge.document_url.borrow(); 1203 + if let Some(u) = url.as_ref() { 1204 + bridge 1205 + .cookie_jar 1206 + .borrow_mut() 1207 + .document_cookie_set(&cookie_str, u); 1208 + } 1209 + true 1210 + } 1211 + 1139 1212 /// Handle a DOM property set on a wrapper object. 1140 1213 /// Returns `true` if the key was handled (caller should skip normal property set). 1141 1214 pub fn handle_dom_set(
+38 -2
crates/js/src/vm.rs
··· 240 240 /// The serialized origin of this document (e.g. "https://example.com"). 241 241 /// Used for Same-Origin Policy enforcement on cross-origin DOM access. 242 242 pub origin: RefCell<String>, 243 + /// Cookie jar shared with the network layer for `document.cookie` access. 244 + pub cookie_jar: RefCell<we_net::cookie::CookieJar>, 245 + /// The URL of the current document, used for cookie domain/path matching. 246 + pub document_url: RefCell<Option<we_url::Url>>, 243 247 } 244 248 245 249 /// Context passed to native functions, providing GC access and `this` binding. ··· 844 848 node_wrappers: RefCell::new(HashMap::new()), 845 849 event_listeners: RefCell::new(HashMap::new()), 846 850 origin: RefCell::new(String::new()), 851 + cookie_jar: RefCell::new(we_net::cookie::CookieJar::new()), 852 + document_url: RefCell::new(None), 847 853 }); 848 854 self.dom_bridge = Some(bridge); 849 855 crate::dom_bridge::init_document_object(self); ··· 859 865 if let Some(bridge) = &self.dom_bridge { 860 866 *bridge.origin.borrow_mut() = origin.to_string(); 861 867 } 868 + } 869 + 870 + /// Set the document URL for cookie domain/path matching. 871 + pub fn set_document_url(&mut self, url: we_url::Url) { 872 + if let Some(bridge) = &self.dom_bridge { 873 + *bridge.document_url.borrow_mut() = Some(url); 874 + } 875 + } 876 + 877 + /// Set the cookie jar on the DOM bridge (typically from the HTTP client). 878 + pub fn set_cookie_jar(&mut self, jar: we_net::cookie::CookieJar) { 879 + if let Some(bridge) = &self.dom_bridge { 880 + *bridge.cookie_jar.borrow_mut() = jar; 881 + } 882 + } 883 + 884 + /// Take the cookie jar from the DOM bridge (to return to the HTTP client). 885 + pub fn take_cookie_jar(&mut self) -> Option<we_net::cookie::CookieJar> { 886 + self.dom_bridge 887 + .as_ref() 888 + .map(|bridge| bridge.cookie_jar.replace(we_net::cookie::CookieJar::new())) 862 889 } 863 890 864 891 /// Detach the DOM document from the VM, returning it. ··· 2096 2123 /// Returns `Some(value)` if the key is a recognized DOM property, `None` otherwise. 2097 2124 fn resolve_dom_property(&mut self, gc_ref: GcRef, key: &str) -> Option<Value> { 2098 2125 let bridge = Rc::clone(self.dom_bridge.as_ref()?); 2099 - crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) 2126 + // Try node wrapper properties first. 2127 + if let Some(val) = crate::dom_bridge::resolve_dom_get(&mut self.gc, &bridge, gc_ref, key) { 2128 + return Some(val); 2129 + } 2130 + // Try document-level dynamic properties (e.g. document.cookie). 2131 + crate::dom_bridge::resolve_document_get(&self.gc, &bridge, gc_ref, key) 2100 2132 } 2101 2133 2102 2134 /// Handle a DOM property set on a wrapper object. 2103 2135 /// Returns `true` if the property was handled (caller should skip normal set). 2104 2136 fn handle_dom_property_set(&mut self, gc_ref: GcRef, key: &str, val: &Value) -> bool { 2105 2137 if let Some(bridge) = self.dom_bridge.clone() { 2106 - // Check for style proxy objects first. 2138 + // Check for document-level dynamic properties (e.g. document.cookie). 2139 + if crate::dom_bridge::handle_document_set(&bridge, gc_ref, key, val, &self.gc) { 2140 + return true; 2141 + } 2142 + // Check for style proxy objects. 2107 2143 if crate::dom_bridge::handle_style_set(&mut self.gc, &bridge, gc_ref, key, val) { 2108 2144 return true; 2109 2145 }
+40 -2
crates/net/src/client.rs
··· 10 10 11 11 use we_url::Url; 12 12 13 + use crate::cookie::{CookieJar, RequestContext}; 13 14 use crate::http::{self, Headers, HttpResponse, Method}; 14 15 use crate::tcp::{self, TcpConnection}; 15 16 use crate::tls::handshake::{self, HandshakeError, TlsStream}; ··· 200 201 // HttpClient 201 202 // --------------------------------------------------------------------------- 202 203 203 - /// High-level HTTP/1.1 client with connection pooling and redirect following. 204 + /// High-level HTTP/1.1 client with connection pooling, redirect following, and cookie jar. 204 205 pub struct HttpClient { 205 206 pool: ConnectionPool, 206 207 max_redirects: u32, 207 208 connect_timeout: Duration, 208 209 read_timeout: Duration, 210 + cookie_jar: CookieJar, 209 211 } 210 212 211 213 impl HttpClient { ··· 216 218 max_redirects: DEFAULT_MAX_REDIRECTS, 217 219 connect_timeout: DEFAULT_CONNECT_TIMEOUT, 218 220 read_timeout: DEFAULT_READ_TIMEOUT, 221 + cookie_jar: CookieJar::new(), 219 222 } 223 + } 224 + 225 + /// Get a reference to the cookie jar. 226 + pub fn cookie_jar(&self) -> &CookieJar { 227 + &self.cookie_jar 228 + } 229 + 230 + /// Get a mutable reference to the cookie jar. 231 + pub fn cookie_jar_mut(&mut self) -> &mut CookieJar { 232 + &mut self.cookie_jar 220 233 } 221 234 222 235 /// Set the maximum number of redirects to follow. ··· 311 324 312 325 let path = request_path(url); 313 326 327 + // Build request headers, attaching cookies from the jar. 328 + let mut merged_headers = Headers::new(); 329 + for (name, value) in headers.iter() { 330 + merged_headers.add(name, value); 331 + } 332 + if !merged_headers.contains("Cookie") { 333 + if let Some(cookie_val) = self 334 + .cookie_jar 335 + .cookie_header_value(url, RequestContext::SameSite) 336 + { 337 + merged_headers.add("Cookie", &cookie_val); 338 + } 339 + } 340 + 314 341 let key = ConnectionKey { 315 342 host: host.clone(), 316 343 port, ··· 326 353 conn.set_read_timeout(Some(self.read_timeout))?; 327 354 328 355 // Serialize and send request 329 - let request_bytes = http::serialize_request(method, &path, &host, headers, body); 356 + let request_bytes = http::serialize_request(method, &path, &host, &merged_headers, body); 330 357 conn.write_all(&request_bytes)?; 331 358 conn.flush()?; 332 359 333 360 // Read and parse response 334 361 let response = read_response(&mut conn)?; 362 + 363 + // Store Set-Cookie headers from the response. 364 + let set_cookies: Vec<String> = response 365 + .headers 366 + .get_all("Set-Cookie") 367 + .into_iter() 368 + .map(|s| s.to_string()) 369 + .collect(); 370 + for header in &set_cookies { 371 + self.cookie_jar.store_from_header(header, url); 372 + } 335 373 336 374 // Return connection to pool if keep-alive 337 375 if !response.connection_close() {
+1239
crates/net/src/cookie.rs
··· 1 + //! Cookie jar: parsing, storage, and matching per RFC 6265bis. 2 + //! 3 + //! Implements `Set-Cookie` header parsing, domain/path matching, expiry 4 + //! handling, and cookie attachment to outgoing requests. 5 + 6 + use std::collections::HashMap; 7 + use std::time::{SystemTime, UNIX_EPOCH}; 8 + 9 + use we_url::Url; 10 + 11 + // --------------------------------------------------------------------------- 12 + // Constants 13 + // --------------------------------------------------------------------------- 14 + 15 + /// Maximum cookies per domain. 16 + const MAX_COOKIES_PER_DOMAIN: usize = 50; 17 + /// Maximum total cookies in the jar. 18 + const MAX_TOTAL_COOKIES: usize = 3000; 19 + 20 + // --------------------------------------------------------------------------- 21 + // SameSite 22 + // --------------------------------------------------------------------------- 23 + 24 + /// The SameSite attribute for a cookie. 25 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 26 + pub enum SameSite { 27 + /// Cookie is only sent in first-party (same-site) context. 28 + Strict, 29 + /// Cookie is sent on top-level navigations but not subresource requests. 30 + Lax, 31 + /// Cookie is sent in all contexts (requires Secure). 32 + None, 33 + } 34 + 35 + // --------------------------------------------------------------------------- 36 + // Cookie 37 + // --------------------------------------------------------------------------- 38 + 39 + /// A single HTTP cookie. 40 + #[derive(Debug, Clone)] 41 + pub struct Cookie { 42 + /// Cookie name. 43 + pub name: String, 44 + /// Cookie value. 45 + pub value: String, 46 + /// The domain the cookie applies to (lowercase, no leading dot stored). 47 + pub domain: String, 48 + /// The path the cookie applies to. 49 + pub path: String, 50 + /// Absolute expiry time in seconds since UNIX epoch, or `None` for session cookies. 51 + pub expires: Option<u64>, 52 + /// Whether the cookie should only be sent over HTTPS. 53 + pub secure: bool, 54 + /// Whether the cookie is inaccessible to JavaScript (`document.cookie`). 55 + pub http_only: bool, 56 + /// SameSite attribute. 57 + pub same_site: SameSite, 58 + /// Whether the domain attribute was explicitly set (host-only vs domain cookie). 59 + pub host_only: bool, 60 + /// Creation time in seconds since UNIX epoch. 61 + pub creation_time: u64, 62 + } 63 + 64 + // --------------------------------------------------------------------------- 65 + // RequestContext — describes the type of request for SameSite 66 + // --------------------------------------------------------------------------- 67 + 68 + /// Context for cookie matching — determines SameSite behavior. 69 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 70 + pub enum RequestContext { 71 + /// Top-level navigation (e.g. clicking a link, typing in the address bar). 72 + Navigation, 73 + /// A subresource request (e.g. img, script, fetch). 74 + Subresource, 75 + /// Same-site request (document and target share the same registrable domain). 76 + SameSite, 77 + } 78 + 79 + // --------------------------------------------------------------------------- 80 + // Set-Cookie parsing 81 + // --------------------------------------------------------------------------- 82 + 83 + /// Parse a `Set-Cookie` header value into a `Cookie`. 84 + /// 85 + /// `request_url` is used to derive default domain/path when not specified. 86 + /// Returns `None` if the header is malformed (empty name, etc.). 87 + pub fn parse_set_cookie(header: &str, request_url: &Url) -> Option<Cookie> { 88 + // Split on ';' to get name=value and attributes. 89 + let mut parts = header.split(';'); 90 + let name_value = parts.next()?.trim(); 91 + 92 + // Parse name=value pair. 93 + let (name, value) = if let Some(eq_pos) = name_value.find('=') { 94 + let n = name_value[..eq_pos].trim(); 95 + let v = name_value[eq_pos + 1..].trim(); 96 + // Strip quotes from value if present. 97 + let v = if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') { 98 + &v[1..v.len() - 1] 99 + } else { 100 + v 101 + }; 102 + (n, v) 103 + } else { 104 + // No '=' — treat entire thing as value with empty name per spec. 105 + // But empty name is rejected below. 106 + ("", name_value.trim()) 107 + }; 108 + 109 + // Reject empty names. 110 + if name.is_empty() { 111 + return None; 112 + } 113 + 114 + // Reject names/values with forbidden characters (control chars, semicolons in name). 115 + if name.bytes().any(|b| b < 0x20 || b == 0x7f || b == b';') { 116 + return None; 117 + } 118 + 119 + let now = now_secs(); 120 + let request_host = request_url 121 + .host_str() 122 + .unwrap_or_default() 123 + .to_ascii_lowercase(); 124 + let request_path = request_url.path(); 125 + 126 + let mut domain: Option<String> = None; 127 + let mut path: Option<String> = None; 128 + let mut expires: Option<u64> = None; 129 + let mut max_age: Option<i64> = None; 130 + let mut secure = false; 131 + let mut http_only = false; 132 + let mut same_site = SameSite::Lax; // Default per RFC 6265bis 133 + 134 + // Parse attributes. 135 + for part in parts { 136 + let part = part.trim(); 137 + if part.is_empty() { 138 + continue; 139 + } 140 + 141 + if let Some(eq_pos) = part.find('=') { 142 + let attr_name = part[..eq_pos].trim(); 143 + let attr_value = part[eq_pos + 1..].trim(); 144 + 145 + if attr_name.eq_ignore_ascii_case("Domain") { 146 + let mut d = attr_value.to_ascii_lowercase(); 147 + // Strip leading dot per spec. 148 + if d.starts_with('.') { 149 + d = d[1..].to_string(); 150 + } 151 + if !d.is_empty() { 152 + domain = Some(d); 153 + } 154 + } else if attr_name.eq_ignore_ascii_case("Path") { 155 + if attr_value.starts_with('/') { 156 + path = Some(attr_value.to_string()); 157 + } 158 + } else if attr_name.eq_ignore_ascii_case("Expires") { 159 + if let Some(t) = parse_cookie_date(attr_value) { 160 + expires = Some(t); 161 + } 162 + } else if attr_name.eq_ignore_ascii_case("Max-Age") { 163 + if let Ok(secs) = attr_value.parse::<i64>() { 164 + max_age = Some(secs); 165 + } 166 + } else if attr_name.eq_ignore_ascii_case("SameSite") { 167 + if attr_value.eq_ignore_ascii_case("Strict") { 168 + same_site = SameSite::Strict; 169 + } else if attr_value.eq_ignore_ascii_case("Lax") { 170 + same_site = SameSite::Lax; 171 + } else if attr_value.eq_ignore_ascii_case("None") { 172 + same_site = SameSite::None; 173 + } 174 + } 175 + } else { 176 + // Flag-only attributes. 177 + if part.eq_ignore_ascii_case("Secure") { 178 + secure = true; 179 + } else if part.eq_ignore_ascii_case("HttpOnly") { 180 + http_only = true; 181 + } 182 + } 183 + } 184 + 185 + // SameSite=None requires Secure. 186 + if same_site == SameSite::None && !secure { 187 + same_site = SameSite::Lax; 188 + } 189 + 190 + // Determine effective expiry: Max-Age takes precedence over Expires. 191 + let effective_expires = if let Some(ma) = max_age { 192 + if ma <= 0 { 193 + Some(0) // Expire immediately. 194 + } else { 195 + Some(now.saturating_add(ma as u64)) 196 + } 197 + } else { 198 + expires 199 + }; 200 + 201 + // Determine domain: if not set, use request host (host-only cookie). 202 + let host_only; 203 + let effective_domain = if let Some(d) = domain { 204 + // Domain must domain-match the request host. 205 + if !domain_matches(&request_host, &d) { 206 + return None; // Reject cookie with mismatched domain. 207 + } 208 + host_only = false; 209 + d 210 + } else { 211 + host_only = true; 212 + request_host 213 + }; 214 + 215 + // Determine path: if not set, use the default path from the request URL. 216 + let effective_path = path.unwrap_or_else(|| default_cookie_path(&request_path)); 217 + 218 + Some(Cookie { 219 + name: name.to_string(), 220 + value: value.to_string(), 221 + domain: effective_domain, 222 + path: effective_path, 223 + expires: effective_expires, 224 + secure, 225 + http_only, 226 + same_site, 227 + host_only, 228 + creation_time: now, 229 + }) 230 + } 231 + 232 + /// Compute the default cookie path from a request URI path per RFC 6265bis Section 5.1.4. 233 + fn default_cookie_path(request_path: &str) -> String { 234 + if !request_path.starts_with('/') { 235 + return "/".to_string(); 236 + } 237 + match request_path.rfind('/') { 238 + Some(pos) if pos > 0 => request_path[..pos].to_string(), 239 + _ => "/".to_string(), 240 + } 241 + } 242 + 243 + // --------------------------------------------------------------------------- 244 + // Domain / path matching 245 + // --------------------------------------------------------------------------- 246 + 247 + /// Check if `request_host` domain-matches `cookie_domain` per RFC 6265bis. 248 + /// 249 + /// `domain_matches("sub.example.com", "example.com")` → true 250 + /// `domain_matches("example.com", "example.com")` → true 251 + /// `domain_matches("notexample.com", "example.com")` → false 252 + pub fn domain_matches(request_host: &str, cookie_domain: &str) -> bool { 253 + let rh = request_host.to_ascii_lowercase(); 254 + let cd = cookie_domain.to_ascii_lowercase(); 255 + 256 + if rh == cd { 257 + return true; 258 + } 259 + 260 + // request_host must end with "."+cookie_domain 261 + if rh.len() > cd.len() { 262 + let suffix_start = rh.len() - cd.len(); 263 + if rh[suffix_start..] == cd && rh.as_bytes()[suffix_start - 1] == b'.' { 264 + // cookie_domain must not be an IP address 265 + if cd.parse::<std::net::Ipv4Addr>().is_ok() { 266 + return false; 267 + } 268 + return true; 269 + } 270 + } 271 + 272 + false 273 + } 274 + 275 + /// Check if `request_path` path-matches `cookie_path` per RFC 6265bis. 276 + /// 277 + /// `/foo` matches `/foo`, `/foo/bar`, `/foo/` but NOT `/foobar`. 278 + pub fn path_matches(request_path: &str, cookie_path: &str) -> bool { 279 + if request_path == cookie_path { 280 + return true; 281 + } 282 + 283 + if request_path.starts_with(cookie_path) { 284 + // cookie_path ends with '/' — any subpath matches. 285 + if cookie_path.ends_with('/') { 286 + return true; 287 + } 288 + // The next char in request_path after the cookie_path must be '/'. 289 + if request_path.as_bytes().get(cookie_path.len()) == Some(&b'/') { 290 + return true; 291 + } 292 + } 293 + 294 + false 295 + } 296 + 297 + // --------------------------------------------------------------------------- 298 + // Cookie date parsing (simplified) 299 + // --------------------------------------------------------------------------- 300 + 301 + /// Parse a cookie date string (RFC 6265bis Section 5.1.1). 302 + /// 303 + /// Supports common formats: 304 + /// - `Thu, 01 Dec 2025 00:00:00 GMT` 305 + /// - `Thu, 01-Dec-2025 00:00:00 GMT` 306 + /// - `01 Dec 2025 00:00:00` 307 + fn parse_cookie_date(input: &str) -> Option<u64> { 308 + // Tokenize: split on delimiters (any non-alphanumeric except ':'). 309 + let tokens: Vec<&str> = input 310 + .split(|c: char| !c.is_alphanumeric() && c != ':') 311 + .filter(|t| !t.is_empty()) 312 + .collect(); 313 + 314 + let mut hour: Option<u32> = None; 315 + let mut minute: Option<u32> = None; 316 + let mut second: Option<u32> = None; 317 + let mut day: Option<u32> = None; 318 + let mut month: Option<u32> = None; 319 + let mut year: Option<i64> = None; 320 + 321 + for token in &tokens { 322 + // Try time HH:MM:SS 323 + if hour.is_none() && token.contains(':') { 324 + let time_parts: Vec<&str> = token.split(':').collect(); 325 + if time_parts.len() >= 3 { 326 + if let (Ok(h), Ok(m), Ok(s)) = ( 327 + time_parts[0].parse::<u32>(), 328 + time_parts[1].parse::<u32>(), 329 + time_parts[2].parse::<u32>(), 330 + ) { 331 + if h <= 23 && m <= 59 && s <= 59 { 332 + hour = Some(h); 333 + minute = Some(m); 334 + second = Some(s); 335 + continue; 336 + } 337 + } 338 + } 339 + } 340 + 341 + // Try month name 342 + if month.is_none() { 343 + if let Some(m) = parse_month(token) { 344 + month = Some(m); 345 + continue; 346 + } 347 + } 348 + 349 + // Try year (4 digits) or day (1-2 digits) 350 + if let Ok(num) = token.parse::<i64>() { 351 + if year.is_none() && (num >= 70 || token.len() >= 4) { 352 + let y = if (0..=69).contains(&num) { 353 + num + 2000 354 + } else if (70..=99).contains(&num) { 355 + num + 1900 356 + } else { 357 + num 358 + }; 359 + year = Some(y); 360 + continue; 361 + } 362 + if day.is_none() && (1..=31).contains(&num) { 363 + day = Some(num as u32); 364 + continue; 365 + } 366 + if year.is_none() { 367 + let y = if (0..=69).contains(&num) { 368 + num + 2000 369 + } else if (70..=99).contains(&num) { 370 + num + 1900 371 + } else { 372 + num 373 + }; 374 + year = Some(y); 375 + continue; 376 + } 377 + } 378 + } 379 + 380 + let year = year?; 381 + let month = month?; 382 + let day = day?; 383 + let hour = hour.unwrap_or(0); 384 + let minute = minute.unwrap_or(0); 385 + let second = second.unwrap_or(0); 386 + 387 + if year < 1601 || !(1..=31).contains(&day) { 388 + return None; 389 + } 390 + 391 + // Convert to seconds since UNIX epoch (simplified, no leap seconds). 392 + date_to_epoch(year, month, day, hour, minute, second) 393 + } 394 + 395 + fn parse_month(s: &str) -> Option<u32> { 396 + let lower = s.to_ascii_lowercase(); 397 + match lower.get(..3)? { 398 + "jan" => Some(1), 399 + "feb" => Some(2), 400 + "mar" => Some(3), 401 + "apr" => Some(4), 402 + "may" => Some(5), 403 + "jun" => Some(6), 404 + "jul" => Some(7), 405 + "aug" => Some(8), 406 + "sep" => Some(9), 407 + "oct" => Some(10), 408 + "nov" => Some(11), 409 + "dec" => Some(12), 410 + _ => None, 411 + } 412 + } 413 + 414 + fn date_to_epoch(year: i64, month: u32, day: u32, hour: u32, min: u32, sec: u32) -> Option<u64> { 415 + // Days in each month (non-leap year). 416 + let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; 417 + 418 + if !(1..=12).contains(&month) { 419 + return None; 420 + } 421 + 422 + let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); 423 + let max_day = if month == 2 && is_leap { 424 + 29 425 + } else { 426 + days_in_month[month as usize] 427 + }; 428 + if day > max_day { 429 + return None; 430 + } 431 + 432 + // Days from epoch (1970-01-01) to the start of `year`. 433 + let mut total_days: i64 = 0; 434 + if year >= 1970 { 435 + for y in 1970..year { 436 + total_days += if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) { 437 + 366 438 + } else { 439 + 365 440 + }; 441 + } 442 + } else { 443 + for y in year..1970 { 444 + total_days -= if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) { 445 + 366 446 + } else { 447 + 365 448 + }; 449 + } 450 + } 451 + 452 + // Add days for months in current year. 453 + for m in 1..month { 454 + total_days += days_in_month[m as usize] as i64; 455 + if m == 2 && is_leap { 456 + total_days += 1; 457 + } 458 + } 459 + total_days += (day - 1) as i64; 460 + 461 + let total_secs = total_days * 86400 + hour as i64 * 3600 + min as i64 * 60 + sec as i64; 462 + if total_secs < 0 { 463 + return None; 464 + } 465 + Some(total_secs as u64) 466 + } 467 + 468 + // --------------------------------------------------------------------------- 469 + // CookieJar 470 + // --------------------------------------------------------------------------- 471 + 472 + /// A cookie jar that stores cookies and selects them for outgoing requests. 473 + #[derive(Debug)] 474 + pub struct CookieJar { 475 + /// Cookies keyed by domain for fast lookup. 476 + cookies: HashMap<String, Vec<Cookie>>, 477 + /// Total cookie count across all domains. 478 + total_count: usize, 479 + } 480 + 481 + impl CookieJar { 482 + /// Create an empty cookie jar. 483 + pub fn new() -> Self { 484 + Self { 485 + cookies: HashMap::new(), 486 + total_count: 0, 487 + } 488 + } 489 + 490 + /// Store a cookie from a `Set-Cookie` header. 491 + /// 492 + /// Parses the header, replaces any existing cookie with the same 493 + /// (domain, path, name) tuple, and enforces per-domain and total limits. 494 + pub fn store_from_header(&mut self, header: &str, request_url: &Url) { 495 + if let Some(cookie) = parse_set_cookie(header, request_url) { 496 + self.store(cookie); 497 + } 498 + } 499 + 500 + /// Store a cookie directly. 501 + pub fn store(&mut self, cookie: Cookie) { 502 + // If expiry is in the past (or zero), remove the cookie instead. 503 + if let Some(exp) = cookie.expires { 504 + if exp <= now_secs() { 505 + self.remove(&cookie.domain, &cookie.path, &cookie.name); 506 + return; 507 + } 508 + } 509 + 510 + let domain = cookie.domain.clone(); 511 + 512 + // Check if we're replacing an existing cookie. 513 + let is_replace = self 514 + .cookies 515 + .get(&domain) 516 + .map(|entries| { 517 + entries 518 + .iter() 519 + .any(|c| c.name == cookie.name && c.path == cookie.path) 520 + }) 521 + .unwrap_or(false); 522 + 523 + if is_replace { 524 + let entries = self.cookies.get_mut(&domain).unwrap(); 525 + let pos = entries 526 + .iter() 527 + .position(|c| c.name == cookie.name && c.path == cookie.path) 528 + .unwrap(); 529 + entries[pos] = cookie; 530 + } else { 531 + // Enforce per-domain limit: evict oldest if at capacity. 532 + if let Some(entries) = self.cookies.get_mut(&domain) { 533 + if entries.len() >= MAX_COOKIES_PER_DOMAIN { 534 + if let Some(oldest_pos) = entries 535 + .iter() 536 + .enumerate() 537 + .min_by_key(|(_, c)| c.creation_time) 538 + .map(|(i, _)| i) 539 + { 540 + entries.remove(oldest_pos); 541 + self.total_count -= 1; 542 + } 543 + } 544 + } 545 + 546 + // Enforce total limit. 547 + if self.total_count >= MAX_TOTAL_COOKIES { 548 + self.evict_oldest_global(); 549 + } 550 + 551 + self.cookies.entry(domain).or_default().push(cookie); 552 + self.total_count += 1; 553 + } 554 + } 555 + 556 + /// Remove a specific cookie by (domain, path, name). 557 + pub fn remove(&mut self, domain: &str, path: &str, name: &str) { 558 + if let Some(entries) = self.cookies.get_mut(domain) { 559 + let before = entries.len(); 560 + entries.retain(|c| !(c.name == name && c.path == path)); 561 + let removed = before - entries.len(); 562 + self.total_count -= removed; 563 + if entries.is_empty() { 564 + self.cookies.remove(domain); 565 + } 566 + } 567 + } 568 + 569 + /// Select cookies matching the given request URL and context. 570 + /// 571 + /// Returns cookies sorted by path length (longest first), then by 572 + /// creation time (earliest first) — per RFC 6265bis. 573 + pub fn cookies_for_request(&mut self, url: &Url, context: RequestContext) -> Vec<&Cookie> { 574 + let now = now_secs(); 575 + let is_secure = url.scheme() == "https"; 576 + let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); 577 + let path = url.path(); 578 + let path = if path.is_empty() { "/" } else { &path }; 579 + 580 + // Remove expired cookies first. 581 + self.remove_expired(now); 582 + 583 + let mut result = Vec::new(); 584 + 585 + for (domain, entries) in &self.cookies { 586 + for cookie in entries { 587 + // Domain matching. 588 + if cookie.host_only { 589 + if host != cookie.domain { 590 + continue; 591 + } 592 + } else if !domain_matches(&host, domain) { 593 + continue; 594 + } 595 + 596 + // Path matching. 597 + if !path_matches(path, &cookie.path) { 598 + continue; 599 + } 600 + 601 + // Secure flag. 602 + if cookie.secure && !is_secure { 603 + continue; 604 + } 605 + 606 + // SameSite. 607 + match cookie.same_site { 608 + SameSite::Strict => { 609 + if context != RequestContext::SameSite { 610 + continue; 611 + } 612 + } 613 + SameSite::Lax => { 614 + if context == RequestContext::Subresource { 615 + continue; 616 + } 617 + } 618 + SameSite::None => { 619 + // SameSite=None requires Secure, already enforced at parse time. 620 + } 621 + } 622 + 623 + result.push(cookie); 624 + } 625 + } 626 + 627 + // Sort: longest path first, then earliest creation time. 628 + result.sort_by(|a, b| { 629 + b.path 630 + .len() 631 + .cmp(&a.path.len()) 632 + .then(a.creation_time.cmp(&b.creation_time)) 633 + }); 634 + 635 + result 636 + } 637 + 638 + /// Serialize matching cookies into a `Cookie` header value. 639 + pub fn cookie_header_value(&mut self, url: &Url, context: RequestContext) -> Option<String> { 640 + let cookies = self.cookies_for_request(url, context); 641 + if cookies.is_empty() { 642 + return None; 643 + } 644 + let pairs: Vec<String> = cookies 645 + .iter() 646 + .map(|c| format!("{}={}", c.name, c.value)) 647 + .collect(); 648 + Some(pairs.join("; ")) 649 + } 650 + 651 + /// Get non-HttpOnly cookies for `document.cookie` (JS getter). 652 + pub fn document_cookie_get(&mut self, url: &Url) -> String { 653 + let cookies = self.cookies_for_request(url, RequestContext::SameSite); 654 + let pairs: Vec<String> = cookies 655 + .iter() 656 + .filter(|c| !c.http_only) 657 + .map(|c| format!("{}={}", c.name, c.value)) 658 + .collect(); 659 + pairs.join("; ") 660 + } 661 + 662 + /// Parse and store a cookie from `document.cookie` (JS setter). 663 + /// 664 + /// HttpOnly cookies cannot be set via JS. Returns true if the cookie was stored. 665 + pub fn document_cookie_set(&mut self, cookie_str: &str, url: &Url) -> bool { 666 + if let Some(cookie) = parse_set_cookie(cookie_str, url) { 667 + // document.cookie cannot set HttpOnly cookies. 668 + if cookie.http_only { 669 + return false; 670 + } 671 + self.store(cookie); 672 + true 673 + } else { 674 + false 675 + } 676 + } 677 + 678 + /// Remove all expired cookies. 679 + fn remove_expired(&mut self, now: u64) { 680 + let mut empty_domains = Vec::new(); 681 + 682 + for (domain, entries) in &mut self.cookies { 683 + let before = entries.len(); 684 + entries.retain(|c| match c.expires { 685 + Some(exp) => exp > now, 686 + None => true, // Session cookies don't expire by time. 687 + }); 688 + let removed = before - entries.len(); 689 + self.total_count -= removed; 690 + if entries.is_empty() { 691 + empty_domains.push(domain.clone()); 692 + } 693 + } 694 + 695 + for domain in empty_domains { 696 + self.cookies.remove(&domain); 697 + } 698 + } 699 + 700 + /// Evict the globally oldest cookie to make room. 701 + fn evict_oldest_global(&mut self) { 702 + let mut oldest: Option<(String, usize, u64)> = None; 703 + 704 + for (domain, entries) in &self.cookies { 705 + for (i, cookie) in entries.iter().enumerate() { 706 + match oldest { 707 + None => oldest = Some((domain.clone(), i, cookie.creation_time)), 708 + Some((_, _, t)) if cookie.creation_time < t => { 709 + oldest = Some((domain.clone(), i, cookie.creation_time)); 710 + } 711 + _ => {} 712 + } 713 + } 714 + } 715 + 716 + if let Some((domain, idx, _)) = oldest { 717 + if let Some(entries) = self.cookies.get_mut(&domain) { 718 + entries.remove(idx); 719 + self.total_count -= 1; 720 + if entries.is_empty() { 721 + self.cookies.remove(&domain); 722 + } 723 + } 724 + } 725 + } 726 + 727 + /// Process `Set-Cookie` headers from an HTTP response and store cookies. 728 + pub fn store_from_response_headers(&mut self, set_cookie_headers: &[&str], request_url: &Url) { 729 + for header in set_cookie_headers { 730 + self.store_from_header(header, request_url); 731 + } 732 + } 733 + } 734 + 735 + impl Default for CookieJar { 736 + fn default() -> Self { 737 + Self::new() 738 + } 739 + } 740 + 741 + // --------------------------------------------------------------------------- 742 + // Time helpers 743 + // --------------------------------------------------------------------------- 744 + 745 + fn now_secs() -> u64 { 746 + SystemTime::now() 747 + .duration_since(UNIX_EPOCH) 748 + .unwrap_or_default() 749 + .as_secs() 750 + } 751 + 752 + // --------------------------------------------------------------------------- 753 + // Tests 754 + // --------------------------------------------------------------------------- 755 + 756 + #[cfg(test)] 757 + mod tests { 758 + use super::*; 759 + 760 + fn test_url(s: &str) -> Url { 761 + Url::parse(s).unwrap() 762 + } 763 + 764 + // -- parse_set_cookie tests -- 765 + 766 + #[test] 767 + fn parse_basic_cookie() { 768 + let url = test_url("https://example.com/path"); 769 + let cookie = parse_set_cookie("name=value", &url).unwrap(); 770 + assert_eq!(cookie.name, "name"); 771 + assert_eq!(cookie.value, "value"); 772 + assert_eq!(cookie.domain, "example.com"); 773 + assert_eq!(cookie.path, "/"); 774 + assert!(cookie.host_only); 775 + assert!(!cookie.secure); 776 + assert!(!cookie.http_only); 777 + assert_eq!(cookie.same_site, SameSite::Lax); 778 + assert!(cookie.expires.is_none()); 779 + } 780 + 781 + #[test] 782 + fn parse_cookie_with_all_attributes() { 783 + let url = test_url("https://example.com/foo/bar"); 784 + let header = "id=abc123; Domain=example.com; Path=/foo; Secure; HttpOnly; SameSite=Strict; Max-Age=3600"; 785 + let cookie = parse_set_cookie(header, &url).unwrap(); 786 + assert_eq!(cookie.name, "id"); 787 + assert_eq!(cookie.value, "abc123"); 788 + assert_eq!(cookie.domain, "example.com"); 789 + assert_eq!(cookie.path, "/foo"); 790 + assert!(cookie.secure); 791 + assert!(cookie.http_only); 792 + assert_eq!(cookie.same_site, SameSite::Strict); 793 + assert!(!cookie.host_only); 794 + assert!(cookie.expires.is_some()); 795 + } 796 + 797 + #[test] 798 + fn parse_cookie_empty_name_rejected() { 799 + let url = test_url("https://example.com/"); 800 + assert!(parse_set_cookie("=value", &url).is_none()); 801 + } 802 + 803 + #[test] 804 + fn parse_cookie_domain_mismatch_rejected() { 805 + let url = test_url("https://example.com/"); 806 + assert!(parse_set_cookie("name=val; Domain=evil.com", &url).is_none()); 807 + } 808 + 809 + #[test] 810 + fn parse_cookie_subdomain_domain() { 811 + let url = test_url("https://sub.example.com/"); 812 + let cookie = parse_set_cookie("name=val; Domain=example.com", &url).unwrap(); 813 + assert_eq!(cookie.domain, "example.com"); 814 + assert!(!cookie.host_only); 815 + } 816 + 817 + #[test] 818 + fn parse_cookie_leading_dot_stripped() { 819 + let url = test_url("https://sub.example.com/"); 820 + let cookie = parse_set_cookie("name=val; Domain=.example.com", &url).unwrap(); 821 + assert_eq!(cookie.domain, "example.com"); 822 + } 823 + 824 + #[test] 825 + fn parse_cookie_max_age_zero_means_delete() { 826 + let url = test_url("https://example.com/"); 827 + let cookie = parse_set_cookie("name=val; Max-Age=0", &url).unwrap(); 828 + assert_eq!(cookie.expires, Some(0)); 829 + } 830 + 831 + #[test] 832 + fn parse_cookie_max_age_overrides_expires() { 833 + let url = test_url("https://example.com/"); 834 + let header = "name=val; Expires=Thu, 01 Dec 2050 00:00:00 GMT; Max-Age=60"; 835 + let cookie = parse_set_cookie(header, &url).unwrap(); 836 + // Max-Age should produce an expiry close to now+60, not 2050. 837 + let now = now_secs(); 838 + let exp = cookie.expires.unwrap(); 839 + assert!(exp >= now && exp <= now + 120); 840 + } 841 + 842 + #[test] 843 + fn parse_cookie_samesite_none_without_secure() { 844 + let url = test_url("https://example.com/"); 845 + let cookie = parse_set_cookie("name=val; SameSite=None", &url).unwrap(); 846 + // SameSite=None without Secure should fall back to Lax. 847 + assert_eq!(cookie.same_site, SameSite::Lax); 848 + } 849 + 850 + #[test] 851 + fn parse_cookie_quoted_value() { 852 + let url = test_url("https://example.com/"); 853 + let cookie = parse_set_cookie("name=\"hello world\"", &url).unwrap(); 854 + assert_eq!(cookie.value, "hello world"); 855 + } 856 + 857 + #[test] 858 + fn parse_cookie_default_path() { 859 + let url = test_url("https://example.com/a/b/c"); 860 + let cookie = parse_set_cookie("name=val", &url).unwrap(); 861 + assert_eq!(cookie.path, "/a/b"); 862 + } 863 + 864 + #[test] 865 + fn parse_cookie_default_path_root() { 866 + let url = test_url("https://example.com/"); 867 + let cookie = parse_set_cookie("name=val", &url).unwrap(); 868 + assert_eq!(cookie.path, "/"); 869 + } 870 + 871 + #[test] 872 + fn parse_cookie_case_insensitive_attributes() { 873 + let url = test_url("https://example.com/"); 874 + let cookie = parse_set_cookie("name=val; SECURE; HTTPONLY; SAMESITE=STRICT", &url).unwrap(); 875 + assert!(cookie.secure); 876 + assert!(cookie.http_only); 877 + assert_eq!(cookie.same_site, SameSite::Strict); 878 + } 879 + 880 + // -- domain_matches tests -- 881 + 882 + #[test] 883 + fn domain_matches_exact() { 884 + assert!(domain_matches("example.com", "example.com")); 885 + } 886 + 887 + #[test] 888 + fn domain_matches_subdomain() { 889 + assert!(domain_matches("sub.example.com", "example.com")); 890 + } 891 + 892 + #[test] 893 + fn domain_matches_deep_subdomain() { 894 + assert!(domain_matches("a.b.c.example.com", "example.com")); 895 + } 896 + 897 + #[test] 898 + fn domain_no_match_prefix() { 899 + assert!(!domain_matches("notexample.com", "example.com")); 900 + } 901 + 902 + #[test] 903 + fn domain_no_match_different() { 904 + assert!(!domain_matches("evil.com", "example.com")); 905 + } 906 + 907 + #[test] 908 + fn domain_no_match_ip() { 909 + assert!(!domain_matches("1.192.168.1.1", "192.168.1.1")); 910 + } 911 + 912 + // -- path_matches tests -- 913 + 914 + #[test] 915 + fn path_matches_exact() { 916 + assert!(path_matches("/foo", "/foo")); 917 + } 918 + 919 + #[test] 920 + fn path_matches_subpath() { 921 + assert!(path_matches("/foo/bar", "/foo")); 922 + } 923 + 924 + #[test] 925 + fn path_matches_with_trailing_slash() { 926 + assert!(path_matches("/foo/bar", "/foo/")); 927 + } 928 + 929 + #[test] 930 + fn path_no_match_prefix() { 931 + assert!(!path_matches("/foobar", "/foo")); 932 + } 933 + 934 + #[test] 935 + fn path_matches_root() { 936 + assert!(path_matches("/anything", "/")); 937 + } 938 + 939 + // -- CookieJar tests -- 940 + 941 + #[test] 942 + fn jar_store_and_retrieve() { 943 + let mut jar = CookieJar::new(); 944 + let url = test_url("https://example.com/path"); 945 + jar.store_from_header("session=abc", &url); 946 + 947 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 948 + assert_eq!(cookies.len(), 1); 949 + assert_eq!(cookies[0].name, "session"); 950 + assert_eq!(cookies[0].value, "abc"); 951 + } 952 + 953 + #[test] 954 + fn jar_replace_existing_cookie() { 955 + let mut jar = CookieJar::new(); 956 + let url = test_url("https://example.com/"); 957 + jar.store_from_header("name=old", &url); 958 + jar.store_from_header("name=new", &url); 959 + 960 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 961 + assert_eq!(cookies.len(), 1); 962 + assert_eq!(cookies[0].value, "new"); 963 + } 964 + 965 + #[test] 966 + fn jar_expired_cookies_not_returned() { 967 + let mut jar = CookieJar::new(); 968 + let url = test_url("https://example.com/"); 969 + jar.store_from_header("name=val; Max-Age=0", &url); 970 + 971 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 972 + assert_eq!(cookies.len(), 0); 973 + } 974 + 975 + #[test] 976 + fn jar_secure_cookie_not_on_http() { 977 + let mut jar = CookieJar::new(); 978 + let https_url = test_url("https://example.com/"); 979 + jar.store_from_header("name=val; Secure", &https_url); 980 + 981 + let http_url = test_url("http://example.com/"); 982 + let cookies = jar.cookies_for_request(&http_url, RequestContext::SameSite); 983 + assert_eq!(cookies.len(), 0); 984 + } 985 + 986 + #[test] 987 + fn jar_secure_cookie_on_https() { 988 + let mut jar = CookieJar::new(); 989 + let url = test_url("https://example.com/"); 990 + jar.store_from_header("name=val; Secure", &url); 991 + 992 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 993 + assert_eq!(cookies.len(), 1); 994 + } 995 + 996 + #[test] 997 + fn jar_host_only_cookie() { 998 + let mut jar = CookieJar::new(); 999 + let url = test_url("https://example.com/"); 1000 + jar.store_from_header("name=val", &url); 1001 + 1002 + // Should NOT match subdomain. 1003 + let sub_url = test_url("https://sub.example.com/"); 1004 + let cookies = jar.cookies_for_request(&sub_url, RequestContext::SameSite); 1005 + assert_eq!(cookies.len(), 0); 1006 + } 1007 + 1008 + #[test] 1009 + fn jar_domain_cookie_matches_subdomain() { 1010 + let mut jar = CookieJar::new(); 1011 + let url = test_url("https://example.com/"); 1012 + jar.store_from_header("name=val; Domain=example.com", &url); 1013 + 1014 + let sub_url = test_url("https://sub.example.com/"); 1015 + let cookies = jar.cookies_for_request(&sub_url, RequestContext::SameSite); 1016 + assert_eq!(cookies.len(), 1); 1017 + } 1018 + 1019 + #[test] 1020 + fn jar_path_matching() { 1021 + let mut jar = CookieJar::new(); 1022 + let url = test_url("https://example.com/foo/bar"); 1023 + jar.store_from_header("name=val; Path=/foo", &url); 1024 + 1025 + let match_url = test_url("https://example.com/foo/baz"); 1026 + let no_match_url = test_url("https://example.com/bar"); 1027 + 1028 + assert_eq!( 1029 + jar.cookies_for_request(&match_url, RequestContext::SameSite) 1030 + .len(), 1031 + 1 1032 + ); 1033 + assert_eq!( 1034 + jar.cookies_for_request(&no_match_url, RequestContext::SameSite) 1035 + .len(), 1036 + 0 1037 + ); 1038 + } 1039 + 1040 + #[test] 1041 + fn jar_cookie_header_value() { 1042 + let mut jar = CookieJar::new(); 1043 + let url = test_url("https://example.com/foo"); 1044 + jar.store_from_header("a=1; Path=/", &url); 1045 + jar.store_from_header("b=2; Path=/foo", &url); 1046 + 1047 + let header = jar 1048 + .cookie_header_value(&url, RequestContext::SameSite) 1049 + .unwrap(); 1050 + // /foo is longer than /, so b=2 comes first. 1051 + assert!(header.starts_with("b=2")); 1052 + assert!(header.contains("a=1")); 1053 + } 1054 + 1055 + #[test] 1056 + fn jar_samesite_strict_blocks_cross_site() { 1057 + let mut jar = CookieJar::new(); 1058 + let url = test_url("https://example.com/"); 1059 + jar.store_from_header("name=val; SameSite=Strict", &url); 1060 + 1061 + // Strict should not be sent on navigation from another site. 1062 + let cookies = jar.cookies_for_request(&url, RequestContext::Navigation); 1063 + assert_eq!(cookies.len(), 0); 1064 + 1065 + // But should be sent on same-site. 1066 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 1067 + assert_eq!(cookies.len(), 1); 1068 + } 1069 + 1070 + #[test] 1071 + fn jar_samesite_lax_allows_navigation() { 1072 + let mut jar = CookieJar::new(); 1073 + let url = test_url("https://example.com/"); 1074 + jar.store_from_header("name=val; SameSite=Lax", &url); 1075 + 1076 + // Lax sends on navigation. 1077 + let cookies = jar.cookies_for_request(&url, RequestContext::Navigation); 1078 + assert_eq!(cookies.len(), 1); 1079 + 1080 + // But NOT on subresource. 1081 + let cookies = jar.cookies_for_request(&url, RequestContext::Subresource); 1082 + assert_eq!(cookies.len(), 0); 1083 + } 1084 + 1085 + #[test] 1086 + fn jar_samesite_none_requires_secure_and_sends_everywhere() { 1087 + let mut jar = CookieJar::new(); 1088 + let url = test_url("https://example.com/"); 1089 + jar.store_from_header("name=val; SameSite=None; Secure", &url); 1090 + 1091 + let cookies = jar.cookies_for_request(&url, RequestContext::Subresource); 1092 + assert_eq!(cookies.len(), 1); 1093 + } 1094 + 1095 + #[test] 1096 + fn jar_httponly_hidden_from_document_cookie() { 1097 + let mut jar = CookieJar::new(); 1098 + let url = test_url("https://example.com/"); 1099 + jar.store_from_header("secret=hidden; HttpOnly", &url); 1100 + jar.store_from_header("visible=yes", &url); 1101 + 1102 + let doc_cookies = jar.document_cookie_get(&url); 1103 + assert!(!doc_cookies.contains("secret")); 1104 + assert!(doc_cookies.contains("visible=yes")); 1105 + } 1106 + 1107 + #[test] 1108 + fn jar_document_cookie_set() { 1109 + let mut jar = CookieJar::new(); 1110 + let url = test_url("https://example.com/"); 1111 + assert!(jar.document_cookie_set("name=value", &url)); 1112 + 1113 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 1114 + assert_eq!(cookies.len(), 1); 1115 + } 1116 + 1117 + #[test] 1118 + fn jar_document_cookie_set_httponly_rejected() { 1119 + let mut jar = CookieJar::new(); 1120 + let url = test_url("https://example.com/"); 1121 + assert!(!jar.document_cookie_set("name=val; HttpOnly", &url)); 1122 + } 1123 + 1124 + #[test] 1125 + fn jar_per_domain_limit() { 1126 + let mut jar = CookieJar::new(); 1127 + let url = test_url("https://example.com/"); 1128 + 1129 + // Store MAX_COOKIES_PER_DOMAIN + 1 cookies. 1130 + for i in 0..=MAX_COOKIES_PER_DOMAIN { 1131 + jar.store_from_header(&format!("cookie{i}=val{i}"), &url); 1132 + } 1133 + 1134 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 1135 + assert_eq!(cookies.len(), MAX_COOKIES_PER_DOMAIN); 1136 + } 1137 + 1138 + #[test] 1139 + fn jar_remove_cookie() { 1140 + let mut jar = CookieJar::new(); 1141 + let url = test_url("https://example.com/"); 1142 + jar.store_from_header("name=val", &url); 1143 + jar.remove("example.com", "/", "name"); 1144 + 1145 + let cookies = jar.cookies_for_request(&url, RequestContext::SameSite); 1146 + assert_eq!(cookies.len(), 0); 1147 + } 1148 + 1149 + #[test] 1150 + fn jar_multiple_domains() { 1151 + let mut jar = CookieJar::new(); 1152 + let url1 = test_url("https://a.com/"); 1153 + let url2 = test_url("https://b.com/"); 1154 + jar.store_from_header("name=a", &url1); 1155 + jar.store_from_header("name=b", &url2); 1156 + 1157 + let cookies1 = jar.cookies_for_request(&url1, RequestContext::SameSite); 1158 + assert_eq!(cookies1.len(), 1); 1159 + assert_eq!(cookies1[0].value, "a"); 1160 + 1161 + let cookies2 = jar.cookies_for_request(&url2, RequestContext::SameSite); 1162 + assert_eq!(cookies2.len(), 1); 1163 + assert_eq!(cookies2[0].value, "b"); 1164 + } 1165 + 1166 + // -- Cookie date parsing tests -- 1167 + 1168 + #[test] 1169 + fn parse_cookie_date_rfc1123() { 1170 + let t = parse_cookie_date("Thu, 01 Dec 2025 00:00:00 GMT").unwrap(); 1171 + // 2025-12-01 00:00:00 UTC 1172 + assert!(t > 0); 1173 + } 1174 + 1175 + #[test] 1176 + fn parse_cookie_date_rfc850() { 1177 + let t = parse_cookie_date("Thursday, 01-Dec-25 00:00:00 GMT").unwrap(); 1178 + assert!(t > 0); 1179 + } 1180 + 1181 + #[test] 1182 + fn parse_cookie_date_asctime() { 1183 + let t = parse_cookie_date("Dec 1 00:00:00 2025").unwrap(); 1184 + assert!(t > 0); 1185 + } 1186 + 1187 + // -- default_cookie_path tests -- 1188 + 1189 + #[test] 1190 + fn default_path_with_subpath() { 1191 + assert_eq!(default_cookie_path("/a/b/c"), "/a/b"); 1192 + } 1193 + 1194 + #[test] 1195 + fn default_path_root() { 1196 + assert_eq!(default_cookie_path("/"), "/"); 1197 + } 1198 + 1199 + #[test] 1200 + fn default_path_empty() { 1201 + assert_eq!(default_cookie_path(""), "/"); 1202 + } 1203 + 1204 + #[test] 1205 + fn default_path_single_segment() { 1206 + assert_eq!(default_cookie_path("/foo"), "/"); 1207 + } 1208 + 1209 + // -- date_to_epoch tests -- 1210 + 1211 + #[test] 1212 + fn epoch_1970() { 1213 + assert_eq!(date_to_epoch(1970, 1, 1, 0, 0, 0), Some(0)); 1214 + } 1215 + 1216 + #[test] 1217 + fn epoch_2000() { 1218 + // 2000-01-01 00:00:00 = 946684800 1219 + assert_eq!(date_to_epoch(2000, 1, 1, 0, 0, 0), Some(946684800)); 1220 + } 1221 + 1222 + #[test] 1223 + fn epoch_invalid_month() { 1224 + assert_eq!(date_to_epoch(2020, 13, 1, 0, 0, 0), None); 1225 + } 1226 + 1227 + #[test] 1228 + fn epoch_invalid_day() { 1229 + assert_eq!(date_to_epoch(2020, 2, 30, 0, 0, 0), None); 1230 + } 1231 + 1232 + #[test] 1233 + fn epoch_leap_year() { 1234 + // 2020 is a leap year, Feb 29 should be valid. 1235 + assert!(date_to_epoch(2020, 2, 29, 0, 0, 0).is_some()); 1236 + // 2021 is not, Feb 29 should be invalid. 1237 + assert!(date_to_epoch(2021, 2, 29, 0, 0, 0).is_none()); 1238 + } 1239 + }
+1
crates/net/src/lib.rs
··· 1 1 //! TCP, DNS, pure-Rust TLS 1.3, HTTP/1.1, HTTP/2, CORS. 2 2 3 3 pub mod client; 4 + pub mod cookie; 4 5 pub mod cors; 5 6 pub mod dns; 6 7 pub mod http;