v0.1.36b: 真根因修复——CSP unsafe-eval 拦截 Function() 字符串求值(推进崩溃+定制白屏同根)

【根因】Electron 打包 renderer CSP 禁 unsafe-eval;buildings.bonusOf / exprToTable / modManager.booleanExprTable
三处用 Function()+正则白名单字符串求值——任何调用点抛 EvalError:
·推进崩溃=每月月率 bonusOf('juling','expBonus') 等(MarketPanel craft 表头同源)
·定制白屏=craft 页渲染行内 bonusOf('danfang','craftChance') → React 崩 → 只剩背景
【修复】全新 arith.ts 纯解析器(令牌化+递归下降:数字/L/四则/括号/幂/负号——白名单天然,字母函数名一律 NaN),
三处替换零 Function/eval;非法输入 NaN 不抛(调用方 Number.isFinite 跳过)
【测试】arith 4 例(四则/幂右结合/负号/除零/非法输入防注入)+全量 7011 绿
【版本】0.1.36(同版本内提交——不另立迭代)
This commit is contained in:
2026-08-24 22:20:25 +08:00
parent 9571c58c07
commit 41cffdfdb6
4 changed files with 168 additions and 13 deletions
+124
View File
@@ -0,0 +1,124 @@
/**
* 安全算术表达式求值(0.1.36CSP 禁止 unsafe-eval —— Function()/eval 一律改为纯解析器)
*
* 支持:数字(整数/小数)、变量 L(等级)、+ - * / 、括号、幂 ^
* 白名单:解析器只认这些——任何字母(除 L)/函数名/属性访问都不会被解析,报 NaN 即跳过。
* 用法:arith('0.5 + 0.1*L', 3) === 0.8
*/
interface Tok { t: 'num' | 'op' | 'lparen' | 'rparen' | 'var'; v: number | string }
function tokenize(src: string): Tok[] | null {
const out: Tok[] = []
let i = 0
const s = src.replace(/\s+/g, '')
while (i < s.length) {
const c = s[i]
if (c >= '0' && c <= '9' || c === '.') {
let j = i
while (j < s.length && ((s[j] >= '0' && s[j] <= '9') || s[j] === '.')) j++
const num = Number(s.slice(i, j))
if (!Number.isFinite(num)) return null
out.push({ t: 'num', v: num })
i = j
} else if (c === 'L') {
out.push({ t: 'var', v: 'L' })
i++
} else if (c === '+' || c === '-' || c === '*' || c === '/' || c === '^') {
out.push({ t: 'op', v: c })
i++
} else if (c === '(') {
out.push({ t: 'lparen', v: '(' })
i++
} else if (c === ')') {
out.push({ t: 'rparen', v: ')' })
i++
} else {
return null
}
}
return out
}
/** 递归下降求值(后进先出操作符;幂右结合、乘除左结合、余运算符优先级遵循通常数学惯例) */
function evalTokens(toks: Tok[], start: number, end: number, L: number): { v: number; next: number } | null {
const peek = (p: number) => (p < toks.length ? toks[p] : null)
// 加法/减法层:最低优先级
const evalAdd = (p: number): { v: number; next: number } | null => {
let left = evalMul(p)
if (!left) return null
while (true) {
const t = peek(left.next)
if (t && t.t === 'op' && (t.v === '+' || t.v === '-')) {
const right = evalMul(left.next + 1)
if (!right) return null
left = { v: t.v === '+' ? left.v + right.v : left.v - right.v, next: right.next }
} else break
}
return left
}
// 乘除层
const evalMul = (p: number): { v: number; next: number } | null => {
let left = evalPow(p)
if (!left) return null
while (true) {
const t = peek(left.next)
if (t && t.t === 'op' && (t.v === '*' || t.v === '/')) {
const right = evalPow(left.next + 1)
if (!right) return null
if (t.v === '/' && right.v === 0) return null
left = { v: t.v === '*' ? left.v * right.v : left.v / right.v, next: right.next }
} else break
}
return left
}
// 幂层(右结合)
const evalPow = (p: number): { v: number; next: number } | null => {
const base = evalUnary(p)
if (!base) return null
const t = peek(base.next)
if (t && t.t === 'op' && t.v === '^') {
const exp = evalPow(base.next + 1)
if (!exp) return null
return { v: Math.pow(base.v, exp.v), next: exp.next }
}
return base
}
// 负号/括号层
const evalUnary = (p: number): { v: number; next: number } | null => {
const t = peek(p)
if (!t) return null
if (t.t === 'op' && t.v === '-') {
const inner = evalUnary(p + 1)
if (!inner) return null
return { v: -inner.v, next: inner.next }
}
if (t.t === 'lparen') {
const inner = evalAdd(p + 1)
if (!inner) return null
const close = peek(inner.next)
if (!close || close.t !== 'rparen') return null
return { v: inner.v, next: inner.next + 1 }
}
if (t.t === 'num') return { v: t.v as number, next: p + 1 }
if (t.t === 'var') return { v: L, next: p + 1 }
return null
}
const outer = evalAdd(start)
if (!outer || outer.next !== end) return null
return outer
}
/** 安全求值:非法/未消费完 → NaN(调用方 Number.isFinite 判定) */
export function arith(expr: string, L = 0): number {
const toks = tokenize(expr ?? '')
if (!toks || toks.length === 0) return Number.NaN
const r = evalTokens(toks, 0, toks.length, L)
if (!r) return Number.NaN
return r.v
}
+12 -7
View File
@@ -1,3 +1,5 @@
import { arith } from './arith'
export interface BuildingDef {
id: string
name: string
@@ -126,12 +128,16 @@ export function buildingById(id: string): BuildingDef | undefined {
export function listBuildingDefs(): BuildingDef[] {
return [...Object.values(BUILDINGS), ...DYN_BUILDINGS.values()]
}
/** 表达式 → 月产/升级费函数('10 * L'/'80 * 1.7^(L-1)' 白名单求值) */
/** 表达式 → 月产/升级费函数('10 * L'/'80 * 1.7^(L-1)' 白名单求值——0.1.36 起纯解析器 */
export function exprToTable(expr: string): (level: number) => Record<string, number> {
const entries = Object.entries(JSON.parse(exprCfg(expr) ?? '{}') as Record<string, string>)
return (L: number) => {
const body = Object.entries(JSON.parse(exprCfg(expr) ?? '{}')).map(([k, v]) => `"${k}":${v}`).join(',')
const fn = Function('L', `"use strict"; return { ${body} };`) as (l: number) => Record<string, number>
return fn(L)
const out: Record<string, number> = {}
for (const [k, v] of entries) {
const val = arith(String(v), L)
if (Number.isFinite(val)) out[k] = Math.round(val)
}
return out
}
}
function exprCfg(_e: string): string | null {
@@ -152,8 +158,7 @@ export function bonusOf(id: string, key: string, level: number): number {
const def = BUILDINGS[id]
const expr = def?.extra?.[key]
if (!expr) return 0
const sanitized = expr.replace(/L/g, String(level))
if (!/^[0-9.\s+\-*/()]+$/.test(sanitized)) return 0
const v = Function(`"use strict";return (${sanitized})`)() as number
// 0.1.36 CSP safe:纯解析器——不再 Function()unsafe-eval 被 Electron 打包版拦截)
const v = arith(expr, level)
return Number.isFinite(v) ? v : 0
}
@@ -8,6 +8,7 @@ import { EventDef } from '../../data/events'
import { findEvent } from './Systems/events'
import { World } from './World'
import { WorldGenPools } from '../sim/worldgen'
import { arith } from '../../data/arith'
import { pack as dataPack } from '../../data/registry'
import { removeCalamityByName, overrideWorldNum, resetWorldNum } from '../sim/worldsim-data'
import { removePillRecipe, removeForgeRecipe } from '../../data/items'
@@ -54,12 +55,8 @@ function booleanExprTable(expr: string | Record<string, string>): (level: number
return (L: number) => {
const out: Record<string, number> = {}
for (const [k, v] of entries) {
const sanitized = String(v).replace(/L/g, String(L))
if (!/^[0-9.\s+\-*/()^]+$/.test(sanitized)) continue
try {
const val = Function(`"use strict";return (${sanitized.replace(/\^/g, '**')})`)() as number
if (Number.isFinite(val)) out[k] = Math.round(val)
} catch { /* 非法表达式跳过 */ }
const val = arith(String(v), L)
if (Number.isFinite(val) && val >= 0) out[k] = Math.round(val)
}
return out
}