@noob-stupid/dsh-plugin-console 0.5.31 → 0.5.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -10
- package/README.zh.md +63 -5
- package/lib/client.js +85 -53
- package/lib/server/domain/auto-disable.js +226 -0
- package/lib/server/domain/auto-preflight-run.js +184 -0
- package/lib/server/domain/auto-preflight.js +345 -0
- package/lib/server/domain/compat-state.js +57 -8
- package/lib/server/domain/compat.js +10 -60
- package/lib/server/domain/patch-yaml-check.js +151 -0
- package/lib/server/domain/peer-veto.js +103 -0
- package/lib/server/domain/safe-boot.js +520 -0
- package/lib/server/domain/settings-api-scan.js +251 -0
- package/lib/server/routes/compat.js +240 -11
- package/lib/server/routes/framework-preflight.js +14 -1
- package/lib/server/routes/index.js +4 -0
- package/lib/server/routes/plugins.js +3 -1
- package/lib/server/routes/safe-boot.js +139 -0
- package/lib/server/routes/state.js +17 -3
- package/package.json +1 -1
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
// L1 · domain —— safe-boot.js(**安全启动材料**:last-known-good 快照 + 恢复判定;0.5.32 加法 D2/D3)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要有它(用户 2026-09-29 诉求,原话):
|
|
4
|
+
// 「走别的更新通道改了框架后…**能挽救"改完打不开"**的局面」。
|
|
5
|
+
// 现状(0.5.31):兜底(启动失败隔离)的输入是我们**自己的升级脚本**留下的记录 —— 官方桌面端更新器 /
|
|
6
|
+
// 手动 pnpm / npx 缓存变化导致的"打不开",既没有那份记录,也没有可回退的"上一次好状态"。
|
|
7
|
+
// 本模块补上这块材料:
|
|
8
|
+
// · D2:每次确认服务正常(状态查询成功 + 指纹未变)就把**逐字节补丁 + 框架版本 + 环境指纹 +
|
|
9
|
+
// 此刻启用/禁用的行清单 + 时间戳**写一份快照,落在 `dshHome()/plugin-console/safe-boot/snapshots/`
|
|
10
|
+
// (**不是** profile 目录 —— 绝不污染用户 profile);写入**原子**(临时文件 + rename)并记 sha256。
|
|
11
|
+
// · D3:恢复入口的**判定与落盘原语**(路由与离线脚本共用同一套):`restoreLastGood`(改前再备份 +
|
|
12
|
+
// 读回核实 + 逐字节比对)/ `disableSuspects`(**只**给点名行追加 `disabled: true`,不删任何行)。
|
|
13
|
+
//
|
|
14
|
+
// 保留策略:最近 AUTO_KEEP 份 + **永不删**「用户标记为良好」的那份;标记的那份另记 lastGoodId,
|
|
15
|
+
// 即便被手动清理,恢复也会如实报"找不到"而不是默默换一份。
|
|
16
|
+
//
|
|
17
|
+
// 分层:L1 domain —— 无 ctx、无 HTTP;IO 全部走注入的默认实现(测试可整段替换为内存实现)。
|
|
18
|
+
|
|
19
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
22
|
+
import { CORE_PATCH_ROW_IDS } from './patch.js'
|
|
23
|
+
import { parsePatchRows } from './patch-composition-audit.js'
|
|
24
|
+
import { validatePatchYaml } from './patch-yaml-check.js'
|
|
25
|
+
import { dshHome } from '../infra/paths.js'
|
|
26
|
+
|
|
27
|
+
/** 快照根(我们自己的目录,位于 `dshHome()/plugin-console/` 下,与用户 profile 无关)。
|
|
28
|
+
* ★ 默认值必须在**调用时**求值(不能写成模块级常量):离线脚本 `scripts/safe-boot.mjs --home <dir>`
|
|
29
|
+
* 是在导入之后才设置 DSH_HOME 的,模块级常量会把默认 home 提前钉死在导入那一刻(真机实测踩到)。 */
|
|
30
|
+
const safeBootDir = (home = undefined) => join(home ?? dshHome(), 'plugin-console', 'safe-boot')
|
|
31
|
+
const snapshotsDir = (home = undefined) => join(safeBootDir(home), 'snapshots')
|
|
32
|
+
const safeBootStateFile = (home = undefined) => join(safeBootDir(home), 'state.json')
|
|
33
|
+
|
|
34
|
+
/** 默认保留:最近 3 份自动快照(用户手动标记为良好的那份永不删)。 */
|
|
35
|
+
const AUTO_KEEP = 3
|
|
36
|
+
|
|
37
|
+
/** 快照类别:auto = 每次确认正常时自动落;good = 用户显式标记为"良好"(永不删)。 */
|
|
38
|
+
const SNAPSHOT_KINDS = ['auto', 'good']
|
|
39
|
+
|
|
40
|
+
const sha256 = (text) => createHash('sha256').update(typeof text === 'string' ? Buffer.from(text, 'utf8') : text).digest('hex')
|
|
41
|
+
|
|
42
|
+
/** 文件名里安全的时间戳(冒号/点在 Windows 上是非法字符)。 */
|
|
43
|
+
function stampOf(ms) {
|
|
44
|
+
return new Date(ms).toISOString().replace(/[:.]/gu, '-')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 指纹等价判定(只看参与变更判定的三要素,忽略 `at`;win32 路径大小写不敏感 → 小写归一)。
|
|
49
|
+
* 与 `domain/auto-preflight.js#fingerprintKey` 同源判据,但这里保持独立实现,
|
|
50
|
+
* 避免「快照等价」被自动预检的节流语义牵着走(两者的失败模式不同)。
|
|
51
|
+
*/
|
|
52
|
+
function sameEnv(a, b) {
|
|
53
|
+
const norm = (v, lower = false) => {
|
|
54
|
+
if (v === null || v === undefined) return ''
|
|
55
|
+
const t = String(v).trim()
|
|
56
|
+
return lower ? t.toLowerCase() : t
|
|
57
|
+
}
|
|
58
|
+
const ent = (v) => (v === null || v === undefined ? '?' : String(v))
|
|
59
|
+
return norm(a?.frameworkVersion) === norm(b?.frameworkVersion)
|
|
60
|
+
&& norm(a?.pnpmRoot, true) === norm(b?.pnpmRoot, true)
|
|
61
|
+
&& ent(a?.pnpmEntities) === ent(b?.pnpmEntities)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 保留策略(纯函数):最近 `keep` 份 **auto** 快照 + 全部 `good` 快照;其余进 `removed`。
|
|
66
|
+
* @param {Array<{id:string,at:number,kind?:string}>} snapshots
|
|
67
|
+
* @returns {{ keep: string[], removed: string[] }}
|
|
68
|
+
*/
|
|
69
|
+
function applyRetention(snapshots, keep = AUTO_KEEP) {
|
|
70
|
+
const list = (Array.isArray(snapshots) ? snapshots : []).filter((s) => s !== null && typeof s === 'object' && typeof s.id === 'string')
|
|
71
|
+
const newestFirst = [...list].sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0))
|
|
72
|
+
const auto = newestFirst.filter((s) => s.kind !== 'good')
|
|
73
|
+
const good = newestFirst.filter((s) => s.kind === 'good')
|
|
74
|
+
const keptAuto = auto.slice(0, Math.max(0, Number(keep) || 0))
|
|
75
|
+
const keepIds = new Set([...keptAuto, ...good].map((s) => s.id))
|
|
76
|
+
return {
|
|
77
|
+
keep: newestFirst.filter((s) => keepIds.has(s.id)).map((s) => s.id),
|
|
78
|
+
removed: newestFirst.filter((s) => !keepIds.has(s.id)).map((s) => s.id),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 从快照列表里挑「用于恢复」的那一份:用户标记的 good 优先,其次最新。 */
|
|
83
|
+
function pickSnapshot(snapshots, { id = null } = {}) {
|
|
84
|
+
const list = (Array.isArray(snapshots) ? snapshots : []).filter((s) => s !== null && typeof s === 'object' && typeof s.id === 'string')
|
|
85
|
+
if (typeof id === 'string' && id !== '') return list.find((s) => s.id === id) ?? null
|
|
86
|
+
const good = list.filter((s) => s.kind === 'good').sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0))
|
|
87
|
+
if (good.length > 0) return good[0]
|
|
88
|
+
const sorted = [...list].sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0))
|
|
89
|
+
return sorted[0] ?? null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 快照的对外摘要(**不含** patchText —— 列表接口不该把几十 KB 补丁塞给面板)。 */
|
|
93
|
+
function snapshotView(rec) {
|
|
94
|
+
if (rec === null || rec === undefined) return null
|
|
95
|
+
return {
|
|
96
|
+
id: rec.id ?? null,
|
|
97
|
+
kind: rec.kind ?? 'auto',
|
|
98
|
+
at: rec.at ?? null,
|
|
99
|
+
createdAt: rec.createdAt ?? null,
|
|
100
|
+
frameworkVersion: rec.frameworkVersion ?? null,
|
|
101
|
+
fingerprint: rec.fingerprint ?? null,
|
|
102
|
+
patchSha256: rec.patchSha256 ?? null,
|
|
103
|
+
patchBytes: typeof rec.patchText === 'string' ? Buffer.byteLength(rec.patchText, 'utf8') : (rec.patchBytes ?? null),
|
|
104
|
+
patchPath: rec.patchPath ?? null,
|
|
105
|
+
rowCount: rec.rows?.all?.length ?? 0,
|
|
106
|
+
enabledCount: rec.rows?.enabled?.length ?? 0,
|
|
107
|
+
disabledCount: rec.rows?.disabled?.length ?? 0,
|
|
108
|
+
enabled: rec.rows?.enabled ?? [],
|
|
109
|
+
disabled: rec.rows?.disabled ?? [],
|
|
110
|
+
reasons: rec.reasons ?? [],
|
|
111
|
+
note: rec.note ?? null,
|
|
112
|
+
restoreCommand: rec.restoreCommand ?? null,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 读 patches 的启用/禁用行清单(补丁层视角:谁被 `disabled: true` 明确关掉)。 */
|
|
117
|
+
function patchRowState(text) {
|
|
118
|
+
const lines = String(text ?? '').replace(/\r\n/gu, '\n').split('\n')
|
|
119
|
+
const disabled = []
|
|
120
|
+
const forced = []
|
|
121
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
122
|
+
const m = lines[i].match(/^- id:\s*(\S+)\s*$/u)
|
|
123
|
+
if (m === null) continue
|
|
124
|
+
const next = lines[i + 1] ?? ''
|
|
125
|
+
if (/^\s+disabled:\s*true\s*$/u.test(next)) disabled.push(m[1])
|
|
126
|
+
else if (/^\s+disabled:\s*false\s*$/u.test(next)) forced.push(m[1])
|
|
127
|
+
}
|
|
128
|
+
const ins = []
|
|
129
|
+
for (const row of parsePatchRows(text)) if (typeof row.id === 'string' && row.id !== '') ins.push(row.id)
|
|
130
|
+
return { disabled, forced, all: ins }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 某行此刻是否已被 `disabled: true` 明确关掉(幂等判据)。 */
|
|
134
|
+
function isRowDisabled(text, rowId) {
|
|
135
|
+
return new RegExp(`^- id: ${escapeRe(rowId)}\\r?\\n\\s+disabled: true\\s*$`, 'mu').test(String(text ?? '').replace(/\r\n/gu, '\n'))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function escapeRe(text) {
|
|
139
|
+
return String(text).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `disable-suspects` 的纯判定:**只**给点名行追加 `disabled: true`,一行都不删。
|
|
144
|
+
* 不写的六种情形(如实回报,绝不静默):核心行 / 控制台自身 / 补丁里没有这一行 / 本来就是禁用的 /
|
|
145
|
+
* **框架自带行(`@deepseek-ai/*`,D5 安全栏)** / **受保护模块行(D5 安全栏)**。
|
|
146
|
+
*
|
|
147
|
+
* ⚠ 后两条是 D5 的关键(用户 2026-09-29 明确要求「必须带上 0.5.30 已立的安全栏」):
|
|
148
|
+
* 自动禁用的**触发源**从"我们升级"扩到了"指纹变化 / 启动失败记录",所以写盘路径必须自带这两道闸门
|
|
149
|
+
* —— 不能指望调用方每次都记得传对名单。手动点名(D3 路由 / 离线脚本)也一并受保护:
|
|
150
|
+
* 点错了最多是"没生效 + 原因可读",而不是把框架自己的行关掉。
|
|
151
|
+
*/
|
|
152
|
+
function planDisableRows(text, rowIds, { coreRowIds = CORE_PATCH_ROW_IDS, moduleNameOf = null, isProtected = null, isFrameworkOwned = null } = {}) {
|
|
153
|
+
const source = String(text ?? '')
|
|
154
|
+
const rows = parsePatchRows(source)
|
|
155
|
+
const known = new Map(rows.map((r) => [r.id, r]))
|
|
156
|
+
const add = []
|
|
157
|
+
const skipped = []
|
|
158
|
+
for (const raw of (Array.isArray(rowIds) ? rowIds : [])) {
|
|
159
|
+
const rowId = typeof raw === 'string' ? raw.trim() : ''
|
|
160
|
+
if (rowId === '') continue
|
|
161
|
+
if (coreRowIds.has(rowId)) { skipped.push({ rowId, reason: 'core-row' }); continue }
|
|
162
|
+
if (rowId === 'plugin-console') { skipped.push({ rowId, reason: 'self' }); continue }
|
|
163
|
+
if (!known.has(rowId)) { skipped.push({ rowId, reason: 'not-in-patch' }); continue }
|
|
164
|
+
const moduleName = (typeof moduleNameOf === 'function' ? moduleNameOf(rowId) : null) ?? known.get(rowId)?.name ?? null
|
|
165
|
+
if (typeof isFrameworkOwned === 'function' && moduleName !== null && isFrameworkOwned(moduleName) === true) {
|
|
166
|
+
skipped.push({ rowId, reason: 'framework-owned' }); continue
|
|
167
|
+
}
|
|
168
|
+
if (typeof isProtected === 'function' && moduleName !== null && isProtected(moduleName) === true) {
|
|
169
|
+
skipped.push({ rowId, reason: 'protected-module' }); continue
|
|
170
|
+
}
|
|
171
|
+
if (add.some((a) => a.rowId === rowId)) continue
|
|
172
|
+
if (isRowDisabled(source, rowId)) { skipped.push({ rowId, reason: 'already-disabled' }); continue }
|
|
173
|
+
add.push({ rowId, line: known.get(rowId)?.line ?? null, moduleName })
|
|
174
|
+
}
|
|
175
|
+
if (add.length === 0) return { add, skipped, next: source, changed: false }
|
|
176
|
+
const base = source.replace(/\s+$/u, '')
|
|
177
|
+
const blocks = add.map((a) => `- id: ${a.rowId}\n disabled: true\n`).join('')
|
|
178
|
+
return { add, skipped, next: `${base}\n${blocks}`, changed: true }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* 恢复的**计划**(纯函数):给定快照文本与当前文本,算出「要不要写、写什么、影响面」。
|
|
183
|
+
* 恢复不做任何"智能合并":目标就是快照的**逐字节**内容(严格 YAML 校验只当闸门,不做改写)。
|
|
184
|
+
*/
|
|
185
|
+
function planRestore({ snapshotText = null, currentText = '', snapshot = null } = {}) {
|
|
186
|
+
if (typeof snapshotText !== 'string') return { ok: false, code: 'snapshot-missing', error: '找不到可用快照(没有快照,或指定 id 不存在)' }
|
|
187
|
+
const expected = snapshot?.patchSha256 ?? null
|
|
188
|
+
const actual = sha256(snapshotText)
|
|
189
|
+
if (expected !== null && expected !== actual) {
|
|
190
|
+
return { ok: false, code: 'snapshot-corrupt', error: `快照内容与它记录的 sha256 不一致(记录 ${String(expected).slice(0, 12)}…,实际 ${actual.slice(0, 12)}…)—— 拒绝用被改坏的快照恢复`, sha256: actual }
|
|
191
|
+
}
|
|
192
|
+
const check = validatePatchYaml(snapshotText)
|
|
193
|
+
if (check.ok !== true) {
|
|
194
|
+
return { ok: false, code: 'snapshot-invalid-yaml', error: `快照不是合法补丁结构(拒绝写入):${check.problems.slice(0, 3).join(';')}`, problems: check.problems }
|
|
195
|
+
}
|
|
196
|
+
if (snapshotText === currentText) {
|
|
197
|
+
return { ok: true, code: 'already-identical', changed: false, text: snapshotText, sha256: actual, problems: [] }
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, code: 'restore', changed: true, text: snapshotText, sha256: actual, problems: [] }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 写一份快照(**原子**:同目录临时文件 + rename;fsync 后 rename)。
|
|
204
|
+
* 相同环境**且**补丁逐字节未变时**零写盘**(节流:面板每次刷新都会调状态查询)。
|
|
205
|
+
* @returns {{ ok: boolean, written: boolean, reason?: string, snapshot?: object, error?: string }}
|
|
206
|
+
*/
|
|
207
|
+
function writeSnapshot({
|
|
208
|
+
patchPath, patchText = null, fingerprint = null, frameworkVersion = null, reasons = [],
|
|
209
|
+
kind = 'auto', note = null, force = false, home = undefined, keep = AUTO_KEEP, now = () => Date.now(),
|
|
210
|
+
pluginVersion = null, restoreCommand = 'node scripts/safe-boot.mjs --restore-last-good',
|
|
211
|
+
readFile = (p) => readFileSync(p, 'utf8'), writeFile = (p, d) => writeFileSync(p, d, 'utf8'),
|
|
212
|
+
rename = (a, b) => renameSync(a, b), mkdir = (d) => mkdirSync(d, { recursive: true }),
|
|
213
|
+
readDir = (d) => readdirSync(d), remove = (p) => rmSync(p, { force: true }), exists = existsSync,
|
|
214
|
+
} = {}) {
|
|
215
|
+
try {
|
|
216
|
+
if (typeof patchPath !== 'string' || patchPath === '') return { ok: false, written: false, error: '没有补丁路径,无法保存安全启动材料' }
|
|
217
|
+
const text = typeof patchText === 'string' ? patchText : (exists(patchPath) ? readFile(patchPath) : '')
|
|
218
|
+
const bytes = Buffer.byteLength(text, 'utf8')
|
|
219
|
+
const hash = sha256(text)
|
|
220
|
+
const dir = snapshotsDir(home)
|
|
221
|
+
mkdir(dir)
|
|
222
|
+
const records = readSnapshotDir({ home, readDir, readFile })
|
|
223
|
+
if (force !== true) {
|
|
224
|
+
const latest = pickSnapshot(records)
|
|
225
|
+
if (latest !== null && sameEnv(latest.fingerprint, fingerprint) && latest.patchSha256 === hash && latest.patchBytes === bytes) {
|
|
226
|
+
return { ok: true, written: false, reason: 'unchanged', snapshot: snapshotView(latest) }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const at = now()
|
|
230
|
+
// 同一毫秒内可能连写多份(例如"恢复后再立刻快照"):文件名必须**唯一**,
|
|
231
|
+
// 否则第二份会静默覆盖第一份 —— 保留策略看起来生效、实际少了一份(真机会话里踩过同类坑)。
|
|
232
|
+
const uniqueId = (base) => {
|
|
233
|
+
const taken = new Set(records.map((r) => r.id))
|
|
234
|
+
if (!taken.has(base) && !exists(join(dir, `${base}.json`))) return base
|
|
235
|
+
for (let n = 2; n < 1000; n += 1) {
|
|
236
|
+
const candidate = `${base}-${n}`
|
|
237
|
+
if (!taken.has(candidate) && !exists(join(dir, `${candidate}.json`))) return candidate
|
|
238
|
+
}
|
|
239
|
+
return base
|
|
240
|
+
}
|
|
241
|
+
const id = uniqueId(`snap-${stampOf(at)}`)
|
|
242
|
+
const check = validatePatchYaml(text)
|
|
243
|
+
const record = {
|
|
244
|
+
schema: 1,
|
|
245
|
+
id,
|
|
246
|
+
kind: SNAPSHOT_KINDS.includes(kind) ? kind : 'auto',
|
|
247
|
+
at,
|
|
248
|
+
createdAt: new Date(at).toISOString(),
|
|
249
|
+
frameworkVersion: frameworkVersion ?? fingerprint?.frameworkVersion ?? null,
|
|
250
|
+
fingerprint: fingerprint ?? null,
|
|
251
|
+
reasons: Array.isArray(reasons) ? reasons : [],
|
|
252
|
+
patchPath,
|
|
253
|
+
patchSha256: hash,
|
|
254
|
+
patchBytes: bytes,
|
|
255
|
+
rows: (() => { const st = patchRowState(text); return { all: st.all, disabled: st.disabled, forced: st.forced, enabled: st.all.filter((id2) => !st.disabled.includes(id2)) } })(),
|
|
256
|
+
yamlCheck: { ok: check.ok, problems: check.problems.slice(0, 5) },
|
|
257
|
+
note: typeof note === 'string' ? note : null,
|
|
258
|
+
restoreCommand,
|
|
259
|
+
pluginVersion,
|
|
260
|
+
patchText: text,
|
|
261
|
+
}
|
|
262
|
+
const file = join(dir, `${id}.json`)
|
|
263
|
+
const tmp = join(dir, `.${id}.tmp`)
|
|
264
|
+
writeFile(tmp, `${JSON.stringify(record, null, 2)}\n`)
|
|
265
|
+
rename(tmp, file)
|
|
266
|
+
// 读回核实:文件真的在、且能解析出同一份内容(否则如实报失败,别假装存好了)
|
|
267
|
+
let verified = null
|
|
268
|
+
try { verified = JSON.parse(readFile(file)) } catch (error) { verified = null }
|
|
269
|
+
if (verified === null || verified.patchSha256 !== hash) {
|
|
270
|
+
return { ok: false, written: false, error: `快照写盘后读回核实失败(${file})`, snapshot: null }
|
|
271
|
+
}
|
|
272
|
+
// 保留策略
|
|
273
|
+
const after = readSnapshotDir({ home, readDir, readFile })
|
|
274
|
+
const { removed } = applyRetention(after, keep)
|
|
275
|
+
for (const victim of removed) { try { remove(join(dir, `${victim}.json`)) } catch {} }
|
|
276
|
+
return { ok: true, written: true, snapshot: snapshotView(verified) }
|
|
277
|
+
} catch (error) {
|
|
278
|
+
return { ok: false, written: false, error: `快照写入失败:${error instanceof Error ? error.message : String(error)}` }
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 读快照目录(按文件名解析;坏文件跳过,绝不让一份坏快照挡住列表)。
|
|
283
|
+
* `withPatch: false` = 只列表用:**丢掉补丁正文**再返回(几十 KB/份,状态查询每次都会调,
|
|
284
|
+
* 保留正文纯属浪费内存与 GC 压力)。 */
|
|
285
|
+
function readSnapshotDir({ home = undefined, readDir = (d) => readdirSync(d), readFile = (p) => readFileSync(p, 'utf8'), withPatch = true } = {}) {
|
|
286
|
+
const dir = snapshotsDir(home)
|
|
287
|
+
let names = []
|
|
288
|
+
try { names = readDir(dir) } catch { return [] }
|
|
289
|
+
const out = []
|
|
290
|
+
for (const name of names) {
|
|
291
|
+
if (typeof name !== 'string' || !/^snap-.*\.json$/u.test(name)) continue
|
|
292
|
+
try {
|
|
293
|
+
const rec = JSON.parse(readFile(join(dir, name)))
|
|
294
|
+
if (rec !== null && typeof rec === 'object' && typeof rec.id === 'string') out.push(withPatch === true ? rec : { ...rec, patchText: undefined })
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
return out.sort((a, b) => Number(b.at ?? 0) - Number(a.at ?? 0))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** 列出快照摘要(新→旧;只读元数据,不返回补丁正文)。 */
|
|
301
|
+
function listSnapshots({ home = undefined, readDir, readFile } = {}) {
|
|
302
|
+
return readSnapshotDir({ home, readDir, readFile, withPatch: false }).map(snapshotView)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 确保「当前这份补丁」有快照可回退(D5 的安全栏:**每一次自动禁用之前**都必须有可一键回滚的材料)。
|
|
307
|
+
* 判据很直白:最新快照的 sha256 与当前补丁一致 → 已覆盖,零写盘;不一致 → **强制**写一份
|
|
308
|
+
* (自动禁用改的是**当前**状态;拿一份更早的快照"顶着"等于回滚不到禁用前的样子)。
|
|
309
|
+
* @returns {{ ok: boolean, covered: boolean, written: boolean, id: string|null, error?: string }}
|
|
310
|
+
*/
|
|
311
|
+
function ensureSnapshotForCurrentState({
|
|
312
|
+
patchPath, patchText = null, fingerprint = null, frameworkVersion = null, reasons = [],
|
|
313
|
+
note = '自动禁用前的安全快照', home = undefined, readFile, readDir, exists = existsSync, ...rest
|
|
314
|
+
} = {}) {
|
|
315
|
+
try {
|
|
316
|
+
const read = readFile ?? ((p) => readFileSync(p, 'utf8'))
|
|
317
|
+
const text = typeof patchText === 'string' ? patchText : (exists(patchPath) ? read(patchPath) : '')
|
|
318
|
+
const hash = sha256(text)
|
|
319
|
+
const latest = pickSnapshot(readSnapshotDir({ home, readDir, readFile }))
|
|
320
|
+
if (latest !== null && latest.patchSha256 === hash) return { ok: true, covered: true, written: false, id: latest.id ?? null }
|
|
321
|
+
const result = writeSnapshot({
|
|
322
|
+
patchPath, patchText: text, fingerprint, frameworkVersion, reasons, kind: 'auto',
|
|
323
|
+
note, force: true, home, readFile, readDir, ...rest,
|
|
324
|
+
})
|
|
325
|
+
if (result.ok !== true) return { ok: false, covered: false, written: false, id: null, error: result.error ?? '快照写入失败' }
|
|
326
|
+
return { ok: true, covered: true, written: result.written === true, id: result.snapshot?.id ?? null }
|
|
327
|
+
} catch (error) {
|
|
328
|
+
return { ok: false, covered: false, written: false, id: null, error: `快照失败:${error instanceof Error ? error.message : String(error)}` }
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** 读 safe-boot 状态(lastGoodId / lastAutoId / 最近一次写入时间)。 */
|
|
333
|
+
function readSafeBootState({ home = undefined, readFile = (p) => readFileSync(p, 'utf8') } = {}) {
|
|
334
|
+
try {
|
|
335
|
+
const rec = JSON.parse(readFile(safeBootStateFile(home)))
|
|
336
|
+
return {
|
|
337
|
+
lastGoodId: typeof rec?.lastGoodId === 'string' ? rec.lastGoodId : null,
|
|
338
|
+
lastAutoId: typeof rec?.lastAutoId === 'string' ? rec.lastAutoId : null,
|
|
339
|
+
lastAutoAt: typeof rec?.lastAutoAt === 'number' ? rec.lastAutoAt : null,
|
|
340
|
+
lastGoodAt: typeof rec?.lastGoodAt === 'number' ? rec.lastGoodAt : null,
|
|
341
|
+
lastAutoPatchSha256: typeof rec?.lastAutoPatchSha256 === 'string' ? rec.lastAutoPatchSha256 : null,
|
|
342
|
+
}
|
|
343
|
+
} catch {
|
|
344
|
+
return { lastGoodId: null, lastAutoId: null, lastAutoAt: null, lastGoodAt: null, lastAutoPatchSha256: null }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 记录「安全启动材料」的状态(lastGoodId / lastAutoId)—— 只记 id 与哈希,不重复存补丁。
|
|
350
|
+
* 写盘失败不抛(调用方是只读状态查询)。
|
|
351
|
+
*/
|
|
352
|
+
function writeSafeBootState(patch, { home = undefined, writeFile = (p, d) => writeFileSync(p, d, 'utf8'), mkdir = (d) => mkdirSync(d, { recursive: true }), rename = (a, b) => renameSync(a, b) } = {}) {
|
|
353
|
+
try {
|
|
354
|
+
const dir = safeBootDir(home)
|
|
355
|
+
mkdir(dir)
|
|
356
|
+
const file = safeBootStateFile(home)
|
|
357
|
+
const tmp = `${file}.tmp`
|
|
358
|
+
writeFile(tmp, `${JSON.stringify({ schema: 1, ...patch }, null, 2)}\n`)
|
|
359
|
+
rename(tmp, file)
|
|
360
|
+
return { ok: true }
|
|
361
|
+
} catch (error) {
|
|
362
|
+
return { ok: false, error: `状态写入失败:${error instanceof Error ? error.message : String(error)}` }
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* D3-① 恢复到 last-known-good(**改前再备份一份当前状态** + 读回核实 + 逐字节比对)。
|
|
368
|
+
* 用户可指定 `id`;缺省 = 用户标记的 good 快照优先,否则最新一份(避免恢复到一个更早的"好状态")。
|
|
369
|
+
*/
|
|
370
|
+
function restoreLastGood({
|
|
371
|
+
patchPath, id = null, home = undefined, now = () => Date.now(),
|
|
372
|
+
readFile = (p) => readFileSync(p, 'utf8'), writeFile = (p, d) => writeFileSync(p, d, 'utf8'),
|
|
373
|
+
copyFile = (a, b) => copyFileSync(a, b), exists = existsSync, readDir,
|
|
374
|
+
} = {}) {
|
|
375
|
+
try {
|
|
376
|
+
if (typeof patchPath !== 'string' || patchPath === '') return { ok: false, code: 'no-patch-path', error: '没有补丁路径,无法恢复' }
|
|
377
|
+
const records = readSnapshotDir({ home, readDir, readFile })
|
|
378
|
+
const target = pickSnapshot(records, { id })
|
|
379
|
+
if (target === null) {
|
|
380
|
+
return { ok: false, code: 'snapshot-missing', error: id === null ? '还没有任何安全启动快照(服务从未在正常状态下记录过)' : `找不到快照 ${id}` }
|
|
381
|
+
}
|
|
382
|
+
const currentText = exists(patchPath) ? readFile(patchPath) : ''
|
|
383
|
+
const plan = planRestore({ snapshotText: target.patchText, currentText, snapshot: target })
|
|
384
|
+
if (plan.ok !== true) return { ok: false, ...plan }
|
|
385
|
+
const backupPath = exists(patchPath) ? `${patchPath}.bak-safe-boot-${Date.now()}` : null
|
|
386
|
+
if (backupPath !== null) {
|
|
387
|
+
try { copyFile(patchPath, backupPath) } catch (error) {
|
|
388
|
+
return { ok: false, code: 'backup-failed', error: `改前备份失败,已放弃恢复:${error instanceof Error ? error.message : String(error)}`, snapshot: snapshotView(target) }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (plan.changed === false) {
|
|
392
|
+
return {
|
|
393
|
+
ok: true, code: 'already-identical', changed: false, backupPath, snapshot: snapshotView(target),
|
|
394
|
+
patchPath, sha256: plan.sha256, note: { zh: '当前补丁与快照逐字节一致,无需恢复(已备份当前状态)', en: 'Patch already identical to the snapshot — nothing to restore (current state backed up)' },
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const tmp = `${patchPath}.safe-boot-tmp-${Date.now()}`
|
|
398
|
+
try {
|
|
399
|
+
writeFile(tmp, plan.text)
|
|
400
|
+
renameSync(tmp, patchPath)
|
|
401
|
+
} catch (error) {
|
|
402
|
+
try { rmSync(tmp, { force: true }) } catch {}
|
|
403
|
+
return { ok: false, code: 'write-failed', error: `写入失败(原文件未改动):${error instanceof Error ? error.message : String(error)}`, backupPath }
|
|
404
|
+
}
|
|
405
|
+
const back = exists(patchPath) ? readFile(patchPath) : null
|
|
406
|
+
const verified = back === plan.text && sha256(back) === target.patchSha256
|
|
407
|
+
if (verified !== true) {
|
|
408
|
+
return { ok: false, code: 'verify-failed', error: '写入后读回核实失败:内容与快照不一致(当前状态已备份,可用备份回退)', backupPath, patchPath }
|
|
409
|
+
}
|
|
410
|
+
const check = validatePatchYaml(back)
|
|
411
|
+
return {
|
|
412
|
+
ok: true, code: 'restored', changed: true, backupPath, patchPath,
|
|
413
|
+
sha256: target.patchSha256, snapshot: snapshotView(target),
|
|
414
|
+
yamlCheck: { ok: check.ok, problems: check.problems },
|
|
415
|
+
note: { zh: '已按快照逐字节恢复(改前状态已另存备份)', en: 'Restored byte-for-byte from the snapshot (previous state was backed up)' },
|
|
416
|
+
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
return { ok: false, code: 'restore-failed', error: `恢复失败:${error instanceof Error ? error.message : String(error)}` }
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* D3-② 只禁用点名的可疑行(**不删任何行**):改前备份 → 逐行追加 `disabled: true` → 严格 YAML 校验 →
|
|
424
|
+
* 原子写盘 → 读回核实。`rowIds` 必须由调用方显式给出(D1 的隔离计划 / 用户点名),本函数不猜。
|
|
425
|
+
*/
|
|
426
|
+
function disableSuspects({
|
|
427
|
+
patchPath, rowIds = [], home = undefined, coreRowIds = CORE_PATCH_ROW_IDS,
|
|
428
|
+
moduleNameOf = null, isProtected = null, isFrameworkOwned = null,
|
|
429
|
+
readFile = (p) => readFileSync(p, 'utf8'), writeFile = (p, d) => writeFileSync(p, d, 'utf8'),
|
|
430
|
+
copyFile = (a, b) => copyFileSync(a, b), exists = existsSync,
|
|
431
|
+
} = {}) {
|
|
432
|
+
try {
|
|
433
|
+
if (typeof patchPath !== 'string' || patchPath === '') return { ok: false, code: 'no-patch-path', error: '没有补丁路径,无法禁用' }
|
|
434
|
+
if (!exists(patchPath)) return { ok: false, code: 'patch-missing', error: `补丁文件不存在:${patchPath}` }
|
|
435
|
+
const currentText = readFile(patchPath)
|
|
436
|
+
const plan = planDisableRows(currentText, rowIds, { coreRowIds, moduleNameOf, isProtected, isFrameworkOwned })
|
|
437
|
+
if (plan.changed !== true) {
|
|
438
|
+
return {
|
|
439
|
+
ok: true, code: 'noop', changed: false, added: [], skipped: plan.skipped, backupPath: null,
|
|
440
|
+
note: { zh: '没有需要禁用的行(点名行都已禁用 / 不在补丁里 / 属于核心行或受保护行)', en: 'Nothing to disable (already disabled, not in the patch, or a core/protected row)' },
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const check = validatePatchYaml(plan.next)
|
|
444
|
+
if (check.ok !== true) {
|
|
445
|
+
return { ok: false, code: 'invalid-yaml', error: `禁用后会写出非法补丁结构,已放弃(一个字节都没改):${check.problems.slice(0, 3).join(';')}`, problems: check.problems, added: plan.add, skipped: plan.skipped }
|
|
446
|
+
}
|
|
447
|
+
const backupPath = `${patchPath}.bak-safe-boot-${Date.now()}`
|
|
448
|
+
try { copyFile(patchPath, backupPath) } catch (error) {
|
|
449
|
+
return { ok: false, code: 'backup-failed', error: `改前备份失败,已放弃禁用:${error instanceof Error ? error.message : String(error)}` }
|
|
450
|
+
}
|
|
451
|
+
const tmp = `${patchPath}.safe-boot-tmp-${Date.now()}`
|
|
452
|
+
try {
|
|
453
|
+
writeFile(tmp, plan.next)
|
|
454
|
+
renameSync(tmp, patchPath)
|
|
455
|
+
} catch (error) {
|
|
456
|
+
try { rmSync(tmp, { force: true }) } catch {}
|
|
457
|
+
return { ok: false, code: 'write-failed', error: `写入失败(原文件未改动):${error instanceof Error ? error.message : String(error)}`, backupPath }
|
|
458
|
+
}
|
|
459
|
+
const back = exists(patchPath) ? readFile(patchPath) : null
|
|
460
|
+
if (back !== plan.next) {
|
|
461
|
+
return { ok: false, code: 'verify-failed', error: '写入后读回核实失败:内容与预期不一致(当前状态已备份,可用备份回退)', backupPath }
|
|
462
|
+
}
|
|
463
|
+
// 逐字节证明:只有点名行被追加了块,其余部分一字不动
|
|
464
|
+
const prefixSame = back.startsWith(currentText.replace(/\s+$/u, ''))
|
|
465
|
+
return {
|
|
466
|
+
ok: true, code: 'disabled', changed: true, added: plan.add.map((a) => a.rowId), skipped: plan.skipped, backupPath,
|
|
467
|
+
patchPath, sha256: sha256(back), prefixSame,
|
|
468
|
+
note: { zh: `已只给 ${plan.add.length} 行点名行追加 disabled: true(未删任何行,改前状态已备份)`, en: `Appended disabled: true to ${plan.add.length} named row(s) only (no row deleted; previous state backed up)` },
|
|
469
|
+
}
|
|
470
|
+
} catch (error) {
|
|
471
|
+
return { ok: false, code: 'disable-failed', error: `禁用失败:${error instanceof Error ? error.message : String(error)}` }
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** 把一份快照标记为「良好」(永不随保留策略删除)。 */
|
|
476
|
+
function markSnapshotGood({ id = null, home = undefined, readFile = (p) => readFileSync(p, 'utf8'), writeFile = (p, d) => writeFileSync(p, d, 'utf8'), readDir } = {}) {
|
|
477
|
+
try {
|
|
478
|
+
const records = readSnapshotDir({ home, readDir, readFile })
|
|
479
|
+
const target = pickSnapshot(records, { id })
|
|
480
|
+
if (target === null) return { ok: false, code: 'snapshot-missing', error: id === null ? '还没有任何安全启动快照' : `找不到快照 ${id}` }
|
|
481
|
+
const marked = { ...target, kind: 'good', markedAt: Date.now() }
|
|
482
|
+
const file = join(snapshotsDir(home), `${marked.id}.json`)
|
|
483
|
+
const tmp = `${file}.tmp`
|
|
484
|
+
writeFile(tmp, `${JSON.stringify(marked, null, 2)}\n`)
|
|
485
|
+
renameSync(tmp, file)
|
|
486
|
+
const state = readSafeBootState({ home, readFile })
|
|
487
|
+
writeSafeBootState({ ...state, lastGoodId: marked.id, lastGoodAt: marked.markedAt }, { home, writeFile })
|
|
488
|
+
return { ok: true, snapshot: snapshotView(marked), note: { zh: '已标记为良好:这份快照永不随保留策略删除', en: 'Marked as good: this snapshot is never removed by retention' } }
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return { ok: false, code: 'mark-failed', error: `标记失败:${error instanceof Error ? error.message : String(error)}` }
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export {
|
|
495
|
+
AUTO_KEEP,
|
|
496
|
+
SNAPSHOT_KINDS,
|
|
497
|
+
applyRetention,
|
|
498
|
+
disableSuspects,
|
|
499
|
+
ensureSnapshotForCurrentState,
|
|
500
|
+
isRowDisabled,
|
|
501
|
+
listSnapshots,
|
|
502
|
+
markSnapshotGood,
|
|
503
|
+
patchRowState,
|
|
504
|
+
pickSnapshot,
|
|
505
|
+
planDisableRows,
|
|
506
|
+
planRestore,
|
|
507
|
+
readSafeBootState,
|
|
508
|
+
readSnapshotDir,
|
|
509
|
+
restoreLastGood,
|
|
510
|
+
safeBootDir,
|
|
511
|
+
safeBootStateFile,
|
|
512
|
+
sameEnv,
|
|
513
|
+
sha256,
|
|
514
|
+
snapshotView,
|
|
515
|
+
snapshotsDir,
|
|
516
|
+
stampOf,
|
|
517
|
+
validatePatchYaml,
|
|
518
|
+
writeSafeBootState,
|
|
519
|
+
writeSnapshot,
|
|
520
|
+
}
|