fix(stores): 配置文件形状不符先备份再回退;日志尾部同步落盘
- commands/connections/settings 三个 store 原先只对 JSON.parse 抛错做 .bak 备份,合法 JSON 但形状不符(如 [1,2,3])会静默回退空/默认,下次 写盘把命令库/书签凭据/自定义主题整体销毁。现统一为形状不符也先备份 (与 knownHosts 的 fail-closed 策略对齐),三个 store 测试补对应用例 - sanitizeRules 回退分支返回预设副本而非模块级共享数组引用,并在回退时 记录 warning(原先连警告都没有) - 会话日志尾部:logStop 触发的最后一轮 flush 改同步落盘(在途异步写未 落地时由 drain 循环做保序的最终同步轮),before-quit 不再丢日志尾巴
This commit is contained in:
1 parent
e23010f950
commit
172cce1962
6 files changed
+253
-19
No files matched your search
@@ -236,6 +236,21 @@ const expected =
|
||||
'partial-tail'
|
||||
ok(readFileSync(startB.file, 'utf8') === expected, 'burst writes + stop tail land in order')
|
||||
|
||||
// The stop tail is flushed *synchronously* when no async append is in flight:
|
||||
// the quit path (killAllPtys → safeStopLog → logStop) has no later sync point,
|
||||
// so a fresh appendFile chain may never run and the tail would be lost. The
|
||||
// drain for the committed line below has long settled, so nothing can race it.
|
||||
const sid3 = 'cccccccc-dddd-eeee-ffff-000000000000'
|
||||
const startC = store.logStart(sid3)
|
||||
store.logWrite(sid3, 'settled line\n')
|
||||
await wait(100)
|
||||
store.logWrite(sid3, 'no trailing newline')
|
||||
store.logStop(sid3)
|
||||
ok(
|
||||
readFileSync(startC.file, 'utf8') === 'settled line\nno trailing newline',
|
||||
'the stop tail is on disk when logStop returns (sync flush when the drain is idle)'
|
||||
)
|
||||
|
||||
// ---- 7. index.json path containment (hydrateIndex) -----------------------------
|
||||
// index.json is data, not trust: a tampered `file` value must never turn
|
||||
// logWrite into an arbitrary-path append. Only entries that resolve inside
|
||||
@@ -366,6 +381,36 @@ ok(!existsSync(join(freshDir, 'commands.json.bak')), 'a missing file (ENOENT) is
|
||||
ok(existsSync(join(freshDir, 'commands.json')), 'and the first write lands normally')
|
||||
rmSync(freshDir, { recursive: true, force: true })
|
||||
|
||||
// ---- 10. Valid JSON of the wrong shape is backed up too -------------------------
|
||||
// `[1,2,3]` parses fine, so the parse-catch never saw it: the file used to be
|
||||
// treated exactly like a missing one and the next recordCommand replaced it with
|
||||
// an empty history. A file that is not the store we wrote is unreadable, not
|
||||
// empty — same backup + empty-state exit as the corrupt case above.
|
||||
const shapeDir = mkdtempSync(join(tmpdir(), 'm5-cmd-shape-'))
|
||||
const shapeFile = join(shapeDir, 'commands.json')
|
||||
const shapeStore = new commandsMod.CommandsStore(shapeDir)
|
||||
const wrongShape = '[1,2,3]'
|
||||
writeFileSync(shapeFile, wrongShape, 'utf8')
|
||||
ok(shapeStore.listHistory().length === 0, 'a wrong-shaped commands.json loads as an empty history')
|
||||
const shapeBak = `${shapeFile}.bak`
|
||||
ok(existsSync(shapeBak), 'a wrong-shaped commands.json is backed up to .bak')
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, '.bak holds the wrong-shaped original byte for byte')
|
||||
writeSettings({ historyLimit: 50, historyEnabled: true })
|
||||
shapeStore.recordCommand('after-shape-mismatch')
|
||||
const rewrittenShape = JSON.parse(readFileSync(shapeFile, 'utf8'))
|
||||
ok(
|
||||
Array.isArray(rewrittenShape.history) &&
|
||||
rewrittenShape.history.some((h) => h.command === 'after-shape-mismatch'),
|
||||
'the store recovers with a well-formed file after the backup'
|
||||
)
|
||||
|
||||
// An object without the expected keys is the same class of problem, and a second
|
||||
// episode must not overwrite the first backup.
|
||||
writeFileSync(shapeFile, '{"library":[]}', 'utf8')
|
||||
shapeStore.listHistory()
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, 'an existing .bak is kept for a wrong shape too (earliest evidence wins)')
|
||||
rmSync(shapeDir, { recursive: true, force: true })
|
||||
|
||||
// All stores wrote into temp dirs; drop them so repeated runs do not litter.
|
||||
rmSync(userData, { recursive: true, force: true })
|
||||
|
||||
|
||||
@@ -205,6 +205,42 @@ ok(!existsSync(join(freshDir, 'connections.json.bak')), 'a missing file (ENOENT)
|
||||
ok(existsSync(join(freshDir, 'connections.json')), 'and the first write lands normally')
|
||||
rmSync(freshDir, { recursive: true, force: true })
|
||||
|
||||
// ---- 7b. Valid JSON of the wrong shape is backed up too --------------------------
|
||||
// `{}` parses fine, so the parse-catch never saw it: the file used to be treated
|
||||
// exactly like a missing one and the next write replaced every bookmark with an
|
||||
// empty list. A file that is not the array we wrote is unreadable, not empty —
|
||||
// same backup + empty-state exit as the corrupt case above.
|
||||
const shapeDir = mkdtempSync(join(tmpdir(), 'm-conn-shape-'))
|
||||
const shapeFile = join(shapeDir, 'connections.json')
|
||||
const shapeStore = new mod.ConnectionsStore(shapeFile)
|
||||
const wrongShape = '{"connections":[]}'
|
||||
writeFileSync(shapeFile, wrongShape, 'utf8')
|
||||
ok(shapeStore.listConnections().length === 0, 'a wrong-shaped connections.json loads as an empty list')
|
||||
const shapeBak = `${shapeFile}.bak`
|
||||
ok(existsSync(shapeBak), 'a wrong-shaped connections.json is backed up to .bak')
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, '.bak holds the wrong-shaped original byte for byte')
|
||||
const shapeSaved = shapeStore.saveConnection({
|
||||
name: 'after-shape-mismatch',
|
||||
host: 'h',
|
||||
port: 22,
|
||||
username: 'u',
|
||||
auth: 'password',
|
||||
askPasswordAtConnect: false,
|
||||
askPassphraseAtConnect: false,
|
||||
keepaliveIntervalSec: 0
|
||||
})
|
||||
const shapeList = JSON.parse(readFileSync(shapeFile, 'utf8'))
|
||||
ok(
|
||||
Array.isArray(shapeList) && shapeList.length === 1 && shapeList[0].id === shapeSaved.id,
|
||||
'the new bookmark is written normally after the backup'
|
||||
)
|
||||
|
||||
// A second wrong-shape episode must not overwrite the first backup.
|
||||
writeFileSync(shapeFile, '{"nope":true}', 'utf8')
|
||||
shapeStore.listConnections()
|
||||
ok(readFileSync(shapeBak, 'utf8') === wrongShape, 'an existing .bak is kept (earliest evidence wins)')
|
||||
rmSync(shapeDir, { recursive: true, force: true })
|
||||
|
||||
// An unreadable path (here: a directory) must still load as empty and never
|
||||
// throw — the backup is best effort and may itself fail.
|
||||
const dirCase = mkdtempSync(join(tmpdir(), 'm-conn-dir-'))
|
||||
|
||||
@@ -94,6 +94,27 @@ console.log('[highlight rules]')
|
||||
const out = (await roundTrip({ highlightRules: [] })).highlightRules
|
||||
ok(out.length === 0, 'an explicitly empty rule list stays empty')
|
||||
}
|
||||
{
|
||||
// A non-array highlightRules is a shape error, not "no rules": the built-in
|
||||
// rules come back as copies (mutating a loaded list must not poison the
|
||||
// module-level preset table) and the fallback is recorded in the warnings log.
|
||||
writeFileSync(
|
||||
join(userData, 'settings.json'),
|
||||
JSON.stringify({ highlightRules: { nope: true } }),
|
||||
'utf8'
|
||||
)
|
||||
const first = store.loadSettings().highlightRules
|
||||
ok(first.length > 0, `a non-array highlightRules loads the built-in rules (got ${first.length})`)
|
||||
const second = store.loadSettings().highlightRules
|
||||
ok(first !== second, 'each load hands out its own array, not the shared preset list')
|
||||
first.length = 0
|
||||
const afterMutation = store.loadSettings().highlightRules
|
||||
ok(afterMutation.length > 0, 'emptying a loaded rule list does not affect the next load')
|
||||
const log = existsSync(join(userData, 'settings-warnings.log'))
|
||||
? readFileSync(join(userData, 'settings-warnings.log'), 'utf8')
|
||||
: ''
|
||||
ok(log.includes('highlightRules: not an array'), 'the fallback is recorded in settings-warnings.log')
|
||||
}
|
||||
|
||||
// ---- 3. value bands ---------------------------------------------------------
|
||||
console.log('[value bands]')
|
||||
|
||||
Reference in new issue
Block a user