use std::collections::{BTreeMap, HashMap};
pub fn registrable_domain(url: &str) -> String {
let host = url
.trim_start_matches("https://")
.trim_start_matches("http://")
.split(['/', '?', '#'])
.next()
.unwrap_or("")
.split(':') .next()
.unwrap_or("")
.to_ascii_lowercase();
let labels: Vec<&str> = host.split('.').filter(|s| !s.is_empty()).collect();
let is_ip = !labels.is_empty() && labels.iter().all(|l| l.chars().all(|c| c.is_ascii_digit()));
if is_ip {
return host;
}
if let Some(d) = psl::domain_str(&host) {
return d.to_string();
}
if labels.len() >= 2 {
labels[labels.len() - 2..].join(".")
} else {
host
}
}
pub(crate) fn parse_cookie_str(s: &str) -> BTreeMap<String, String> {
let mut map = BTreeMap::new();
for kv in s.split(';').map(str::trim).filter(|s| !s.is_empty()) {
if let Some((k, v)) = kv.split_once('=') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
map
}
pub fn merge_cookie_str(first: &str, second: &str) -> String {
let mut map = parse_cookie_str(first);
map.extend(parse_cookie_str(second));
pairs_to_str(&map)
}
pub(crate) fn pairs_to_str(map: &BTreeMap<String, String>) -> String {
map.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ")
}
pub(crate) fn sanitize_header_value(v: &str) -> String {
v.replace(['\r', '\n'], "")
}
pub(crate) fn request_registrable_domain(url: &str, source_domain: &str) -> String {
if url.starts_with("http://") || url.starts_with("https://") {
registrable_domain(url)
} else {
source_domain.to_string()
}
}
pub(crate) fn merge_login_into_headers(
login_header: &BTreeMap<String, String>,
source_domain: &str,
request_domain: &str,
jar_cookie: Option<&str>,
headers: &mut HashMap<String, String>,
) {
let mut cookie = headers
.remove("Cookie")
.or_else(|| headers.remove("cookie"));
if request_domain == source_domain {
for (k, v) in login_header {
if k.eq_ignore_ascii_case("cookie") {
let v = sanitize_header_value(v);
cookie = Some(match cookie {
Some(c) => merge_cookie_str(&c, &v),
None => v,
});
} else {
headers.insert(k.clone(), sanitize_header_value(v));
}
}
}
if let Some(jar) = jar_cookie {
cookie = Some(match cookie {
Some(c) => merge_cookie_str(&c, jar),
None => jar.to_string(),
});
}
if let Some(c) = cookie.map(|c| sanitize_header_value(&c))
&& !c.is_empty()
{
headers.insert("Cookie".into(), c);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct CookieVal {
value: String,
persistent: bool,
}
#[derive(Debug, Clone, Default)]
pub struct CookieJar {
jar: BTreeMap<String, BTreeMap<String, CookieVal>>,
}
impl CookieJar {
pub fn from_persistent(saved: &BTreeMap<String, String>) -> Self {
let mut jar = BTreeMap::new();
for (domain, cookie) in saved {
let m: BTreeMap<String, CookieVal> = parse_cookie_str(cookie)
.into_iter()
.map(|(k, v)| {
(
k,
CookieVal {
value: v,
persistent: true,
},
)
})
.collect();
if !m.is_empty() {
jar.insert(registrable_domain(domain), m);
}
}
Self { jar }
}
pub fn cookie_header(&self, domain: &str) -> Option<String> {
let key = registrable_domain(domain);
let m = self.jar.get(&key)?;
if m.is_empty() {
return None;
}
let flat: BTreeMap<String, String> = m
.iter()
.map(|(k, v)| (k.clone(), v.value.clone()))
.collect();
Some(pairs_to_str(&flat))
}
pub fn absorb_set_cookie(&mut self, request_domain: &str, set_cookie: &str) {
let key = registrable_domain(request_domain);
let entry = self.jar.entry(key).or_default();
for line in set_cookie
.split('\n')
.map(str::trim)
.filter(|s| !s.is_empty())
{
let mut parts = line.split(';').map(str::trim);
let Some(nv) = parts.next() else { continue };
let Some((name, value)) = nv.split_once('=') else {
continue;
};
let (name, value) = (name.trim().to_string(), value.trim().to_string());
if name.is_empty() {
continue;
}
let mut persistent = false;
let mut deleted = false;
for attr in parts {
let lower = attr.to_ascii_lowercase();
if let Some(ma) = lower.strip_prefix("max-age=") {
match ma.trim().parse::<i64>() {
Ok(n) if n <= 0 => deleted = true,
Ok(_) => persistent = true,
Err(_) => {}
}
} else if lower.starts_with("expires=") {
persistent = true;
}
}
if deleted {
entry.remove(&name);
} else {
entry.insert(name, CookieVal { value, persistent });
}
}
if entry.is_empty() {
self.jar.remove(®istrable_domain(request_domain));
}
}
pub fn persistent(&self) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for (domain, m) in &self.jar {
let flat: BTreeMap<String, String> = m
.iter()
.filter(|(_, v)| v.persistent)
.map(|(k, v)| (k.clone(), v.value.clone()))
.collect();
if !flat.is_empty() {
out.insert(domain.clone(), pairs_to_str(&flat));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.jar.values().all(BTreeMap::is_empty)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registrable_domain_publicsuffix_and_fallbacks() {
assert_eq!(
registrable_domain("https://www.fanqienovel.com/x"),
"fanqienovel.com"
);
assert_eq!(registrable_domain("http://api.site.com:8080/p"), "site.com");
assert_eq!(registrable_domain("WWW.Site.COM"), "site.com");
assert_eq!(
registrable_domain("https://www.example.com.cn/p"),
"example.com.cn"
);
assert_eq!(
registrable_domain("http://a.b.example.co.uk"),
"example.co.uk"
);
assert_eq!(registrable_domain("http://192.168.1.1:80"), "192.168.1.1");
assert_eq!(registrable_domain("localhost"), "localhost");
assert_eq!(registrable_domain("http:///path"), "");
}
#[test]
fn merge_cookie_str_dedups_second_wins() {
assert_eq!(
merge_cookie_str("sid=old; theme=dark", "sid=new; lang=zh"),
"lang=zh; sid=new; theme=dark"
);
}
#[test]
fn sanitize_header_value_strips_crlf() {
assert_eq!(
sanitize_header_value("a=1; Path=/\nb=2; HttpOnly"),
"a=1; Path=/b=2; HttpOnly"
);
assert_eq!(sanitize_header_value("Bearer\r\n token"), "Bearer token");
}
#[test]
fn merge_login_gates_cross_domain_and_merges_cookie() {
let mut lh = BTreeMap::new();
lh.insert("Authorization".into(), "Bearer T".into());
lh.insert("Cookie".into(), "lang=zh".into());
let mut h = HashMap::new();
h.insert("Cookie".into(), "a=1".into());
merge_login_into_headers(&lh, "site.com", "site.com", Some("sid=9"), &mut h);
assert_eq!(h.get("Authorization").map(String::as_str), Some("Bearer T"));
assert_eq!(
h.get("Cookie").map(String::as_str),
Some("a=1; lang=zh; sid=9")
);
let mut h2 = HashMap::new();
merge_login_into_headers(&lh, "site.com", "evil.com", Some("sid=9"), &mut h2);
assert!(!h2.contains_key("Authorization"), "跨域不应注入登录头");
assert_eq!(h2.get("Cookie").map(String::as_str), Some("sid=9"));
}
#[test]
fn absorb_splits_session_and_persistent() {
let mut jar = CookieJar::default();
jar.absorb_set_cookie(
"www.site.com",
"sid=abc; Path=/\nremember=1; Max-Age=3600; HttpOnly\ntmp=x; Path=/",
);
let header = jar.cookie_header("api.site.com").unwrap();
assert!(header.contains("sid=abc"));
assert!(header.contains("remember=1"));
assert!(header.contains("tmp=x"));
let persisted = jar.persistent();
assert_eq!(
persisted.get("site.com").map(String::as_str),
Some("remember=1")
);
}
#[test]
fn absorb_max_age_zero_deletes() {
let mut jar = CookieJar::default();
jar.absorb_set_cookie("site.com", "sid=abc; Max-Age=3600");
assert!(jar.cookie_header("site.com").unwrap().contains("sid=abc"));
jar.absorb_set_cookie("site.com", "sid=; Max-Age=0");
assert!(jar.cookie_header("site.com").is_none(), "Max-Age=0 应删除");
}
#[test]
fn from_persistent_round_trip() {
let mut saved = BTreeMap::new();
saved.insert("site.com".to_string(), "a=1; b=2".to_string());
let jar = CookieJar::from_persistent(&saved);
assert_eq!(
jar.cookie_header("www.site.com"),
Some("a=1; b=2".to_string())
);
assert_eq!(
jar.persistent().get("site.com").map(String::as_str),
Some("a=1; b=2")
);
}
#[test]
fn expires_attribute_marks_persistent() {
let mut jar = CookieJar::default();
jar.absorb_set_cookie("site.com", "t=1; Expires=Wed, 09 Jun 2027 10:18:14 GMT");
assert_eq!(
jar.persistent().get("site.com").map(String::as_str),
Some("t=1")
);
}
}