Files
erqiwang_youle/server/games/erqiwang/test/test_leak.js
T
joywayerandClaude Opus 5 5d981a722e 二七王:I-3/I-2/M-2 服务端补齐阶段·控制权·结算重连下发面
I-3 阶段与控制权改由服务端唯一维护并逐包下发:
- fapai / jiaofen / shangzhuang / xuanzhu / maipai / jiesuan 各补 step(取自 o_paiju.step)
- shangzhuang / xuanzhu 补 nextseat(控制权 = o_paiju.banker,选主/投降/埋牌均只由庄家做)
- deskinfo 的 ChooseMain / BuryCards 各补 seat(控制权,与增量推送 nextseat 同源同值)
- 各下发点的真实值先用探针跑真实一局逐个核实;顺带发现 shangzhuang.data.seat 是
  最后一个叫分者而非控制权(本局实测 2 vs 庄家 0),故控制权另起名 nextseat
- get_paiju_account 统一把 step 置 6:解散结算此前无人推进 step,包里给不出诚实的阶段值

I-2 结算阶段的重连包补发抠底明细与大局结算:
- get_paiju_account 末尾一并冻结 tmp_jiesuan_bottom / tmp_jiesuan_account
- get_deskinfo case 6 的 Balance 据此补 bottom / account,与 jiesuan 推送同源同值、
  门控一致(投降/解散无 bottom、非末局无 account);新局开始时三份快照一并清除
- 修复「结算面板开着时断线重连/硬刷新 → 抠底明细与末局大结算空白」

M-2 seatlist 不再以活引用挂进下发包:
- 新增 get_seatlist() 深拷贝快照,maipai / chupai / deskinfo.PushCards 三处改用它

测试:test_rpc 新增 I-3/I-2/M-2 三节(131→166 checks)、test_desk 新增 fapai
阶段与控制权用例(6→10);全部新增守卫已逐条做「退回修复必转红」反向验证(15/15)。
test_leak 的可见性规则同步收紧到「本局结算(step6)后埋牌底牌才全场公开」,
并修掉一处把阶段写死成 step5 的报错文案;整局泄露审计仍全绿。
协议文档同步补充上述字段说明。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:30:56 +08:00

179 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 下发面泄露审计(server 红线「发全 ≠ 发多,按可见性下发」+ design §4/§9/§11)
// 跑完整局,对每一个「服务器 → 某座位」的包做深度扫描,取出其中出现的所有牌 id,
// 逐个判定「此刻该座位是否有权知道这张牌」。依据 design §4(底牌只有庄家可见 / 70分亮3秒)、
// §9(查牌模式)、§11(结束亮埋牌底牌)与 server 红线「发全 ≠ 发多,按可见性下发」。
const R = require('./_rpc.js'); const mod = R.mod;
const P = global.cls_youle_erqiwang_paiju, A = global.cls_youle_erqiwang_arith;
const D = require('../class.desk.js'), EX = require('../class.export.js');
const t = require('./_assert')();
let seed = 0xBEEF01;
const rnd = n => { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; seed >>>= 0; return seed % n; };
global.min_ontimeout = fn => fn();
global.min_random = (a, b) => a + rnd(b - a + 1);
const pk = (s, d) => ({ conmode: 0, fromid: s, data: Object.assign({ agentid: 1, playerid: s, gameid: 1, roomcode: 1, seat: s }, d || {}) });
// 深度收集一个对象里所有「看起来是牌 id」的整数(0~107)。
// 为避免把 seat/count/grade 之类误当牌 id,只扫描已知承载牌 id 的字段名。
const CARD_FIELDS = ['cards', 'bottomcards', 'burycards', 'cardsinhand', 'zhucards', 'gradecards', 'pushlist', 'MyCards', 'mustcard'];
function collectCards(node, key, out) {
if (node === null || node === undefined) return;
if (Array.isArray(node)) { node.forEach(x => collectCards(x, key, out)); return; }
if (typeof node === 'number') { if (CARD_FIELDS.indexOf(key) >= 0 && node >= 0 && node <= 107) out.add(node); return; }
if (typeof node === 'object') { for (const k in node) collectCards(node[k], CARD_FIELDS.indexOf(k) >= 0 ? k : key, out); }
}
// design §8.2 亮牌:庄家埋牌后手中【固定主牌】达门槛时,这些牌的牌面对两个闲家公开(仅可查牌模式)。
// 【独立于 get_liangpai 重算一遍】——若实现误把非固定主牌(主花色普通牌、副牌)塞进 liangpai,
// 下面的集合里不会有它,审计照样报泄露。这是审计的意义所在,不能图省事直接复用被测实现。
function bankerLiangpaiCards(pj) {
const out = [];
let wang = 0, qi = 0, er = 0;
for (const c of pj.cards) {
// 与 get_seat_cards_award 同口径:庄家手牌 = 发到自己的 + 摸起的底牌,且排除已埋的
if (!(c.dealowner === pj.banker + 1 || c.dealowner === 0)) continue;
if (c.playround === 0) continue;
if (c.number >= 53) { wang++; out.push(c.id); }
else if (c.number === 7) { qi++; out.push(c.id); }
else if (c.number === 2) { er++; out.push(c.id); }
}
if (out.length < 10 && wang < 3 && qi < 6 && er < 6) return new Set(); // 不达标:一张都不该下发
return new Set(out);
}
let curLiangpai = new Set(); // 本局亮牌应公开的牌(埋牌后确定,全程固定)
let curNoCheck = false; // 本局是否「不查牌」
let liangpaiRounds = 0; // 可查牌 + 庄家达标的局数(亮牌真的下发过)
let liangpaiRoundsNoCheck = 0; // 不查牌 + 庄家达标的局数(此时一张都不该发)
const leaks = [];
let pkts = 0, scanned = 0;
let ctxRef = null;
// 判定:seat 此刻是否有权知道 cid
function mayKnow(pj, seat, cid, rpc) {
const c = pj.cards[cid];
if (c.dealowner === -1) return true; // 规则去除的 3/4,不可能出现
if (c.playround > 0) return true; // 已打出,全场可见
if (c.dealowner === seat + 1) return true; // 自己的牌
if (seat === pj.banker && (c.dealowner === 0 || c.dealowner === seat + 1)) return true; // 庄家可见底牌/埋牌底牌
if (rpc === 'shangzhuang' && pj.call === 70 && c.dealowner === 0) return true; // §4 70分亮3秒
// §11 结束亮埋牌底牌:本局一旦结算(step6),埋牌底牌就对全场公开——jiesuan 广播本就
// 三家都发(sendpack_toother(msg,-1) 的 bottom.cards)。重连包在 step6 给出的 Balance.bottom
// 是【同一份数据的另一条投递路径】,可见性必须同判,否则会把「重连要看到与广播一样的结算面板」
// 误判成泄露。注意这里限定 step===6,不是无条件放行 playround===0
if (c.playround === 0 && (rpc === 'jiesuan' || (rpc === 'deskinfo' && pj.step === 6))) return true;
if (rpc === 'mingpai') return true; // §9 明牌:单独在下面按内容校验
// §8.2 亮牌:可查牌模式下,庄家的固定主牌牌面对闲家公开(只在 maipai / 重连包里下发)
if (!curNoCheck && (rpc === 'maipai' || rpc === 'deskinfo') && curLiangpai.has(cid)) return true;
return false;
}
function run(roomtype, call) {
const sent = [];
const o_room = {
roomtype, asetcount: 6, roomcode: 1, createtime: 'T', makewartime: 'T',
seatlist: [0, 1, 2].map(i => ({ conmode: 0, fromid: i, playerid: 100 + i, nickname: 'P', avatar: '', gameinfo: {} })),
method: { sendpack_toother: m => sent.push({ to: 'ALL', m: JSON.parse(JSON.stringify(m)) }) }
};
curNoCheck = (String(roomtype).charAt(4) === '1'); // roomtype 位4:1=不查牌
curLiangpai = new Set();
const desk = D.new(o_room); o_room.o_desk = desk;
global.youle_erqiwang.app = { SendPack: m => sent.push({ to: m.fromid, m: JSON.parse(JSON.stringify(m)) }) };
global.youle_erqiwang.import = { check_player: () => o_room, deduct_roomcard: () => { }, save_grade: () => { } };
mod.import = global.youle_erqiwang.import; mod.app = global.youle_erqiwang.app;
D.do_new_paiju(desk, 0);
const pj = desk.method.curr_paiju();
ctxRef = { pj, sent };
const audit = () => {
while (sent.length) {
const { to, m } = sent.shift();
pkts++;
const targets = to === 'ALL' ? [0, 1, 2] : [to];
const out = new Set();
collectCards(m.data, null, out);
for (const seat of targets) for (const cid of out) {
scanned++;
if (!mayKnow(pj, seat, cid, m.rpc)) {
leaks.push(`rpc=${m.rpc} → seat${seat} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`);
}
}
}
};
audit();
mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call })); audit();
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
if (pj.step === 1) { mod.jiaofen(pk(pj.method.get_callgrade_seat(), { call: 0 })); audit(); }
const b = pj.banker;
mod.xuanzhu(pk(b, { flower: 1 + rnd(4) })); audit();
// 亮牌快照在埋牌后才固定;先算出期望集合,再审计这一包(maipai 正是亮牌的下发时机)
mod.maipai(pk(b, { cards: P.get_seat_cards(pj, b).slice(-8) }));
curLiangpai = bankerLiangpaiCards(pj);
if (curLiangpai.size > 0) { if (curNoCheck) { liangpaiRoundsNoCheck++; } else { liangpaiRounds++; } }
audit();
// 重连快照也要审计(每个座位各取一次)
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
const out = new Set(); collectCards(di, null, out);
pkts++;
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
}
let guard = 0;
while (pj.step === 5 && ++guard < 400) {
const seat = pj.playproc.currseat, hand = P.get_seat_cards(pj, seat);
let pick = null;
if (seat === pj.playproc.start) pick = [hand[hand.length - 1]];
else for (const c of hand) if (A.can_followcard(pj.flower, hand, [c], pj.playproc.startcount, pj.playproc.startflower, pj.playproc.starttype).result) { pick = [c]; break; }
if (!pick) break;
mod.chupai(pk(seat, { cards: pick })); audit();
if (guard % 7 === 0) { // 中途穿插重连与明牌
for (let s = 0; s < 3; s++) {
const di = EX.new().get_deskinfo(o_room, s);
const out = new Set(); collectCards(di, null, out); pkts++;
// 阶段取【实际的】pj.step:最后一手打完后 step 已转 6,这里拿到的是结算快照,
// 写死 "step5" 会让报错信息指错阶段(曾据此误判过)
for (const cid of out) { scanned++; if (!mayKnow(pj, s, cid, 'deskinfo')) leaks.push(`deskinfo(step${pj.step}) → seat${s} 泄露牌 ${cid}(dealowner=${pj.cards[cid].dealowner} playround=${pj.cards[cid].playround})`); }
}
if (pj.method.have_baofu()) {
sent.length = 0;
mod.mingpai(pk(0));
// §9 明牌:只允许返回「另外两家未出的主牌」,多一张都算越权
for (const { m } of sent) {
if (m.rpc !== 'mingpai' || !m.data.others) continue;
for (const o of m.data.others) {
for (const cid of o.zhucards) {
scanned++;
const c = pj.cards[cid];
const isTrumpCard = c.flower === pj.flower || c.flower === 5 || c.number === 2 || c.number === 7;
if (c.dealowner !== o.seat + 1 && !(o.seat === pj.banker && c.dealowner === 0)) leaks.push(`mingpai 返回了非该座位的牌 ${cid}`);
if (c.playround !== -1) leaks.push(`mingpai 返回了已出/已埋的牌 ${cid}`);
if (!isTrumpCard) leaks.push(`mingpai 返回了非主牌 ${cid}`);
}
}
}
sent.length = 0;
}
}
}
audit();
return pj;
}
for (const rt of ['00000', '00001']) for (const call of [65, 70, 5]) run(rt, call);
// 上面 6 局按 0xBEEF01 的牌序,庄家一次都没达到亮牌门槛(§8.2)。亮牌是【有意的信息公开】,
// 必须真的被审计走过,否则「无越权泄露」对这条路径只是碰巧没触发。故换牌序再补几局:
// 可查牌局验「该发的发了且不越权」,不查牌局验「达标也一张都不发」。
seed = 0x12345;
for (const call of [65, 5]) run('00000', call);
seed = 0x777001;
run('00000', 65);
run('00001', 65);
t.eq('下发面无越权泄露', [...new Set(leaks)].slice(0, 5), []);
// 覆盖下限:若收集器失效(扫不到牌),上面的断言会假绿,这里钉住实际扫描量
t.eq('审计覆盖 下发面 ≥ 1000 个', pkts >= 1000, true);
t.eq('审计覆盖 可见性判定 ≥ 10000 次', scanned >= 10000, true);
// 覆盖下限(续):随机手牌下庄家未必达到亮牌门槛。若一局都没触发,
// 上面「无越权泄露」对亮牌这条路径就是【碰巧没走到】而不是【验证过安全】——必须钉住。
t.eq('审计覆盖 亮牌路径(可查牌) 至少 1 局', liangpaiRounds >= 1, true);
t.eq('审计覆盖 亮牌路径(不查牌) 至少 1 局', liangpaiRoundsNoCheck >= 1, true);
console.log(` [统计] 扫描 ${pkts} 个下发面 / ${scanned} 次「座位×牌」可见性判定 / 亮牌达标 可查牌${liangpaiRounds}局·不查牌${liangpaiRoundsNoCheck}局`);
process.exit(t.done('leak') ? 0 : 1);