diff --git a/tools/action-journal.test.mjs b/tools/action-journal.test.mjs index 1190077f..07124ef5 100644 --- a/tools/action-journal.test.mjs +++ b/tools/action-journal.test.mjs @@ -158,6 +158,39 @@ describe('N2: verifyChain fail-closed на битой записи (не бро }); }); +// M1 (loose-ends C): journalAppend делает ДВА fs-вызова — appendFileSync(jsonl) + writeFileSync(head). +// Они не атомарны: процесс может «умереть» между ними → jsonl получит новую запись, а head останется +// на предыдущей. Реестр хвостов: «атомарность дозаписи журнала оставлена (fail-closed по конструкции, +// чистого теста нет)». Здесь — характеризационный (pinning) тест этого fail-closed-инварианта. +describe('M1: оборванная дозапись (entry в jsonl, head не обновлён) → verifyChain fail-closed', () => { + const opts = (fs) => ({ key: KEY, sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); + it('jsonl получил entry3, а head остался на entry2 → {ok:false}, brokenAt=3', () => { + const fs = memFs(); + journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); + journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) }); + const headAfter2 = fs.store.get('/rt/action-journal-S1.head'); + // Симуляция обрыва: дозаписываем entry3 в jsonl, но head НЕ трогаем (как если бы процесс + // упал между appendFileSync и writeFileSync внутри journalAppend). + const loaded2 = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); + const e3 = appendEntry(loaded2.entries, { action: 'C' }, { key: KEY, nowMs: 3 }); + fs.store.set('/rt/action-journal-S1.jsonl', e3.entries.map((e) => JSON.stringify(e)).join('\n') + '\n'); + const loaded3 = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); + expect(loaded3.entries).toHaveLength(3); + expect(loaded3.headSig).toBe(headAfter2); // head всё ещё подписывает entry2 + const v = verifyChain(loaded3.entries, loaded3.headSig, { key: KEY }); + expect(v.ok).toBe(false); // голова цепи (entry3) не сходится с подписью head(entry2) + expect(v.brokenAt).toBe(3); + }); + it('контроль: завершённая дозапись (entry3 + head3) → {ok:true}', () => { + const fs = memFs(); + journalAppend({ payload: { action: 'A' }, nowMs: 1, ...opts(fs) }); + journalAppend({ payload: { action: 'B' }, nowMs: 2, ...opts(fs) }); + journalAppend({ payload: { action: 'C' }, nowMs: 3, ...opts(fs) }); + const loaded = loadJournal({ sessionId: 'S1', runtimeDir: '/rt', fsImpl: fs }); + expect(verifyChain(loaded.entries, loaded.headSig, { key: KEY }).ok).toBe(true); + }); +}); + // N3 (2026-06-07 аудит M1): paths() вклеивал sessionId в путь файла без проверки формы. // `..`/`/` → path traversal (журнал пишется/читается вне каталога). Guard /^[A-Za-z0-9_-]+$/ // → throw (fail-closed; в supreme-gate ловится внешним try/catch → block).