use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::tab::Tab;
use crate::Result;
const PROBE_TEMPLATE: &str = include_str!("assets/dump_env_probe.js");
const FP_RECIPES: &str = include_str!("assets/fp_recipes.js");
const ENV_TEMPLATE: &str = include_str!("assets/env_template.js");
const PROJ_PACKAGE_JSON: &str = include_str!("assets/project/package.json");
const PROJ_INDEX_JS: &str = include_str!("assets/project/index.js");
const PROJ_DEMO_JS: &str = include_str!("assets/project/demo.js");
const PROJ_VERIFY_JS: &str = include_str!("assets/project/verify.js");
const PROJ_README_MD: &str = include_str!("assets/project/README.md");
const DEFAULT_SIG: &[&str] = &[
"a_bogus",
"X-Bogus",
"x-bogus",
"msToken",
"_signature",
"verifyFp",
"mssdk",
"webid",
];
#[derive(Debug, Clone)]
pub enum EnvTarget {
Query(String),
Header(String),
Cookie(String),
}
impl EnvTarget {
fn kind(&self) -> &'static str {
match self {
EnvTarget::Query(_) => "query",
EnvTarget::Header(_) => "header",
EnvTarget::Cookie(_) => "cookie",
}
}
fn key(&self) -> &str {
match self {
EnvTarget::Query(k) | EnvTarget::Header(k) | EnvTarget::Cookie(k) => k,
}
}
fn to_json(&self) -> Value {
json!({ "kind": self.kind(), "key": self.key() })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvScope {
Full,
Accessed,
}
pub struct EnvDumper {
tab: Tab,
targets: Vec<EnvTarget>,
url_keywords: Vec<String>,
watch: Vec<String>,
proxy: bool,
}
impl EnvDumper {
pub(crate) fn new(tab: Tab) -> Self {
Self {
tab,
targets: Vec::new(),
url_keywords: Vec::new(),
watch: vec!["navigator".into(), "screen".into()],
proxy: false,
}
}
pub fn target(mut self, t: EnvTarget) -> Self {
self.targets.push(t);
self
}
pub fn target_query(self, key: &str) -> Self {
self.target(EnvTarget::Query(key.to_string()))
}
pub fn target_header(self, key: &str) -> Self {
self.target(EnvTarget::Header(key.to_string()))
}
pub fn target_cookie(self, key: &str) -> Self {
self.target(EnvTarget::Cookie(key.to_string()))
}
pub fn match_url(mut self, keyword: &str) -> Self {
self.url_keywords.push(keyword.to_string());
self
}
pub fn watch(mut self, objects: &[&str]) -> Self {
self.watch = objects.iter().map(|s| s.to_string()).collect();
self
}
pub fn proxy(mut self, on: bool) -> Self {
self.proxy = on;
self
}
pub async fn start(self) -> Result<EnvProbe> {
let mut sig: Vec<String> = self.targets.iter().map(|t| t.key().to_string()).collect();
for d in DEFAULT_SIG {
if !sig.iter().any(|s| s == d) {
sig.push((*d).to_string());
}
}
let cfg = json!({
"proxy": self.proxy,
"watch": self.watch,
"sig": sig,
"urlMatch": self.url_keywords,
"targets": self.targets.iter().map(EnvTarget::to_json).collect::<Vec<_>>(),
});
let probe_js = PROBE_TEMPLATE
.replace("__FP_RECIPES__", FP_RECIPES)
.replace("__DUMP_CFG__", &cfg.to_string());
self.tab.add_init_script(&probe_js).await?;
Ok(EnvProbe {
tab: self.tab,
targets: self.targets,
proxy: self.proxy,
seed: Value::Null,
access: Value::Null,
sinks: Vec::new(),
hits: Vec::new(),
})
}
}
pub struct EnvProbe {
tab: Tab,
#[allow(dead_code)]
targets: Vec<EnvTarget>,
proxy: bool,
seed: Value,
access: Value,
sinks: Vec<Value>,
hits: Vec<Value>,
}
impl EnvProbe {
pub async fn collect(&mut self) -> Result<EnvDump> {
if let Ok(s) = self
.tab
.run_js("window.__DUMP__ ? window.__DUMP__.collectSeed() : null")
.await
&& s.is_object()
{
self.seed = s;
}
if let Ok(a) = self
.tab
.run_js(
"window.__DUMP__ ? ({order: window.__DUMP__.accessOrder, count: window.__DUMP__.access}) : null",
)
.await
&& a.is_object()
{
self.access = a;
}
if let Ok(v) = self.tab.run_js("window.__DUMP__ ? window.__DUMP__.sinks : []").await
&& let Some(arr) = v.as_array()
{
for it in arr {
if !self.sinks.contains(it) {
self.sinks.push(it.clone());
}
}
}
if let Ok(v) = self.tab.run_js("window.__DUMP__ ? window.__DUMP__.targets : []").await
&& let Some(arr) = v.as_array()
{
for it in arr {
if !self.hits.contains(it) {
self.hits.push(it.clone());
}
}
}
Ok(self.dump())
}
pub fn dump(&self) -> EnvDump {
EnvDump {
seed: self.seed.clone(),
access: self.access.clone(),
sinks: self.sinks.clone(),
targets: self.hits.clone(),
proxy: self.proxy,
}
}
pub fn record_hit(&mut self, kind: &str, key: &str, value: &str, url: &str) {
let item = json!({ "kind": kind, "key": key, "value": value, "url": url, "source": "listen" });
let dup = self
.hits
.iter()
.any(|h| h["kind"] == item["kind"] && h["key"] == item["key"] && h["value"] == item["value"]);
if !dup {
self.hits.push(item);
}
}
pub fn tab(&self) -> &Tab {
&self.tab
}
}
#[derive(Debug, Clone)]
pub struct EnvDump {
pub seed: Value,
pub access: Value,
pub sinks: Vec<Value>,
pub targets: Vec<Value>,
pub proxy: bool,
}
impl EnvDump {
pub fn accessed_seed(&self) -> Value {
prune_seed(&self.seed, &self.access)
}
pub fn env_js(&self, scope: EnvScope) -> String {
let seed = match scope {
EnvScope::Full => self.seed.clone(),
EnvScope::Accessed => self.accessed_seed(),
};
build_env_js(&seed)
}
pub fn has_access(&self) -> bool {
self.access
.get("order")
.and_then(Value::as_array)
.is_some_and(|a| !a.is_empty())
}
pub fn write_to(&self, dir: impl AsRef<Path>) -> Result<()> {
let dir = dir.as_ref();
std::fs::create_dir_all(dir)?;
write_json(&dir.join("seed.json"), &self.seed)?;
write_json(&dir.join("access.json"), &self.access)?;
write_json(&dir.join("sinks.json"), &json!(self.sinks))?;
write_json(&dir.join("signers.json"), &json!(self.signers()))?;
write_json(&dir.join("targets.json"), &json!(self.targets))?;
std::fs::write(dir.join("env.js"), self.env_js(EnvScope::Full))?;
if self.has_access() {
std::fs::write(dir.join("env.accessed.js"), self.env_js(EnvScope::Accessed))?;
}
Ok(())
}
pub async fn verify(&self, tab: &Tab, dir: impl AsRef<Path>, scope: EnvScope) -> Result<Value> {
let dir = dir.as_ref();
let seed = match scope {
EnvScope::Full => self.seed.clone(),
EnvScope::Accessed => self.accessed_seed(),
};
let snapshot = gen_snapshot_js(&seed);
let mut r_browser = tab.run_js(&format!("(function(){{ {snapshot} }})()")).await?;
fill_recorded_fp(&mut r_browser, &seed);
std::fs::create_dir_all(dir)?;
std::fs::write(dir.join("env.verify.js"), build_env_js(&seed))?;
let snap_lit = serde_json::to_string(&snapshot).unwrap_or_else(|_| "\"\"".into());
let verify_js = format!(
"const vm = require('vm');\n\
const env = require('./env.verify.js');\n\
const sandbox = {{}};\n\
env.setup(sandbox);\n\
vm.createContext(sandbox);\n\
const res = vm.runInContext('(function(){{ ' + {snap_lit} + ' }})()', sandbox);\n\
const audioCode = \"(async function(){{ try {{ if (typeof OfflineAudioContext === 'undefined') return null; var ctx = new OfflineAudioContext(1,5000,44100); var buf = await ctx.startRendering(); var d = buf.getChannelData(0); var s = 0; for (var i=4500;i<5000;i++) s += Math.abs(d[i]); return Math.round(s*1e6)/1e6; }} catch(e){{ return null; }} }})()\";\n\
Promise.resolve(vm.runInContext(audioCode, sandbox)).then(function (a) {{ if (a !== null && a !== undefined) res['audio.sum'] = a; process.stdout.write(JSON.stringify(res)); }}).catch(function () {{ process.stdout.write(JSON.stringify(res)); }});\n"
);
std::fs::write(dir.join("verify-run.js"), &verify_js)?;
let output = match std::process::Command::new("node")
.arg("verify-run.js")
.current_dir(dir)
.output()
{
Ok(o) => o,
Err(e) => return Ok(json!({ "error": format!("无法运行 node: {e}") })),
};
if !output.status.success() {
return Ok(json!({ "error": String::from_utf8_lossy(&output.stderr).trim().to_string() }));
}
let r_node: Value = serde_json::from_slice(&output.stdout).unwrap_or(Value::Null);
Ok(compare(&r_browser, &r_node))
}
pub fn signers(&self) -> Vec<Value> {
parse_signers(&self.sinks)
}
pub fn export_project(&self, dir: impl AsRef<Path>, scope: EnvScope) -> Result<PathBuf> {
let dir = dir.as_ref();
std::fs::create_dir_all(dir)?;
let seed = match scope {
EnvScope::Full => self.seed.clone(),
EnvScope::Accessed => self.accessed_seed(),
};
let pkg_name = dir
.file_name()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.unwrap_or("drission-env");
std::fs::write(dir.join("env.js"), build_env_js(&seed))?;
std::fs::write(dir.join("index.js"), PROJ_INDEX_JS)?;
std::fs::write(dir.join("demo.js"), PROJ_DEMO_JS)?;
std::fs::write(dir.join("verify.js"), PROJ_VERIFY_JS)?;
std::fs::write(
dir.join("package.json"),
PROJ_PACKAGE_JSON.replace("__PKG_NAME__", pkg_name),
)?;
std::fs::write(
dir.join("README.md"),
PROJ_README_MD.replace("__PKG_NAME__", pkg_name),
)?;
write_json(&dir.join("seed.json"), &seed)?;
write_json(&dir.join("signers.json"), &json!(self.signers()))?;
write_json(&dir.join("targets.json"), &json!(self.targets))?;
write_json(&dir.join("sinks.json"), &json!(self.sinks))?;
std::fs::create_dir_all(dir.join("signer"))?;
Ok(dir.to_path_buf())
}
}
fn write_json(path: &Path, v: &Value) -> Result<()> {
std::fs::write(path, serde_json::to_string_pretty(v)?)?;
Ok(())
}
fn build_env_js(seed: &Value) -> String {
let seed_pretty = serde_json::to_string_pretty(seed).unwrap_or_else(|_| "null".into());
ENV_TEMPLATE.replace("__SEED_JSON__", &seed_pretty)
}
fn prune_seed(seed: &Value, access: &Value) -> Value {
let mut out = json!({});
if let Some(order) = access.get("order").and_then(Value::as_array) {
for p in order {
let Some(raw) = p.as_str() else { continue };
let path = raw.replace("(in)", "");
let parts: Vec<&str> = path.split('.').filter(|s| !s.is_empty()).collect();
if parts.len() < 2 {
continue;
}
if let Some(v) = get_path(seed, &parts) {
let v = v.clone();
set_path(&mut out, &parts, v);
}
}
}
if let Some(loc) = seed.get("location") {
out["location"] = loc.clone();
}
let cookie = seed
.pointer("/document/cookie")
.cloned()
.unwrap_or_else(|| json!(""));
if !out.get("document").map(Value::is_object).unwrap_or(false) {
out["document"] = json!({});
}
out["document"]["cookie"] = cookie;
if !out.get("navigator").map(Value::is_object).unwrap_or(false) {
out["navigator"] = json!({});
}
if out["navigator"].get("userAgent").is_none()
&& let Some(ua) = seed.pointer("/navigator/userAgent")
{
out["navigator"]["userAgent"] = ua.clone();
}
if let Some(fp) = seed.get("fingerprint") {
out["fingerprint"] = fp.clone();
}
if let Some(wm) = seed.get("windowMetrics") {
out["windowMetrics"] = wm.clone();
}
out
}
fn get_path<'a>(v: &'a Value, parts: &[&str]) -> Option<&'a Value> {
let mut cur = v;
for p in parts {
cur = cur.get(p)?;
}
Some(cur)
}
fn set_path(root: &mut Value, parts: &[&str], val: Value) {
let Some((last, prefix)) = parts.split_last() else {
return;
};
let mut cur = root;
for p in prefix {
if !cur.get(*p).map(Value::is_object).unwrap_or(false) {
cur[*p] = json!({});
}
cur = &mut cur[*p];
}
cur[*last] = val;
}
fn gen_snapshot_js(seed: &Value) -> String {
let mut items: Vec<String> = Vec::new();
if let Some(map) = seed.get("navigator").and_then(Value::as_object) {
for (k, val) in map {
match val {
Value::Object(_) => {} Value::Array(_) => {
if k == "languages" {
items.push(
" \"navigator.languages\": g(function () { return (navigator.languages || []).join(\",\"); })".into(),
);
}
}
_ => items.push(format!(
" \"navigator.{k}\": g(function () {{ return navigator.{k}; }})"
)),
}
}
}
if let Some(map) = seed.get("screen").and_then(Value::as_object) {
for k in map.keys() {
items.push(format!(" \"screen.{k}\": g(function () {{ return screen.{k}; }})"));
}
}
for k in ["host", "origin"] {
items.push(format!(" \"location.{k}\": g(function () {{ return location.{k}; }})"));
}
let fp = seed.get("fingerprint");
let supported = |obj: &str| {
fp.and_then(|f| f.get(obj))
.and_then(|c| c.get("supported"))
.and_then(Value::as_bool)
.unwrap_or(false)
};
if supported("canvas") {
items.push(" \"canvas.dataURL\": g(function () { return __C.dataURL; })".into());
}
if supported("webgl") {
for (key, expr) in [
("webgl.unmaskedVendor", "__W.unmaskedVendor"),
("webgl.unmaskedRenderer", "__W.unmaskedRenderer"),
("webgl.vendor", "__W.parameters && __W.parameters[7936]"),
("webgl.renderer", "__W.parameters && __W.parameters[7937]"),
("webgl.version", "__W.parameters && __W.parameters[7938]"),
("webgl.glsl", "__W.parameters && __W.parameters[35724]"),
("webgl.maxTextureSize", "__W.parameters && __W.parameters[3379]"),
("webgl.extCount", "(__W.extensions || []).length"),
] {
items.push(format!(" \"{key}\": g(function () {{ return {expr}; }})"));
}
}
format!(
"{recipes}\nfunction g(f) {{ try {{ var v = f(); return v === undefined ? null : v; }} catch (e) {{ return \"<ERR:\" + (e && e.message) + \">\"; }} }}\nvar __C = (function () {{ try {{ return __fpCanvas(); }} catch (e) {{ return {{}}; }} }})();\nvar __W = (function () {{ try {{ return __fpWebGL(); }} catch (e) {{ return {{}}; }} }})();\nreturn {{\n{items}\n}};\n",
recipes = FP_RECIPES,
items = items.join(",\n")
)
}
fn fill_recorded_fp(browser: &mut Value, seed: &Value) {
let Some(obj) = browser.as_object_mut() else {
return;
};
let fp = seed.get("fingerprint");
let sup = |o: &str| {
fp.and_then(|f| f.get(o))
.and_then(|c| c.get("supported"))
.and_then(Value::as_bool)
.unwrap_or(false)
};
if sup("canvas")
&& let Some(d) = fp.and_then(|f| f.pointer("/canvas/dataURL"))
{
obj.insert("canvas.dataURL".into(), d.clone());
}
if sup("webgl") {
let w = fp.and_then(|f| f.get("webgl"));
let getp = |k: &str| {
w.and_then(|w| w.pointer(&format!("/parameters/{k}")))
.cloned()
.unwrap_or(Value::Null)
};
let get = |k: &str| w.and_then(|w| w.get(k)).cloned().unwrap_or(Value::Null);
obj.insert("webgl.unmaskedVendor".into(), get("unmaskedVendor"));
obj.insert("webgl.unmaskedRenderer".into(), get("unmaskedRenderer"));
obj.insert("webgl.vendor".into(), getp("7936"));
obj.insert("webgl.renderer".into(), getp("7937"));
obj.insert("webgl.version".into(), getp("7938"));
obj.insert("webgl.glsl".into(), getp("35724"));
obj.insert("webgl.maxTextureSize".into(), getp("3379"));
let extc = w
.and_then(|w| w.get("extensions"))
.and_then(Value::as_array)
.map(Vec::len)
.unwrap_or(0);
obj.insert("webgl.extCount".into(), json!(extc));
}
if sup("audio")
&& let Some(sum) = fp.and_then(|f| f.pointer("/audio/sum")).and_then(Value::as_f64)
{
obj.insert("audio.sum".into(), json!(round6(sum)));
}
}
fn round6(x: f64) -> f64 {
(x * 1e6).round() / 1e6
}
fn parse_signers(sinks: &[Value]) -> Vec<Value> {
let mut map: HashMap<String, (u64, u64, u64, String)> = HashMap::new();
for s in sinks {
let stack = s.get("stack").and_then(Value::as_str).unwrap_or("");
let req = s.get("url").and_then(Value::as_str).unwrap_or("").to_string();
if let Some((url, line, col)) = first_http_frame(stack) {
let e = map.entry(url).or_insert((0, line, col, req));
e.0 += 1;
}
}
let mut v: Vec<Value> = map
.into_iter()
.map(|(url, (count, line, col, req))| {
json!({ "url": url, "line": line, "col": col, "count": count, "sample_request": req })
})
.collect();
v.sort_by(|a, b| {
b["count"]
.as_u64()
.cmp(&a["count"].as_u64())
.then_with(|| a["url"].as_str().cmp(&b["url"].as_str()))
});
v
}
fn first_http_frame(stack: &str) -> Option<(String, u64, u64)> {
let pos = ["https://", "http://"]
.iter()
.filter_map(|m| stack.find(m))
.min()?;
let rest = &stack[pos..];
let end = rest
.find(|c: char| c.is_whitespace() || c == ')' || c == '\'' || c == '"')
.unwrap_or(rest.len());
let token = &rest[..end];
let mut it = token.rsplitn(3, ':');
let col = it.next();
let line = it.next();
let url = it.next();
match (url, line, col) {
(Some(u), Some(l), Some(c)) => match (l.parse::<u64>(), c.parse::<u64>()) {
(Ok(l), Ok(c)) => Some((u.to_string(), l, c)),
_ => Some((token.to_string(), 0, 0)),
},
_ => Some((token.to_string(), 0, 0)),
}
}
fn compare(browser: &Value, node: &Value) -> Value {
let mut fields = Vec::new();
let (mut pass, mut fail) = (0usize, 0usize);
if let Some(map) = browser.as_object() {
for (k, bv) in map {
let nv = node.get(k).cloned().unwrap_or(Value::Null);
let ok = &nv == bv;
if ok {
pass += 1;
} else {
fail += 1;
}
fields.push(json!({ "field": k, "ok": ok, "browser": bv, "node": nv }));
}
}
json!({
"pass": pass, "fail": fail, "total": pass + fail,
"browser": browser, "node": node, "fields": fields,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_template_replaces_all_placeholders() {
assert_eq!(
PROBE_TEMPLATE.matches("__DUMP_CFG__").count(),
1,
"__DUMP_CFG__ 必须恰好出现 1 次(在 `var CFG =` 处)"
);
assert_eq!(
PROBE_TEMPLATE.matches("__FP_RECIPES__").count(),
1,
"__FP_RECIPES__ 必须恰好出现 1 次"
);
let replaced = PROBE_TEMPLATE
.replace("__FP_RECIPES__", FP_RECIPES)
.replace("__DUMP_CFG__", "{\"proxy\":false}");
assert!(!replaced.contains("__DUMP_CFG__"), "替换后不应再有配置占位符");
assert!(!replaced.contains("__FP_RECIPES__"), "替换后不应再有配方占位符");
assert!(replaced.contains("var CFG = {\"proxy\":false};"));
assert!(replaced.contains("function __fpCanvas()"));
assert!(replaced.contains("function __fpWebGL()"));
assert!(replaced.contains("function __fpAudioAsync("));
}
#[test]
fn env_template_has_single_seed_placeholder() {
assert_eq!(
ENV_TEMPLATE.matches("__SEED_JSON__").count(),
1,
"__SEED_JSON__ 必须恰好出现 1 次(在 `var __SEED__ =` 处)"
);
let seed = json!({ "navigator": { "userAgent": "UA" }, "location": { "host": "x.com" } });
let env = build_env_js(&seed);
assert!(!env.contains("__SEED_JSON__"), "替换后不应再有种子占位符");
assert!(env.contains("\"userAgent\": \"UA\""));
assert!(env.contains("function setup("));
assert!(env.contains("module.exports"));
}
#[test]
fn first_http_frame_firefox_and_chrome() {
let ff = "sign@https://site.com/static/bdms.js:5:120\n@https://site.com/app.js:1:2";
assert_eq!(
first_http_frame(ff),
Some(("https://site.com/static/bdms.js".to_string(), 5, 120))
);
let cr = "Error\n at sign (https://site.com/static/bdms.js:5:120)\n at h (https://site.com/app.js:1:2)";
assert_eq!(
first_http_frame(cr),
Some(("https://site.com/static/bdms.js".to_string(), 5, 120))
);
assert_eq!(first_http_frame("no url here"), None);
}
#[test]
fn parse_signers_aggregates_by_file() {
let sinks = json!([
{ "type": "xhr", "url": "https://api/detail?a_bogus=1", "stack": "sign@https://site/static/bdms.js:5:1\n@https://site/app.js:1:1" },
{ "type": "xhr", "url": "https://api/detail?a_bogus=2", "stack": "sign@https://site/static/bdms.js:6:1\n@https://site/app.js:1:1" },
{ "type": "fetch", "url": "https://api/related?a_bogus=3", "stack": "w@https://site/app.js:9:1" },
]);
let signers = parse_signers(sinks.as_array().unwrap());
assert_eq!(signers.len(), 2);
assert_eq!(signers[0]["url"], json!("https://site/static/bdms.js"));
assert_eq!(signers[0]["count"], json!(2));
assert_eq!(signers[1]["url"], json!("https://site/app.js"));
assert_eq!(signers[1]["count"], json!(1));
}
#[test]
fn prune_keeps_fingerprint_skeleton() {
let seed = json!({
"navigator": { "userAgent": "UA", "platform": "MacIntel" },
"screen": { "width": 1920 },
"location": { "host": "x.com" },
"document": { "cookie": "a=1" },
"windowMetrics": { "devicePixelRatio": 2 },
"fingerprint": { "canvas": { "supported": true, "dataURL": "data:img" }, "webgl": { "supported": true }, "audio": { "supported": false } }
});
let access = json!({ "order": ["navigator.platform"], "count": {} });
let pruned = prune_seed(&seed, &access);
assert_eq!(pruned.pointer("/fingerprint/canvas/dataURL"), Some(&json!("data:img")));
assert_eq!(pruned.pointer("/windowMetrics/devicePixelRatio"), Some(&json!(2)));
}
#[test]
fn snapshot_includes_fingerprint_when_supported() {
let seed = json!({
"navigator": { "userAgent": "UA" },
"screen": { "width": 1920 },
"fingerprint": { "canvas": { "supported": true }, "webgl": { "supported": true }, "audio": { "supported": true } }
});
let js = gen_snapshot_js(&seed);
assert!(js.contains("function __fpCanvas()")); assert!(js.contains("\"canvas.dataURL\""));
assert!(js.contains("\"webgl.unmaskedVendor\""));
assert!(js.contains("\"webgl.extCount\""));
let seed2 = json!({ "navigator": { "userAgent": "UA" }, "fingerprint": { "canvas": { "supported": false } } });
let js2 = gen_snapshot_js(&seed2);
assert!(!js2.contains("\"canvas.dataURL\""));
}
#[test]
fn fill_recorded_fp_overwrites_from_seed() {
let seed = json!({
"fingerprint": {
"canvas": { "supported": true, "dataURL": "data:REC" },
"webgl": { "supported": true, "unmaskedVendor": "Acme", "unmaskedRenderer": "GPU-9", "parameters": { "7936": "Moz", "3379": 16384 }, "extensions": ["A", "B"] },
"audio": { "supported": true, "sum": 124.0434752751607 }
}
});
let mut browser = json!({ "canvas.dataURL": "data:LIVE", "webgl.unmaskedVendor": "live" });
fill_recorded_fp(&mut browser, &seed);
assert_eq!(browser["canvas.dataURL"], json!("data:REC"));
assert_eq!(browser["webgl.unmaskedVendor"], json!("Acme"));
assert_eq!(browser["webgl.vendor"], json!("Moz"));
assert_eq!(browser["webgl.maxTextureSize"], json!(16384));
assert_eq!(browser["webgl.extCount"], json!(2));
assert_eq!(browser["audio.sum"], json!(round6(124.0434752751607)));
}
#[test]
fn target_to_json() {
assert_eq!(
EnvTarget::Query("a_bogus".into()).to_json(),
json!({ "kind": "query", "key": "a_bogus" })
);
assert_eq!(
EnvTarget::Header("x-bogus".into()).to_json(),
json!({ "kind": "header", "key": "x-bogus" })
);
assert_eq!(EnvTarget::Cookie("msToken".into()).key(), "msToken");
}
#[test]
fn set_and_get_path() {
let mut o = json!({});
set_path(&mut o, &["navigator", "userAgent"], json!("UA"));
set_path(&mut o, &["navigator", "userAgentData", "platform"], json!("macOS"));
assert_eq!(get_path(&o, &["navigator", "userAgent"]), Some(&json!("UA")));
assert_eq!(
get_path(&o, &["navigator", "userAgentData", "platform"]),
Some(&json!("macOS"))
);
assert_eq!(get_path(&o, &["missing"]), None);
}
#[test]
fn prune_keeps_accessed_and_skeleton() {
let seed = json!({
"navigator": { "userAgent": "UA", "platform": "MacIntel", "vendor": "Google", "hardwareConcurrency": 10 },
"screen": { "width": 1920, "height": 1080 },
"location": { "host": "x.com", "origin": "https://x.com" },
"document": { "cookie": "a=1", "title": "T" },
"localStorage": { "k": "v" }
});
let access = json!({
"order": ["navigator.platform", "navigator.hardwareConcurrency", "screen.width"],
"count": {}
});
let pruned = prune_seed(&seed, &access);
assert_eq!(pruned.pointer("/navigator/platform"), Some(&json!("MacIntel")));
assert_eq!(pruned.pointer("/navigator/hardwareConcurrency"), Some(&json!(10)));
assert_eq!(pruned.pointer("/screen/width"), Some(&json!(1920)));
assert!(pruned.get("localStorage").is_none());
assert!(pruned.pointer("/screen/height").is_none());
assert_eq!(pruned.pointer("/navigator/userAgent"), Some(&json!("UA")));
assert_eq!(pruned.pointer("/location/host"), Some(&json!("x.com")));
assert_eq!(pruned.pointer("/document/cookie"), Some(&json!("a=1")));
}
#[test]
fn snapshot_covers_scalars() {
let seed = json!({
"navigator": { "userAgent": "UA", "languages": ["zh-CN", "en"], "userAgentData": { "mobile": false } },
"screen": { "width": 1920 }
});
let js = gen_snapshot_js(&seed);
assert!(js.contains("\"navigator.userAgent\""));
assert!(js.contains("(navigator.languages || []).join")); assert!(!js.contains("\"navigator.userAgentData\"")); assert!(js.contains("\"screen.width\""));
assert!(js.contains("\"location.host\""));
}
#[test]
fn compare_counts() {
let b = json!({ "navigator.userAgent": "UA", "screen.width": 1920 });
let n = json!({ "navigator.userAgent": "UA", "screen.width": 1366 });
let r = compare(&b, &n);
assert_eq!(r["pass"], 1);
assert_eq!(r["fail"], 1);
assert_eq!(r["total"], 2);
}
}