/** * 把「改动落盘」的保护链路对着一个真的内核跑一遍。 * * 这条链路的价值全在出错的时候,所以这里大部分场景是故意写坏配置, * 看它拦在哪一步、真文件有没有被动过、还原有没有生效。 * * 前置条件:本地测试内核正在运行(见 .env.example 的「本地开发」一节) * ./binary/mihomo-windows-amd64-v3-go125.exe -d patchbay/dev/home -f patchbay/dev/home/config.yaml * * 用法:bun run scripts/safety-net.ts */ import { copyFile, readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { readEnv } from '../server/env.ts' import { applyConfig, listBackups, readConfig } from '../server/config-store.ts' import { kernelVersion } from '../server/kernel.ts' import type { ConfigObject } from '../vendor/config-editor/index.ts' const env = readEnv() const kernel = await kernelVersion(env) if (!kernel.online) { console.error('本地测试内核没在跑。先启动它,命令见本文件顶部的注释。') process.exit(1) } console.log(`内核在线 ${kernel.version ?? ''}\n`) // 每次从模板重置,保证多次运行结果一致。 await copyFile('./dev/config.yaml', env.configPath) const clone = (v: unknown) => JSON.parse(JSON.stringify(v)) as ConfigObject let failures = 0 async function check(name: string, run: () => Promise) { const problem = await run() if (problem) { failures += 1 console.log(`✗ ${name}\n ${problem}`) } else { console.log(`✓ ${name}`) } } await check('正常改动能落盘并让内核重载', async () => { const before = await readConfig(env) const draft = clone(before.data) draft.listeners = [{ name: '容器A', type: 'mixed', port: 17891, proxy: 'AUTO' }] const result = await applyConfig(env, draft, before.revision) if (!result.ok) return `本该成功,却停在 ${result.stage}:${result.message}` if (!result.reloaded) return '文件写了但没重载内核' const onDisk = await readFile(env.configPath, 'utf8') if (!onDisk.includes('port: 17891')) return '文件里没有新增的入站' return null }) await check('未改动的行没有被动过', async () => { const onDisk = await readFile(env.configPath, 'utf8') const template = await readFile('./dev/config.yaml', 'utf8') const untouched = template.split('\n').filter((l) => l.trim() && !l.startsWith('listeners')) const missing = untouched.filter((line) => !onDisk.includes(line)) return missing.length === 0 ? null : `这些行被改动了:\n ${missing.join('\n ')}` }) await check('入站指向不存在的出站会被自己的检查拦下(内核不管这类)', async () => { const before = await readConfig(env) const draft = clone(before.data) draft.listeners = [{ name: '坏口', type: 'mixed', port: 17892, proxy: '这个出站不存在' }] const result = await applyConfig(env, draft, before.revision) if (result.ok) return '居然放行了——这正是内核 mihomo -t 会漏掉的那类错误' if (result.stage !== 'self-check') return `拦下了,但停在 ${result.stage} 而不是 self-check` const after = await readConfig(env) if (after.revision !== before.revision) return '真文件被动过了' return null }) await check('内核拒绝的配置被拦下,且真文件一个字节没动', async () => { const before = await readConfig(env) const beforeYaml = await readFile(env.configPath, 'utf8') const draft = clone(before.data) // 自己的检查只要求入站有 name 和 type,不校验 type 的取值,所以这份会走到内核那一步。 draft.listeners = [{ name: '怪口', type: '这不是合法类型', port: 17893 }] const result = await applyConfig(env, draft, before.revision) if (result.ok) return '内核居然接受了一个非法的入站类型' if (result.stage !== 'kernel-check') return `停在 ${result.stage} 而不是 kernel-check` if (!result.message.trim()) return '拦下了但没给出内核的原话,用户会不知道哪里错了' const afterYaml = await readFile(env.configPath, 'utf8') if (afterYaml !== beforeYaml) return '真文件被动过了——校验本该在临时文件上做' return null }) await check('基准过时的改动会被拒绝,不会覆盖别人的修改', async () => { const before = await readConfig(env) const draft = clone(before.data) draft.mode = 'global' const result = await applyConfig(env, draft, '这是一个过时的指纹') if (result.ok) return '居然放行了,别人的改动会被悄悄覆盖' if (result.stage !== 'stale') return `停在 ${result.stage} 而不是 stale` return null }) await check('成功的改动会留下备份', async () => { const backups = await listBackups(env) if (backups.length === 0) return '一个备份都没有' const newest = await readFile(join(dirname(env.configPath), '.patchbay-backups', backups[0]!), 'utf8') if (newest.includes('port: 17891')) return '备份里已经含有新改动,说明备份晚于写入' return null }) console.log(failures === 0 ? '\n全部通过' : `\n${String(failures)} 项没通过`) process.exit(failures === 0 ? 0 : 1)