use crate::cookie::{
CookieJar, merge_cookie_str, merge_login_into_headers, parse_cookie_str, registrable_domain,
request_registrable_domain, sanitize_header_value,
};
use crate::error::EvalError;
use crate::eval::Vars;
use crate::fetch::{FetchRequest, FetchResponse, Fetcher};
use crate::js::{arg, register, to_eval, yield_js};
use crate::source::Method;
use crate::state::SourceState;
use boa_engine::object::ObjectInitializer;
use boa_engine::property::Attribute;
use boa_engine::{
Context, JsNativeError, JsObject, JsResult, JsValue, NativeFunction, Source, js_string,
};
use std::cell::RefCell;
use std::collections::{BTreeMap, HashMap};
use std::rc::Rc;
use std::sync::Arc;
pub struct SourceHost {
pub state: SourceState,
pub dirty: bool,
domain: String,
fetcher: Arc<dyn Fetcher>,
rt: tokio::runtime::Runtime,
}
impl SourceHost {
pub fn new(
source_url: &str,
state: SourceState,
fetcher: Arc<dyn Fetcher>,
) -> Result<Self, EvalError> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| EvalError::Host(format!("create host runtime: {e}")))?;
Ok(Self {
state,
dirty: false,
domain: registrable_domain(source_url),
fetcher,
rt,
})
}
fn outbound_headers(
&self,
url: &str,
extra: Option<BTreeMap<String, String>>,
) -> HashMap<String, String> {
let mut headers = HashMap::new();
let domain = request_registrable_domain(url, &self.domain);
let jar_cookie = self.state.cookies.get(&domain);
merge_login_into_headers(
&self.state.login_header,
&self.domain,
&domain,
jar_cookie.map(String::as_str),
&mut headers,
);
if let Some(extra) = extra {
for (k, v) in extra {
headers.insert(k, sanitize_header_value(&v));
}
}
headers
}
fn request(
&mut self,
label: &str,
method: Method,
url: &str,
body: Option<String>,
extra_headers: Option<&str>,
) -> Result<FetchResponse, EvalError> {
let extra = match extra_headers {
Some(j) => Some(json_to_string_map(j)?),
None => None,
};
let req = FetchRequest {
url: url.to_string(),
method,
body,
headers: self.outbound_headers(url, extra),
};
let fetcher = self.fetcher.clone();
let resp = self
.rt
.block_on(fetcher.fetch_full(req))
.map_err(|e| EvalError::Host(format!("{label} {url}: {e}")))?;
self.absorb_set_cookie(url, &resp);
Ok(resp)
}
fn absorb_set_cookie(&mut self, url: &str, resp: &FetchResponse) {
let Some(set_cookie) = resp.headers.get("set-cookie") else {
return;
};
let domain = request_registrable_domain(url, &self.domain);
let mut saved = BTreeMap::new();
if let Some(existing) = self.state.cookies.get(&domain) {
saved.insert(domain.clone(), existing.clone());
}
let mut jar = CookieJar::from_persistent(&saved);
jar.absorb_set_cookie(&domain, set_cookie);
match jar.cookie_header(&domain) {
Some(c) => {
self.state.cookies.insert(domain, c);
}
None => {
self.state.cookies.remove(&domain);
}
}
self.dirty = true;
}
fn ajax(&mut self, url: &str) -> Result<String, EvalError> {
self.request("ajax", Method::Get, url, None, None)
.map(|r| r.body)
}
fn connect(
&mut self,
url: &str,
extra_headers: Option<&str>,
) -> Result<FetchResponse, EvalError> {
self.request("connect", Method::Get, url, None, extra_headers)
}
fn post(
&mut self,
url: &str,
body: &str,
extra_headers: Option<&str>,
) -> Result<FetchResponse, EvalError> {
self.request(
"post",
Method::Post,
url,
Some(body.to_string()),
extra_headers,
)
}
fn set_login_header(&mut self, mut map: BTreeMap<String, String>) {
for v in map.values_mut() {
*v = sanitize_header_value(v);
}
self.state.login_header = map;
if let Some(cookie) = self
.state
.login_header
.get("Cookie")
.or_else(|| self.state.login_header.get("cookie"))
.cloned()
{
merge_cookie_jar(&mut self.state.cookies, &self.domain, &cookie);
}
self.dirty = true;
}
fn store_login_info(&mut self, plain: &str) -> Result<(), EvalError> {
self.state.set_login_info(plain)?;
self.dirty = true;
Ok(())
}
fn get_cookie(&self, domain: &str, key: Option<&str>) -> String {
let domain = domain.to_ascii_lowercase();
let jar = self.state.cookies.get(&domain).or_else(|| {
let reg = registrable_domain(&domain);
(reg != domain)
.then(|| self.state.cookies.get(®))
.flatten()
});
let Some(jar) = jar else {
return String::new();
};
match key {
None | Some("") => jar.clone(),
Some(k) => parse_cookie_str(jar).get(k).cloned().unwrap_or_default(),
}
}
}
thread_local! {
static ACTIVE_HOST: RefCell<Option<Rc<RefCell<SourceHost>>>> = const { RefCell::new(None) };
}
struct HostGuard;
impl HostGuard {
fn install(host: Rc<RefCell<SourceHost>>) -> Self {
ACTIVE_HOST.with(|slot| *slot.borrow_mut() = Some(host));
HostGuard
}
}
impl Drop for HostGuard {
fn drop(&mut self) {
ACTIVE_HOST.with(|slot| *slot.borrow_mut() = None);
}
}
fn active_host() -> Option<Rc<RefCell<SourceHost>>> {
ACTIVE_HOST.with(|slot| slot.borrow().clone())
}
fn merge_cookie_jar(jar: &mut BTreeMap<String, String>, domain: &str, add: &str) {
let add = sanitize_header_value(add);
let merged = merge_cookie_str(jar.get(domain).map(String::as_str).unwrap_or(""), &add);
jar.insert(domain.to_string(), merged);
}
pub fn eval_js_with_host(
script: &str,
result: &str,
vars: &Vars,
host: Rc<RefCell<SourceHost>>,
) -> Result<String, EvalError> {
let _guard = HostGuard::install(host);
let mut ctx = Context::default();
register(&mut ctx, result, vars).map_err(to_eval)?; register_host(&mut ctx).map_err(to_eval)?; let value = ctx.eval(Source::from_bytes(script)).map_err(to_eval)?;
Ok(value
.to_string(&mut ctx)
.map_err(to_eval)?
.to_std_string_escaped())
}
pub fn eval_blocking(
script: &str,
result: &str,
vars: &Vars,
source_url: &str,
state: SourceState,
fetcher: Arc<dyn Fetcher>,
) -> Result<(String, SourceState, bool), EvalError> {
let host = Rc::new(RefCell::new(SourceHost::new(source_url, state, fetcher)?));
let out = eval_js_with_host(script, result, vars, host.clone())?;
let host = Rc::try_unwrap(host)
.map_err(|_| EvalError::Host("host still referenced after eval".into()))?
.into_inner();
Ok((out, host.state, host.dirty))
}
pub fn run_login(
login_js: &str,
source_url: &str,
state: SourceState,
fetcher: Arc<dyn Fetcher>,
) -> Result<(SourceState, bool), EvalError> {
let wrapped = format!("{login_js}\n;if(typeof login=='function'){{login.apply(this);}}\n");
let (_, state, dirty) = eval_blocking(&wrapped, "", &Vars::new(), source_url, state, fetcher)?;
Ok((state, dirty))
}
fn register_host(ctx: &mut Context) -> JsResult<()> {
let source = ObjectInitializer::new(ctx)
.function(NativeFunction::from_fn_ptr(js_put), js_string!("put"), 2)
.function(NativeFunction::from_fn_ptr(js_get), js_string!("get"), 1)
.function(
NativeFunction::from_fn_ptr(js_get_variable),
js_string!("getVariable"),
0,
)
.function(
NativeFunction::from_fn_ptr(js_put_variable),
js_string!("putVariable"),
1,
)
.function(
NativeFunction::from_fn_ptr(js_put_login_header),
js_string!("putLoginHeader"),
1,
)
.function(
NativeFunction::from_fn_ptr(js_get_login_header),
js_string!("getLoginHeader"),
0,
)
.function(
NativeFunction::from_fn_ptr(js_get_login_header_map),
js_string!("getLoginHeaderMap"),
0,
)
.function(
NativeFunction::from_fn_ptr(js_remove_login_header),
js_string!("removeLoginHeader"),
0,
)
.function(
NativeFunction::from_fn_ptr(js_put_login_info),
js_string!("putLoginInfo"),
1,
)
.function(
NativeFunction::from_fn_ptr(js_get_login_info),
js_string!("getLoginInfo"),
0,
)
.function(
NativeFunction::from_fn_ptr(js_get_login_info_map),
js_string!("getLoginInfoMap"),
0,
)
.build();
ctx.register_global_property(js_string!("source"), source, Attribute::all())?;
let net = ObjectInitializer::new(ctx)
.function(NativeFunction::from_fn_ptr(js_ajax), js_string!("ajax"), 1)
.function(
NativeFunction::from_fn_ptr(js_connect),
js_string!("connect"),
1,
)
.function(NativeFunction::from_fn_ptr(js_post), js_string!("post"), 2)
.function(
NativeFunction::from_fn_ptr(js_get_cookie),
js_string!("getCookie"),
2,
)
.build();
ctx.register_global_property(js_string!("net"), net, Attribute::all())?;
Ok(())
}
fn js_put(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let key = arg(args, 0, ctx)?;
let value = arg(args, 1, ctx)?;
if let Some(host) = active_host() {
let mut h = host.borrow_mut();
h.state.kv.insert(key, value.clone());
h.dirty = true;
}
Ok(js_string!(value.as_str()).into())
}
fn js_get(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let key = arg(args, 0, ctx)?;
let v = active_host()
.map(|h| h.borrow().state.kv.get(&key).cloned().unwrap_or_default())
.unwrap_or_default();
Ok(js_string!(v.as_str()).into())
}
fn js_get_variable(_t: &JsValue, _args: &[JsValue], _ctx: &mut Context) -> JsResult<JsValue> {
let v = active_host()
.map(|h| h.borrow().state.variable.clone())
.unwrap_or_default();
Ok(js_string!(v.as_str()).into())
}
fn js_put_variable(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let value = arg(args, 0, ctx)?;
if let Some(host) = active_host() {
let mut h = host.borrow_mut();
h.state.variable = value.clone();
h.dirty = true;
}
Ok(js_string!(value.as_str()).into())
}
fn json_to_string_map(json: &str) -> Result<BTreeMap<String, String>, EvalError> {
let v: serde_json::Value = serde_json::from_str(json)
.map_err(|e| EvalError::Host(format!("invalid json object: {e}")))?;
let obj = v
.as_object()
.ok_or_else(|| EvalError::Host("expected json object".into()))?;
Ok(obj
.iter()
.map(|(k, val)| {
let s = match val {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
(k.clone(), s)
})
.collect())
}
fn map_to_js_object(map: &BTreeMap<String, String>, ctx: &mut Context) -> JsObject {
let mut init = ObjectInitializer::new(ctx);
for (k, v) in map {
init.property(
js_string!(k.as_str()),
js_string!(v.as_str()),
Attribute::all(),
);
}
init.build()
}
fn js_put_login_header(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let json = arg(args, 0, ctx)?;
let r = json_to_string_map(&json).map(|map| {
if let Some(host) = active_host() {
host.borrow_mut().set_login_header(map);
}
json.clone()
});
yield_js(r)
}
fn js_get_login_header(_t: &JsValue, _args: &[JsValue], _ctx: &mut Context) -> JsResult<JsValue> {
let s = active_host()
.map(|h| {
let h = h.borrow();
if h.state.login_header.is_empty() {
String::new()
} else {
serde_json::to_string(&h.state.login_header).unwrap_or_default()
}
})
.unwrap_or_default();
Ok(js_string!(s.as_str()).into())
}
fn js_get_login_header_map(
_t: &JsValue,
_args: &[JsValue],
ctx: &mut Context,
) -> JsResult<JsValue> {
let map = active_host()
.map(|h| h.borrow().state.login_header.clone())
.unwrap_or_default();
Ok(map_to_js_object(&map, ctx).into())
}
fn js_remove_login_header(
_t: &JsValue,
_args: &[JsValue],
_ctx: &mut Context,
) -> JsResult<JsValue> {
if let Some(host) = active_host() {
let mut h = host.borrow_mut();
if !h.state.login_header.is_empty() {
h.state.login_header.clear();
h.dirty = true;
}
}
Ok(JsValue::undefined())
}
fn js_put_login_info(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let plain = arg(args, 0, ctx)?;
let r = match active_host() {
Some(host) => host
.borrow_mut()
.store_login_info(&plain)
.map(|_| plain.clone()),
None => Ok(plain.clone()),
};
yield_js(r)
}
fn js_get_login_info(_t: &JsValue, _args: &[JsValue], _ctx: &mut Context) -> JsResult<JsValue> {
let r = match active_host() {
Some(host) => host
.borrow()
.state
.get_login_info()
.map(Option::unwrap_or_default),
None => Ok(String::new()),
};
yield_js(r)
}
fn js_get_login_info_map(_t: &JsValue, _args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let plain = match active_host() {
Some(host) => match host.borrow().state.get_login_info() {
Ok(o) => o.unwrap_or_default(),
Err(e) => return Err(JsNativeError::typ().with_message(e.to_string()).into()),
},
None => String::new(),
};
if plain.is_empty() {
return Ok(ObjectInitializer::new(ctx).build().into());
}
let map =
json_to_string_map(&plain).map_err(|e| JsNativeError::typ().with_message(e.to_string()))?;
Ok(map_to_js_object(&map, ctx).into())
}
fn opt_extra_arg(args: &[JsValue], i: usize, ctx: &mut Context) -> JsResult<Option<String>> {
let extra = arg(args, i, ctx)?;
Ok((!extra.is_empty() && extra != "[object Object]").then_some(extra))
}
fn yield_response(r: Result<FetchResponse, EvalError>, ctx: &mut Context) -> JsResult<JsValue> {
match r {
Ok(resp) => Ok(response_to_js(&resp, ctx).into()),
Err(e) => Err(JsNativeError::typ().with_message(e.to_string()).into()),
}
}
fn js_ajax(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let url = arg(args, 0, ctx)?;
let r = match active_host() {
Some(host) => host.borrow_mut().ajax(&url),
None => Err(EvalError::Host("no active host".into())),
};
yield_js(r)
}
fn js_connect(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let url = arg(args, 0, ctx)?;
let extra = opt_extra_arg(args, 1, ctx)?;
let r = match active_host() {
Some(host) => host.borrow_mut().connect(&url, extra.as_deref()),
None => Err(EvalError::Host("no active host".into())),
};
yield_response(r, ctx)
}
fn js_post(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let url = arg(args, 0, ctx)?;
let body = arg(args, 1, ctx)?;
let extra = opt_extra_arg(args, 2, ctx)?;
let r = match active_host() {
Some(host) => host.borrow_mut().post(&url, &body, extra.as_deref()),
None => Err(EvalError::Host("no active host".into())),
};
yield_response(r, ctx)
}
fn response_to_js(resp: &FetchResponse, ctx: &mut Context) -> JsObject {
let headers: BTreeMap<String, String> = resp
.headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let headers_obj = map_to_js_object(&headers, ctx);
ObjectInitializer::new(ctx)
.property(
js_string!("body"),
js_string!(resp.body.as_str()),
Attribute::all(),
)
.property(
js_string!("code"),
JsValue::from(i32::from(resp.status)),
Attribute::all(),
)
.property(js_string!("headers"), headers_obj, Attribute::all())
.build()
}
fn js_get_cookie(_t: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let domain = arg(args, 0, ctx)?;
let key = arg(args, 1, ctx)?;
let v = active_host()
.map(|h| {
h.borrow()
.get_cookie(&domain, (!key.is_empty()).then_some(key.as_str()))
})
.unwrap_or_default();
Ok(js_string!(v.as_str()).into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fetch::ReqwestFetcher;
use crate::testutil::{book_source, spawn_echo_server, spawn_fixed_server};
fn host_with(state: SourceState, base: &str) -> Rc<RefCell<SourceHost>> {
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(base)).unwrap());
Rc::new(RefCell::new(SourceHost::new(base, state, fetcher).unwrap()))
}
#[test]
fn source_and_net_share_same_host() {
let mut state = SourceState::default();
state.cookies.insert("site.com".into(), "sid=S".into());
let host = host_with(state, "http://127.0.0.1:0");
let out = eval_js_with_host(
"source.put('a','1'); source.get('a') + ':' + net.getCookie('site.com','sid')",
"",
&Vars::new(),
host,
)
.unwrap();
assert_eq!(out, "1:S");
}
#[test]
fn put_get_and_variable_roundtrip() {
let host = host_with(SourceState::default(), "http://127.0.0.1:0");
let out = eval_js_with_host(
"source.put('token','T'); \
var miss = source.get('nope'); \
source.putVariable('cfg'); \
source.get('token') + '|' + miss + '|' + source.getVariable()",
"",
&Vars::new(),
host.clone(),
)
.unwrap();
assert_eq!(out, "T||cfg", "缺失键应为空串");
let h = host.borrow();
assert_eq!(h.state.kv.get("token").map(String::as_str), Some("T"));
assert_eq!(h.state.variable, "cfg");
assert!(h.dirty);
}
#[test]
fn get_cookie_by_domain_and_key() {
let mut state = SourceState::default();
state
.cookies
.insert("site.com".into(), "sid=abc; theme=dark".into());
let host = host_with(state, "http://127.0.0.1:0");
let out = eval_js_with_host(
"net.getCookie('site.com','theme') + '|' + net.getCookie('site.com') + '|' + net.getCookie('other.com','x')",
"",
&Vars::new(),
host,
)
.unwrap();
assert_eq!(out, "dark|sid=abc; theme=dark|");
}
#[test]
fn login_header_put_get_remove_roundtrip() {
let host = host_with(SourceState::default(), "http://127.0.0.1:0");
let out = eval_js_with_host(
"source.putLoginHeader(JSON.stringify({Authorization:'Bearer T', X:'1'})); \
var s = source.getLoginHeader(); \
var m = source.getLoginHeaderMap(); \
var j = JSON.parse(s); \
j.Authorization + '|' + m.X",
"",
&Vars::new(),
host.clone(),
)
.unwrap();
assert_eq!(out, "Bearer T|1");
assert_eq!(
host.borrow()
.state
.login_header
.get("Authorization")
.map(String::as_str),
Some("Bearer T")
);
let after = eval_js_with_host(
"source.removeLoginHeader(); source.getLoginHeader()",
"",
&Vars::new(),
host.clone(),
)
.unwrap();
assert_eq!(after, "");
assert!(host.borrow().state.login_header.is_empty());
}
#[test]
fn login_info_encrypt_store_and_decrypt_read() {
let host = host_with(SourceState::default(), "http://127.0.0.1:0");
let out = eval_js_with_host(
"source.putLoginInfo(JSON.stringify({user:'alice', pass:'pw密码'})); \
var info = source.getLoginInfo(); \
var m = source.getLoginInfoMap(); \
JSON.parse(info).user + '|' + m.pass",
"",
&Vars::new(),
host.clone(),
)
.unwrap();
assert_eq!(out, "alice|pw密码");
let ct = host.borrow().state.login_info.clone().unwrap();
assert!(!ct.contains("alice"), "凭据应加密落盘: {ct}");
}
#[test]
fn host_exposes_only_whitelisted_capabilities() {
let host = host_with(SourceState::default(), "http://127.0.0.1:0");
let out = eval_js_with_host(
"[typeof source.put, typeof source.get, typeof net.ajax, typeof net.getCookie, \
typeof require, typeof process, typeof source.exec, typeof net.readFile].join(',')",
"",
&Vars::new(),
host,
)
.unwrap();
assert_eq!(
out, "function,function,function,function,undefined,undefined,undefined,undefined",
"只暴露白名单方法,无 require/process/exec/readFile"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_ajax_reuses_pipeline_and_carries_login_header_without_deadlock() {
let (base, server) = spawn_echo_server();
let mut state = SourceState::default();
state
.login_header
.insert("Authorization".into(), "Bearer testjwt".into());
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (body, _state, _dirty) = tokio::task::spawn_blocking(move || {
eval_blocking("net.ajax('/echo')", "", &Vars::new(), &url, state, fetcher)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert!(
body.contains("GET /echo"),
"应真实发出请求(回显含请求行): {body}"
);
assert!(
body.to_ascii_lowercase()
.contains("authorization: bearer testjwt"),
"请求应自动携带 loginHeader: {body}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_ajax_failure_is_catchable() {
let fetcher: Arc<dyn Fetcher> =
Arc::new(ReqwestFetcher::new(&book_source("http://127.0.0.1:1")).unwrap());
let (out, _state, _dirty) = tokio::task::spawn_blocking(move || {
eval_blocking(
"try { net.ajax('/x'); 'NO_THROW' } catch(e) { 'CAUGHT' }",
"",
&Vars::new(),
"http://127.0.0.1:1",
SourceState::default(),
fetcher,
)
})
.await
.unwrap()
.unwrap();
assert_eq!(out, "CAUGHT", "网络失败应抛 JS 异常被 catch,而非 panic");
}
#[test]
fn put_login_header_syncs_cookie_into_jar() {
let host = host_with(SourceState::default(), "https://www.fanqienovel.com/reader");
let out = eval_js_with_host(
"source.putLoginHeader(JSON.stringify({Cookie:'sessionid=abc; uid=7'})); \
net.getCookie('fanqienovel.com','sessionid') + '|' + net.getCookie('fanqienovel.com','uid')",
"",
&Vars::new(),
host.clone(),
)
.unwrap();
assert_eq!(out, "abc|7");
assert!(
host.borrow().state.cookies.contains_key("fanqienovel.com"),
"Cookie 应同步进 cookie 库"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_connect_returns_body_code_and_headers() {
let body = "正文OK";
let (base, server) = spawn_fixed_server(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nSet-Cookie: sid=xyz; Path=/\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
));
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (out, _state, _dirty) = tokio::task::spawn_blocking(move || {
eval_blocking(
"var r = net.connect('/x'); \
r.code + '|' + r.body + '|' + r.headers['set-cookie']",
"",
&Vars::new(),
&url,
SourceState::default(),
fetcher,
)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert_eq!(out, "200|正文OK|sid=xyz; Path=/");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_login_script_writes_back_login_header() {
let body = "TOKEN123";
let (base, server) = spawn_fixed_server(format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
));
let login_js = "function login(){ \
var tok = net.ajax('/api/token'); \
source.putLoginHeader(JSON.stringify({Authorization:'Bearer '+tok})); \
}";
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (state, dirty) = tokio::task::spawn_blocking(move || {
run_login(login_js, &url, SourceState::default(), fetcher)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert!(dirty, "登录写回应置 dirty");
assert_eq!(
state.login_header.get("Authorization").map(String::as_str),
Some("Bearer TOKEN123"),
"login() 应通过 net.ajax 取 token 并写回 loginHeader"
);
}
#[test]
fn merge_cookie_jar_overwrites_same_key_keeps_others() {
let mut jar = BTreeMap::new();
jar.insert("x.com".to_string(), "sid=old; theme=dark".to_string());
merge_cookie_jar(&mut jar, "x.com", "sid=new; lang=zh");
assert_eq!(
jar.get("x.com").map(String::as_str),
Some("lang=zh; sid=new; theme=dark")
);
merge_cookie_jar(&mut jar, "x.com", " ");
assert_eq!(
jar.get("x.com").map(String::as_str),
Some("lang=zh; sid=new; theme=dark")
);
}
#[test]
fn get_cookie_falls_back_to_registrable_domain() {
let mut state = SourceState::default();
state
.cookies
.insert("fanqienovel.com".into(), "sid=abc; theme=dark".into());
let host = host_with(state, "https://www.fanqienovel.com");
let out = eval_js_with_host(
"net.getCookie('www.fanqienovel.com','sid') + '|' + net.getCookie('API.Fanqienovel.com','theme')",
"",
&Vars::new(),
host,
)
.unwrap();
assert_eq!(out, "abc|dark");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_connect_joins_multiple_set_cookie_with_newline() {
let (base, server) = spawn_fixed_server(
"HTTP/1.1 200 OK\r\nSet-Cookie: a=1; Path=/\r\nSet-Cookie: b=2; HttpOnly\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"
.to_string(),
);
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (out, _s, _d) = tokio::task::spawn_blocking(move || {
eval_blocking(
"var r = net.connect('/x'); r.headers['set-cookie'].split('\\n').length + '|' + r.headers['set-cookie']",
"",
&Vars::new(),
&url,
SourceState::default(),
fetcher,
)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert_eq!(out, "2|a=1; Path=/\nb=2; HttpOnly");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_connect_extra_headers_stack_over_login_header() {
let (base, server) = spawn_echo_server();
let mut state = SourceState::default();
state
.login_header
.insert("Authorization".into(), "Bearer T".into());
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (body, _s, _d) = tokio::task::spawn_blocking(move || {
eval_blocking(
"net.connect('/x', JSON.stringify({'X-Test':'42'})).body",
"",
&Vars::new(),
&url,
state,
fetcher,
)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
let lower = body.to_ascii_lowercase();
assert!(lower.contains("x-test: 42"), "额外头应送出: {body}");
assert!(
lower.contains("authorization: bearer t"),
"loginHeader 应保留: {body}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_connect_bad_extra_headers_throws() {
let fetcher: Arc<dyn Fetcher> =
Arc::new(ReqwestFetcher::new(&book_source("http://127.0.0.1:1")).unwrap());
let (out, _s, _d) = tokio::task::spawn_blocking(move || {
eval_blocking(
"try { net.connect('/x', 'not-json'); 'NO_THROW' } catch(e) { 'CAUGHT' }",
"",
&Vars::new(),
"http://127.0.0.1:1",
SourceState::default(),
fetcher,
)
})
.await
.unwrap()
.unwrap();
assert_eq!(out, "CAUGHT", "非法额外头 JSON 应抛错被捕获,而非静默丢弃");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn newline_in_login_header_does_not_break_request() {
let (base, server) = spawn_echo_server();
let mut state = SourceState::default();
state
.login_header
.insert("Cookie".into(), "a=1\nb=2".into());
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (body, _s, _d) = tokio::task::spawn_blocking(move || {
eval_blocking("net.ajax('/x')", "", &Vars::new(), &url, state, fetcher)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert!(body.contains("GET /x"), "请求应成功送出: {body}");
let lower = body.to_ascii_lowercase();
assert!(
lower.contains("cookie: a=1b=2"),
"Cookie 的 \\n 应被剥除: {body}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_request_sends_persisted_cookie_merged_with_login_cookie() {
let (base, server) = spawn_echo_server();
let domain = registrable_domain(&base); let mut state = SourceState::default();
state.cookies.insert(domain, "sid=persisted".into());
state.login_header.insert("Cookie".into(), "lang=zh".into());
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (body, _s, _d) = tokio::task::spawn_blocking(move || {
eval_blocking("net.ajax('/x')", "", &Vars::new(), &url, state, fetcher)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
let lower = body.to_ascii_lowercase();
assert!(
lower.contains("cookie: lang=zh; sid=persisted"),
"应合并发送持久化与登录 cookie: {body}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_response_set_cookie_absorbed_into_state() {
let (base, server) = spawn_fixed_server(
"HTTP/1.1 200 OK\r\nSet-Cookie: session=S1; Path=/; HttpOnly\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"
.to_string(),
);
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (out, state, dirty) = tokio::task::spawn_blocking(move || {
eval_blocking(
"net.connect('/login'); net.getCookie('127.0.0.1','session')",
"",
&Vars::new(),
&url,
SourceState::default(),
fetcher,
)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert_eq!(out, "S1", "Set-Cookie 应回灌进 state.cookies 供脚本读取");
assert!(dirty, "回灌应置 dirty(供调用方落盘)");
assert!(
state
.cookies
.get("127.0.0.1")
.is_some_and(|c| c.contains("session=S1")),
"登录态应随 state 返回供落盘: {:?}",
state.cookies
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn net_post_sends_body() {
let (base, server) = spawn_echo_server();
let fetcher: Arc<dyn Fetcher> = Arc::new(ReqwestFetcher::new(&book_source(&base)).unwrap());
let url = base.clone();
let (body, _s, _d) = tokio::task::spawn_blocking(move || {
eval_blocking(
"net.post('/login', 'user=alice&pass=x').body",
"",
&Vars::new(),
&url,
SourceState::default(),
fetcher,
)
})
.await
.unwrap()
.unwrap();
server.join().unwrap();
assert!(body.contains("POST /login"), "应为 POST: {body}");
assert!(body.contains("user=alice&pass=x"), "body 应送出: {body}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn run_login_propagates_throw_and_noops_when_undefined() {
let f1: Arc<dyn Fetcher> =
Arc::new(ReqwestFetcher::new(&book_source("http://127.0.0.1:1")).unwrap());
let r1 = tokio::task::spawn_blocking(move || {
run_login(
"function login(){ throw new Error('bad cred'); }",
"http://127.0.0.1:1",
SourceState::default(),
f1,
)
})
.await
.unwrap();
let err = r1.unwrap_err().to_string();
assert!(err.contains("bad cred"), "登录脚本抛错应传播: {err}");
let f2: Arc<dyn Fetcher> =
Arc::new(ReqwestFetcher::new(&book_source("http://127.0.0.1:1")).unwrap());
let (state, dirty) = tokio::task::spawn_blocking(move || {
run_login(
"var x = 1;",
"http://127.0.0.1:1",
SourceState::default(),
f2,
)
})
.await
.unwrap()
.unwrap();
assert!(!dirty, "未执行 login 不应置 dirty");
assert!(state.login_header.is_empty(), "未登录则登录态为空");
}
#[test]
fn get_login_info_map_empty_and_non_json() {
let host = host_with(SourceState::default(), "http://127.0.0.1:0");
let out = eval_js_with_host(
"Object.keys(source.getLoginInfoMap()).length.toString()",
"",
&Vars::new(),
host,
)
.unwrap();
assert_eq!(out, "0", "未设置凭据时返回空对象");
let host = host_with(SourceState::default(), "http://127.0.0.1:0");
let out = eval_js_with_host(
"source.putLoginInfo('not-json-token'); \
var raw = source.getLoginInfo(); \
var m; try { source.getLoginInfoMap(); m='NO_THROW'; } catch(e){ m='CAUGHT'; } \
raw + '|' + m",
"",
&Vars::new(),
host,
)
.unwrap();
assert_eq!(out, "not-json-token|CAUGHT");
}
}