519 lines
26 KiB
JavaScript
519 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
function parseArgs(argv) {
|
||
const args = { caseDir: '', fixture: '', envFile: '', require: '', json: false, markdown: false };
|
||
for (let i = 2; i < argv.length; i++) {
|
||
const a = argv[i];
|
||
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
|
||
else if (a === '--fixture') args.fixture = argv[++i] || '';
|
||
else if (a === '--env-file') args.envFile = argv[++i] || '';
|
||
else if (a === '--require') args.require = argv[++i] || '';
|
||
else if (a === '--json') args.json = true;
|
||
else if (a === '--markdown') args.markdown = true;
|
||
else if (a === '--help' || a === '-h') args.help = true;
|
||
else throw new Error(`未知参数:${a}`);
|
||
}
|
||
if (!args.json && !args.markdown) args.markdown = true;
|
||
return args;
|
||
}
|
||
|
||
function usage() {
|
||
return `用法:
|
||
node scripts/check_fingerprint_fixture.js --case-dir case --require canvas,webgl --markdown
|
||
node scripts/check_fingerprint_fixture.js --fixture case/fixtures/fingerprint.fixture.json --env-file case/result/src/env/fingerprint-env.js --json
|
||
|
||
说明:检查浏览器指纹 fixture 是否覆盖 Canvas / WebGL / WebGPU / Audio / DOM 几何等终端 API,是否绑定同一 fingerprint baseline,并检查最终 env 是否避免 node-canvas / headless-gl / 自动化浏览器等错误方向。`;
|
||
}
|
||
|
||
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
|
||
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
|
||
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
|
||
function readJson(p) { return JSON.parse(readText(p)); }
|
||
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
|
||
function ext(p) { return path.extname(p).toLowerCase(); }
|
||
|
||
function walk(p, out = []) {
|
||
if (!exists(p)) return out;
|
||
const st = stat(p);
|
||
if (!st) return out;
|
||
if (st.isDirectory()) {
|
||
let names = [];
|
||
try { names = fs.readdirSync(p); } catch { names = []; }
|
||
for (const name of names) walk(path.join(p, name), out);
|
||
} else if (st.isFile()) out.push(p);
|
||
return out;
|
||
}
|
||
|
||
function countArray(v) { return Array.isArray(v) ? v.length : 0; }
|
||
function hasResultObject(v) { return !!v && typeof v === 'object' && Object.prototype.hasOwnProperty.call(v, 'result'); }
|
||
function pickBaselineId(obj) {
|
||
if (!obj || typeof obj !== 'object') return '';
|
||
return String(obj.baselineId || (obj.source && obj.source.baselineId) || '');
|
||
}
|
||
|
||
|
||
const BAD_VALUE_SOURCE_RE = /(AI\s*(?:推断|猜|生成|guess)|猜测|编造|伪造|默认值|随机值|mock|fake|placeholder|static\s*analysis|静态分析|inferred|guessed|random\s*value|jsdom|node-canvas|headless-gl)/i;
|
||
const REAL_VALUE_SOURCE_RE = /(RuyiTrace|NDJSON|ruyiPage|Camoufox|CloakBrowser|手动|真实浏览器|browser|Hook|HAR|MCP|用户提供|console|network)/i;
|
||
const TRACE_TOOL_RE = /(RuyiTrace|NDJSON)/i;
|
||
const AUTOMATION_OR_USER_TOOL_RE = /(ruyiPage|Camoufox|CloakBrowser|手动|真实浏览器|browser|Hook|HAR|MCP|用户提供|console|network)/i;
|
||
const LONG_VALUE_THRESHOLD = 3900;
|
||
const BAD_TRUNCATED_KEY_RE = /^(visiblePreview|visibleSha256|sha256OfVisible|visibleValue|visibleSnippet|preview|clippedPreview)$/i;
|
||
const BAD_TRUNCATED_TEXT_RE = /(\.\.\.<(?:clipped|truncated)>|<clipped>|<truncated>|__ruyiTraceLongString__|sha256OfVisible|visibleSha256|visiblePreview|realLength["']?\s*[:=]\s*["']?unknown)/i;
|
||
const CHUNK_ENCODING_RE = /^(string-chunks|base64-chunks)$/;
|
||
|
||
function jsonText(v) {
|
||
try { return JSON.stringify(v || {}); } catch { return String(v || ''); }
|
||
}
|
||
|
||
function sourceLabel(source) {
|
||
if (!source || typeof source !== 'object') return '';
|
||
return [source.capturedBy, source.tool, source.mode, source.sourceType, source.type, source.origin, source.evidence]
|
||
.filter(Boolean).map(String).join(' ');
|
||
}
|
||
|
||
function traceStatusOf(source, record) {
|
||
return String((record && record.traceStatus) || (source && source.traceStatus) || '');
|
||
}
|
||
|
||
function hasSourceMeta(source) {
|
||
if (!source || typeof source !== 'object') return false;
|
||
return !!(source.capturedBy || source.tool || source.mode || source.sourceType || source.type || source.origin || source.evidence);
|
||
}
|
||
|
||
function isTruncatedTraceStatus(status) {
|
||
const s = String(status || '');
|
||
if (/used-untruncated|untruncated|not-truncated/i.test(s)) return false;
|
||
return /(^|[-_\s])truncated($|[-_\s])|疑似截断|截断/i.test(s);
|
||
}
|
||
|
||
function isTraceValueSource(source, record) {
|
||
const status = traceStatusOf(source, record);
|
||
const label = sourceLabel(source);
|
||
if (/used-untruncated/i.test(status)) return true;
|
||
if (/truncated|missing|not-covered|baseline-conflict|unused/i.test(status) && AUTOMATION_OR_USER_TOOL_RE.test(label) && !TRACE_TOOL_RE.test(String(source && source.capturedBy || ''))) return false;
|
||
return TRACE_TOOL_RE.test(label);
|
||
}
|
||
|
||
function collectStringLengths(value, out = []) {
|
||
if (typeof value === 'string') out.push(value.length);
|
||
else if (Array.isArray(value)) for (const item of value) collectStringLengths(item, out);
|
||
else if (value && typeof value === 'object') for (const item of Object.values(value)) collectStringLengths(item, out);
|
||
return out;
|
||
}
|
||
|
||
function isChunkedValue(value) {
|
||
return !!(
|
||
value &&
|
||
typeof value === 'object' &&
|
||
CHUNK_ENCODING_RE.test(String(value.encoding || '')) &&
|
||
Array.isArray(value.chunks)
|
||
);
|
||
}
|
||
|
||
function hasOwn(obj, key) {
|
||
return !!(obj && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key));
|
||
}
|
||
|
||
function hasLengthAndHashOn(value, record, source) {
|
||
if (isChunkedValue(value)) {
|
||
return Number.isFinite(value.valueLength) && typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/i.test(value.sha256);
|
||
}
|
||
if (value && typeof value === 'object') {
|
||
const metrics = collectValueMetrics(value);
|
||
if (metrics.chunked.length) {
|
||
return metrics.chunked.every(info =>
|
||
Number.isFinite(info.value.valueLength) &&
|
||
typeof info.value.sha256 === 'string' &&
|
||
/^[a-f0-9]{64}$/i.test(info.value.sha256)
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
(hasOwn(record, 'valueLength') || hasOwn(source, 'valueLength')) &&
|
||
(hasOwn(record, 'sha256') || hasOwn(source, 'sha256'))
|
||
);
|
||
}
|
||
|
||
function collectValueMetrics(value, out = { maxLen: 0, chunked: [], badMarkers: [] }, pathParts = []) {
|
||
const label = pathParts.join('.') || 'result';
|
||
if (typeof value === 'string') {
|
||
out.maxLen = Math.max(out.maxLen, value.length);
|
||
if (BAD_TRUNCATED_TEXT_RE.test(value)) out.badMarkers.push(`${label} 包含截断标记或可见片段标记`);
|
||
} else if (Array.isArray(value)) {
|
||
value.forEach((item, idx) => collectValueMetrics(item, out, pathParts.concat(String(idx))));
|
||
} else if (value && typeof value === 'object') {
|
||
if (isChunkedValue(value)) {
|
||
const joined = value.chunks.join('');
|
||
out.maxLen = Math.max(out.maxLen, Number(value.valueLength) || joined.length);
|
||
out.chunked.push({ path: label, value, joinedLength: joined.length });
|
||
}
|
||
for (const [key, item] of Object.entries(value)) {
|
||
if (BAD_TRUNCATED_KEY_RE.test(key)) out.badMarkers.push(`${label}.${key} 使用了 Trace 可见片段字段,不能作为最终值`);
|
||
collectValueMetrics(item, out, pathParts.concat(key));
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function validateChunkedValue(itemPath, chunk) {
|
||
const problems = [];
|
||
const warnings = [];
|
||
const encoding = String(chunk.encoding || '');
|
||
const joined = Array.isArray(chunk.chunks) ? chunk.chunks.join('') : '';
|
||
if (!CHUNK_ENCODING_RE.test(encoding)) problems.push(`样本 ${itemPath} 的分片 encoding 不合法:${encoding || '未指定'}。`);
|
||
if (!Array.isArray(chunk.chunks) || chunk.chunks.some(v => typeof v !== 'string')) problems.push(`样本 ${itemPath} 的 chunks 必须是字符串数组。`);
|
||
if (!Number.isFinite(chunk.valueLength)) problems.push(`样本 ${itemPath} 的分片结果缺少数值型 valueLength。`);
|
||
if (!Number.isFinite(chunk.chunkSize) || chunk.chunkSize <= 0) problems.push(`样本 ${itemPath} 的分片结果缺少有效 chunkSize。`);
|
||
if (chunk.truncated !== false) problems.push(`样本 ${itemPath} 的分片结果必须显式 truncated:false。`);
|
||
if (Number.isFinite(chunk.valueLength) && joined.length !== chunk.valueLength) {
|
||
problems.push(`样本 ${itemPath} 的 chunks 拼接长度 ${joined.length} 与 valueLength ${chunk.valueLength} 不一致。`);
|
||
}
|
||
if (Number.isFinite(chunk.chunkSize)) {
|
||
const tooLarge = (chunk.chunks || []).find(v => v.length > chunk.chunkSize);
|
||
if (tooLarge) warnings.push(`样本 ${itemPath} 存在超过 chunkSize 的分片;请确认分片写入逻辑没有异常。`);
|
||
}
|
||
if (typeof chunk.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(chunk.sha256)) {
|
||
problems.push(`样本 ${itemPath} 的长指纹分片缺少完整 sha256;必须先在真实浏览器采样 Hook 中 finalize,再写入 fixture。`);
|
||
}
|
||
if (BAD_TRUNCATED_TEXT_RE.test(joined)) problems.push(`样本 ${itemPath} 的分片拼接结果包含截断标记,不能作为最终值。`);
|
||
return { problems, warnings };
|
||
}
|
||
|
||
function collectResultRecords(node, out = [], pathParts = []) {
|
||
if (!node || typeof node !== 'object') return out;
|
||
if (Object.prototype.hasOwnProperty.call(node, 'result')) {
|
||
out.push({ path: pathParts.join('.') || '$', record: node, value: node.result });
|
||
}
|
||
for (const [key, value] of Object.entries(node)) {
|
||
if (key === 'source' || key === 'valueSource' || key === 'match') continue;
|
||
if (value && typeof value === 'object') collectResultRecords(value, out, pathParts.concat(key));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function validateValueSources(fp) {
|
||
const problems = [];
|
||
const warnings = [];
|
||
const sourceSummary = {
|
||
totalRecords: 0,
|
||
inheritedSource: 0,
|
||
explicitSource: 0,
|
||
traceSource: 0,
|
||
sampledSource: 0,
|
||
longValueRecords: 0,
|
||
chunkedLongValueRecords: 0,
|
||
blockedTruncatedResultRecords: 0,
|
||
badSourceRecords: 0
|
||
};
|
||
const rootSource = fp && fp.source;
|
||
|
||
function validateSource(source, label, record, inherited) {
|
||
if (!source || typeof source !== 'object') {
|
||
problems.push(`${label} 缺少真实值来源 source / capturedBy;必须优先使用未截断 Trace,Trace 不可用时用已确认取证工具采样,不能由 AI 猜值。`);
|
||
return;
|
||
}
|
||
const sourceText = jsonText(source);
|
||
const labelText = sourceLabel(source);
|
||
const isFixtureRoot = label === 'fixture.source';
|
||
if (!hasSourceMeta(source)) problems.push(`${label} 的 source 缺少 mode / capturedBy / tool / sourceType 等来源字段。`);
|
||
if (!REAL_VALUE_SOURCE_RE.test(sourceText)) problems.push(`${label} 的 source 未体现真实浏览器证据来源;请记录 RuyiTrace 未截断值,或 ruyiPage / Camoufox / CloakBrowser / 手动浏览器采样来源。`);
|
||
if (BAD_VALUE_SOURCE_RE.test(sourceText)) {
|
||
if (!isFixtureRoot) sourceSummary.badSourceRecords += 1;
|
||
problems.push(`${label} 的 source 疑似为 AI 猜值、静态分析、默认值、随机值、mock 值或 Node.js 模拟库结果,不能作为最终指纹回放值。`);
|
||
}
|
||
const status = traceStatusOf(source, record);
|
||
if (!status) warnings.push(`${label} 缺少 traceStatus;建议记录 used-untruncated / unused / missing / not-covered / truncated / baseline-conflict,明确是否优先检查 Trace。`);
|
||
if (!isFixtureRoot && isTraceValueSource(source, record)) sourceSummary.traceSource += 1;
|
||
if (!isFixtureRoot && AUTOMATION_OR_USER_TOOL_RE.test(labelText)) sourceSummary.sampledSource += 1;
|
||
}
|
||
|
||
validateSource(rootSource, 'fixture.source', null, false);
|
||
const records = collectResultRecords(fp);
|
||
sourceSummary.totalRecords = records.length;
|
||
for (const item of records) {
|
||
const source = item.record.source || item.record.valueSource || rootSource;
|
||
const inherited = !(item.record.source || item.record.valueSource);
|
||
if (inherited) sourceSummary.inheritedSource += 1;
|
||
else sourceSummary.explicitSource += 1;
|
||
validateSource(source, `样本 ${item.path}`, item.record, inherited);
|
||
|
||
const metrics = collectValueMetrics(item.value);
|
||
const maxLen = metrics.maxLen;
|
||
if (maxLen >= LONG_VALUE_THRESHOLD) sourceSummary.longValueRecords += 1;
|
||
if (metrics.chunked.length) sourceSummary.chunkedLongValueRecords += metrics.chunked.length;
|
||
for (const marker of metrics.badMarkers) {
|
||
sourceSummary.blockedTruncatedResultRecords += 1;
|
||
problems.push(`样本 ${item.path} ${marker};不能把 RuyiTrace 可见片段、clip 片段或未知真实长度写入最终 fixture。`);
|
||
}
|
||
for (const chunkInfo of metrics.chunked) {
|
||
const chunkResult = validateChunkedValue(`${item.path}.${chunkInfo.path}`, chunkInfo.value);
|
||
problems.push(...chunkResult.problems);
|
||
warnings.push(...chunkResult.warnings);
|
||
}
|
||
const valueIsMarkedTruncated = item.record.truncated === true || (source && source.truncated === true);
|
||
const status = traceStatusOf(source, item.record);
|
||
if (valueIsMarkedTruncated) problems.push(`样本 ${item.path} 标记为 truncated=true,不能作为最终回放值;请用同一 baseline 下的取证工具补采完整值。`);
|
||
if (isTraceValueSource(source, item.record) && (maxLen >= LONG_VALUE_THRESHOLD || isTruncatedTraceStatus(status) || valueIsMarkedTruncated)) {
|
||
sourceSummary.blockedTruncatedResultRecords += 1;
|
||
problems.push(`样本 ${item.path} 来自 Trace 且疑似长字段截断;不能把 RuyiTrace 4000 / 4096 可见片段作为完整指纹值,请用同一 baseline 下的 ruyiPage / Camoufox / CloakBrowser / 手动浏览器补采完整值。`);
|
||
}
|
||
if (maxLen >= LONG_VALUE_THRESHOLD && inherited) {
|
||
problems.push(`样本 ${item.path} 是长字符串 / 大结果但未记录每条样本自己的 source、valueLength 和 sha256;请补充完整浏览器采样来源,避免误把 Trace 4000 / 4096 字符可见片段当成真实值。`);
|
||
}
|
||
if (maxLen >= LONG_VALUE_THRESHOLD && !hasLengthAndHashOn(item.value, item.record, source)) {
|
||
problems.push(`样本 ${item.path} 长度达到 ${maxLen},但缺少完整 valueLength 与 sha256;长指纹必须通过真实浏览器完整采样或分片保存并记录完整 hash。`);
|
||
}
|
||
}
|
||
return { problems, warnings, sourceSummary };
|
||
}
|
||
|
||
function inspectFixture(file) {
|
||
const problems = [];
|
||
const warnings = [];
|
||
const counts = {
|
||
canvasToDataURL: 0,
|
||
canvasToBlob: 0,
|
||
canvasMeasureText: 0,
|
||
canvasGetImageData: 0,
|
||
webglGetParameter: 0,
|
||
webglGetSupportedExtensions: 0,
|
||
webglGetExtension: 0,
|
||
webglGetShaderPrecisionFormat: 0,
|
||
webglReadPixels: 0,
|
||
webgpuRequestAdapter: 0,
|
||
audioStartRendering: 0,
|
||
audioGetChannelData: 0,
|
||
domGetBoundingClientRect: 0,
|
||
domOffset: 0,
|
||
};
|
||
let fixture = null;
|
||
if (!file || !exists(file)) {
|
||
problems.push(`未找到指纹 fixture:${file || '未指定'}`);
|
||
return { fixture, counts, problems, warnings };
|
||
}
|
||
try { fixture = readJson(file); } catch (err) {
|
||
problems.push(`指纹 fixture JSON 解析失败:${err.message}`);
|
||
return { fixture, counts, problems, warnings };
|
||
}
|
||
const fp = fixture.fingerprint && typeof fixture.fingerprint === 'object' ? fixture.fingerprint : fixture;
|
||
counts.canvasToDataURL = countArray(fp.canvas && fp.canvas.toDataURL);
|
||
counts.canvasToBlob = countArray(fp.canvas && fp.canvas.toBlob);
|
||
counts.canvasMeasureText = countArray(fp.canvas && fp.canvas.measureText);
|
||
counts.canvasGetImageData = countArray(fp.canvas && fp.canvas.getImageData);
|
||
counts.webglGetParameter = countArray(fp.webgl && fp.webgl.getParameter);
|
||
counts.webglGetSupportedExtensions = hasResultObject(fp.webgl && fp.webgl.getSupportedExtensions) ? 1 : countArray(fp.webgl && fp.webgl.getSupportedExtensions);
|
||
counts.webglGetExtension = countArray(fp.webgl && fp.webgl.getExtension);
|
||
counts.webglGetShaderPrecisionFormat = countArray(fp.webgl && fp.webgl.getShaderPrecisionFormat);
|
||
counts.webglReadPixels = countArray(fp.webgl && fp.webgl.readPixels);
|
||
counts.webgpuRequestAdapter = countArray(fp.webgpu && fp.webgpu.requestAdapter);
|
||
counts.audioStartRendering = countArray(fp.audio && fp.audio.startRendering);
|
||
counts.audioGetChannelData = countArray(fp.audio && fp.audio.getChannelData);
|
||
counts.domGetBoundingClientRect = countArray(fp.domGeometry && fp.domGeometry.getBoundingClientRect);
|
||
counts.domOffset = countArray(fp.domGeometry && fp.domGeometry.offset);
|
||
|
||
const baselineId = pickBaselineId(fp);
|
||
if (!baselineId) problems.push('指纹 fixture 缺少 baselineId;必须先创建 case/notes/fingerprint-baseline.json,并让 fixture 绑定同一 baselineId。');
|
||
if (!fp.source) problems.push('指纹 fixture 缺少 source 字段;必须记录真实值来源,优先使用未截断 Trace,Trace 不可用时使用已确认取证工具采样。');
|
||
const sourceResult = validateValueSources(fp);
|
||
problems.push(...sourceResult.problems);
|
||
warnings.push(...sourceResult.warnings);
|
||
return { fixture: fp, baselineId, counts, problems, warnings, sourceSummary: sourceResult.sourceSummary };
|
||
}
|
||
|
||
function inspectBaseline(caseDir, fixtureResult) {
|
||
const problems = [];
|
||
const warnings = [];
|
||
const baselineFile = path.join(caseDir, 'notes', 'fingerprint-baseline.json');
|
||
const result = { file: baselineFile, present: exists(baselineFile), baselineId: '', conflicts: [] };
|
||
if (!result.present) {
|
||
problems.push('缺少指纹基线文件 case/notes/fingerprint-baseline.json;涉及指纹采样时必须先固定同一 case 的 fingerprint baseline。');
|
||
return { result, problems, warnings };
|
||
}
|
||
let baseline;
|
||
try { baseline = readJson(baselineFile); } catch (err) {
|
||
problems.push(`指纹基线 JSON 解析失败:${err.message}`);
|
||
return { result, problems, warnings };
|
||
}
|
||
result.baselineId = String(baseline.baselineId || '');
|
||
if (!result.baselineId) problems.push('fingerprint-baseline.json 缺少 baselineId。');
|
||
if (fixtureResult.baselineId && result.baselineId && fixtureResult.baselineId !== result.baselineId) {
|
||
problems.push(`指纹 fixture baselineId 与基线不一致:fixture=${fixtureResult.baselineId},baseline=${result.baselineId}。不得混用不同随机指纹样本。`);
|
||
}
|
||
const fp = fixtureResult.fixture || {};
|
||
const source = fp.source || {};
|
||
const checks = [
|
||
['userAgent', source.userAgent, baseline.navigator && baseline.navigator.userAgent],
|
||
['timezone', source.timezone, baseline.network && baseline.network.timezone],
|
||
['locale', source.locale, baseline.network && baseline.network.locale],
|
||
];
|
||
for (const [name, a, b] of checks) {
|
||
if (a && b && String(a) !== String(b)) result.conflicts.push({ field: name, fixture: String(a), baseline: String(b) });
|
||
}
|
||
if (result.conflicts.length) {
|
||
problems.push(`指纹 fixture 与 baseline 核心字段冲突:${result.conflicts.map(x => `${x.field}: fixture=${x.fixture}, baseline=${x.baseline}`).join(';')}。请重新采样或生成新 baseline。`);
|
||
}
|
||
return { result, problems, warnings };
|
||
}
|
||
|
||
const REQUIRE_RULES = {
|
||
canvas: ['canvasToDataURL', 'canvasMeasureText', 'canvasGetImageData'],
|
||
webgl: ['webglGetParameter', 'webglGetSupportedExtensions', 'webglGetShaderPrecisionFormat', 'webglReadPixels'],
|
||
webgpu: ['webgpuRequestAdapter'],
|
||
audio: ['audioStartRendering', 'audioGetChannelData'],
|
||
'dom-geometry': ['domGetBoundingClientRect', 'domOffset'],
|
||
dom: ['domGetBoundingClientRect', 'domOffset'],
|
||
font: ['canvasMeasureText', 'domOffset'],
|
||
fonts: ['canvasMeasureText', 'domOffset'],
|
||
};
|
||
|
||
const BAD_RENDER_PATTERNS = [
|
||
{ name: 'node-canvas/canvas', pattern: /\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas)['"]|from\s+['"](?:canvas|node-canvas)['"]/i },
|
||
{ name: 'headless-gl/gl', pattern: /\b(require|import)\s*\(?\s*['"](?:gl|headless-gl)['"]|from\s+['"](?:gl|headless-gl)['"]/i },
|
||
{ name: '浏览器自动化', pattern: /\b(playwright|puppeteer|selenium|cloakbrowser|ruyipage|page\.goto|browser\.launch|chromium\.launch)\b/i },
|
||
];
|
||
|
||
function inspectEnvCode(files, root) {
|
||
const problems = [];
|
||
const warnings = [];
|
||
const hits = [];
|
||
for (const file of files) {
|
||
if (!exists(file)) continue;
|
||
const text = readText(file);
|
||
for (const item of BAD_RENDER_PATTERNS) {
|
||
if (item.pattern.test(text)) hits.push({ file: rel(root, file), type: item.name });
|
||
}
|
||
if (/toDataURL|getImageData|measureText|getParameter|getBoundingClientRect/.test(text) && !/fingerprint|回放|replay|fixture/i.test(text)) {
|
||
warnings.push(`${rel(root, file)} 涉及指纹终端 API,但未明显体现 fixture / replay;请确认不是在 Node.js 中伪造渲染过程。`);
|
||
}
|
||
}
|
||
if (hits.length) {
|
||
problems.push(`发现不推荐的指纹实现方向:${hits.map(h => `${h.file}(${h.type})`).join('、')}。应使用真实浏览器采样值回放,不要在最终项目中依赖渲染库或自动化浏览器计算指纹。`);
|
||
}
|
||
return { problems, warnings, hits };
|
||
}
|
||
|
||
function defaultFixture(caseDir) {
|
||
const candidates = [
|
||
path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'),
|
||
path.join(caseDir, 'fixtures', 'sample.fixture.json'),
|
||
];
|
||
return candidates.find(exists) || candidates[0];
|
||
}
|
||
|
||
function defaultEnvFiles(caseDir) {
|
||
const result = path.join(caseDir, 'result');
|
||
return walk(result).filter(p => ['.js', '.mjs', '.cjs'].includes(ext(p)));
|
||
}
|
||
|
||
function check(args) {
|
||
const caseDir = args.caseDir ? path.resolve(args.caseDir) : (args.fixture ? path.resolve(path.dirname(args.fixture), '..') : process.cwd());
|
||
const fixtureFile = args.fixture ? path.resolve(args.fixture) : defaultFixture(caseDir);
|
||
const envFiles = args.envFile ? [path.resolve(args.envFile)] : defaultEnvFiles(caseDir);
|
||
const required = String(args.require || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||
const problems = [];
|
||
const warnings = [];
|
||
|
||
const fixtureResult = inspectFixture(fixtureFile);
|
||
problems.push(...fixtureResult.problems);
|
||
warnings.push(...fixtureResult.warnings);
|
||
|
||
for (const item of required) {
|
||
const rules = REQUIRE_RULES[item] || [];
|
||
if (!rules.length) warnings.push(`未知 require 类型:${item}`);
|
||
const ok = rules.some(k => fixtureResult.counts[k] > 0);
|
||
if (!ok) problems.push(`要求 ${item} 指纹样本,但 fixture 中未发现对应终端 API 返回值。`);
|
||
}
|
||
|
||
const baselineResult = inspectBaseline(caseDir, fixtureResult);
|
||
problems.push(...baselineResult.problems);
|
||
warnings.push(...baselineResult.warnings);
|
||
|
||
const envResult = inspectEnvCode(envFiles, caseDir);
|
||
problems.push(...envResult.problems);
|
||
warnings.push(...envResult.warnings);
|
||
|
||
return {
|
||
caseDir,
|
||
fixtureFile,
|
||
envFiles: envFiles.map(p => rel(caseDir, p)),
|
||
required,
|
||
clean: problems.length === 0,
|
||
baseline: baselineResult.result,
|
||
fixtureBaselineId: fixtureResult.baselineId || '',
|
||
counts: fixtureResult.counts,
|
||
sourceSummary: fixtureResult.sourceSummary || {
|
||
totalRecords: 0,
|
||
inheritedSource: 0,
|
||
explicitSource: 0,
|
||
traceSource: 0,
|
||
sampledSource: 0,
|
||
longValueRecords: 0,
|
||
chunkedLongValueRecords: 0,
|
||
blockedTruncatedResultRecords: 0,
|
||
badSourceRecords: 0
|
||
},
|
||
badImplementationHits: envResult.hits,
|
||
problems,
|
||
warnings,
|
||
};
|
||
}
|
||
|
||
function renderMarkdown(result) {
|
||
const lines = [
|
||
'# 指纹 fixture 与回放实现检查',
|
||
'',
|
||
`case 目录:${result.caseDir}`,
|
||
`fixture:${result.fixtureFile}`,
|
||
`是否通过:${result.clean ? '是' : '否'}`,
|
||
'',
|
||
'## 指纹基线',
|
||
`- baseline 文件:${result.baseline.file}`,
|
||
`- baseline 是否存在:${result.baseline.present ? '是' : '否'}`,
|
||
`- baselineId:${result.baseline.baselineId || '未发现'}`,
|
||
`- fixture baselineId:${result.fixtureBaselineId || '未发现'}`,
|
||
`- 核心字段冲突:${result.baseline.conflicts.length ? '是' : '否'}`,
|
||
'',
|
||
'## 样本覆盖统计',
|
||
];
|
||
for (const [k, v] of Object.entries(result.counts)) lines.push(`- ${k}:${v}`);
|
||
lines.push('', '## 指纹值来源检查');
|
||
lines.push(`- result 样本数量:${result.sourceSummary.totalRecords}`);
|
||
lines.push(`- 显式 source 样本:${result.sourceSummary.explicitSource}`);
|
||
lines.push(`- 继承全局 source 样本:${result.sourceSummary.inheritedSource}`);
|
||
lines.push(`- Trace 来源样本:${result.sourceSummary.traceSource}`);
|
||
lines.push(`- 自动化 / 手动浏览器采样来源样本:${result.sourceSummary.sampledSource}`);
|
||
lines.push(`- 长字段样本:${result.sourceSummary.longValueRecords}`);
|
||
lines.push(`- 分片长字段样本:${result.sourceSummary.chunkedLongValueRecords}`);
|
||
lines.push(`- 被阻断的截断 / 可见片段样本:${result.sourceSummary.blockedTruncatedResultRecords}`);
|
||
lines.push(`- 疑似猜值 / 默认值 / 模拟库来源样本:${result.sourceSummary.badSourceRecords}`);
|
||
lines.push('', '## 检查的 env 文件');
|
||
if (result.envFiles.length) for (const f of result.envFiles) lines.push(`- ${f}`);
|
||
else lines.push('- 未发现 result 下的 JS env 文件');
|
||
if (result.problems.length) {
|
||
lines.push('', '## 问题');
|
||
for (const p of result.problems) lines.push(`- ${p}`);
|
||
}
|
||
if (result.warnings.length) {
|
||
lines.push('', '## 提醒');
|
||
for (const w of result.warnings) lines.push(`- ${w}`);
|
||
}
|
||
return lines.join('\n') + '\n';
|
||
}
|
||
|
||
try {
|
||
const args = parseArgs(process.argv);
|
||
if (args.help) { console.log(usage()); process.exit(0); }
|
||
const result = check(args);
|
||
if (args.json) console.log(JSON.stringify(result, null, 2));
|
||
if (args.markdown) process.stdout.write(renderMarkdown(result));
|
||
process.exit(result.clean ? 0 : 1);
|
||
} catch (err) {
|
||
console.error(err.message || String(err));
|
||
console.error(usage());
|
||
process.exit(1);
|
||
}
|