fix(mail-index): close handle on every pragma failure, reconcile contact/file deletes
QA pass on the encrypted mail index found two real gaps beyond what the
prior end-to-end fix pass caught:
1. store.ts's MailIndex.open() only wrapped SOME of the post-key pragma
calls in a try/catch before this: the first attempt's `key` pragma and
assertEncrypted() ran outside any try at all, and the wrong-key retry
repeated the same gap. Any pragma throwing there (SQLITE_BUSY, a full
disk on the first WAL write) leaked the native SQLite handle instead of
closing it. Factored the open+key+verify+pragma sequence into openKeyed(),
which guarantees a close before rethrowing on any failure, and reused it
for both the first attempt and the retry.
2. reindex.ts never removed a deleted contact or file from the index. The
`removed` field exists in the API and is fully tested at the store layer,
but nothing in the renderer populates it, so a deleted contact/file stayed
searchable - and retrievable by the AI feature - indefinitely. Mail and
calendar can't use the same fix (their queries are date-windowed, so an id
missing from one fetch may just be outside the window), but contacts/files
have no date filter - a catch-up fetch that comes back under its cap IS
the complete set, so anything locally indexed but absent from it is safely
known to be deleted. Added strayIdsAfterCatchUp() and wired it into the
catch-up path for those two types only.
Also read binding.ts, key.ts, paths.ts, jmap.ts, extract.ts, the FTS5
query builder, and both /api/offline/{search,reindex} routes end to end;
no other concrete bugs found there. Full findings reported separately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bde9f14832
commit
bde8455df5
@@ -4,8 +4,9 @@ import path from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { isSqlcipherAvailable } from '../binding';
|
||||
import type { SqlcipherConstructor, SqlcipherDatabase, SqlcipherStatement } from '../binding';
|
||||
import { accountFileToken, getStoreDir, indexDbPath, STORE_DIR_ENV } from '../paths';
|
||||
import { MailIndex, toFtsMatchQuery, type IndexDoc } from '../store';
|
||||
import { MailIndex, openKeyed, toFtsMatchQuery, type IndexDoc } from '../store';
|
||||
|
||||
describe('toFtsMatchQuery', () => {
|
||||
it('quotes every token so FTS5 operators in user input cannot break the query', () => {
|
||||
@@ -78,6 +79,51 @@ describe('paths', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A fake `SqlcipherDatabase` whose `pragma()` is driven by the given handler. */
|
||||
function fakeSqlcipherCtor(
|
||||
pragmaHandler: (source: string) => unknown,
|
||||
): { ctor: SqlcipherConstructor; instances: Array<{ closeCalls: number }> } {
|
||||
const instances: Array<{ closeCalls: number }> = [];
|
||||
function FakeDatabase(this: unknown, _path?: string): SqlcipherDatabase {
|
||||
const state = { closeCalls: 0 };
|
||||
instances.push(state);
|
||||
const stmt: SqlcipherStatement = { run: () => ({ changes: 0, lastInsertRowid: 0 }), get: () => undefined, all: () => [] };
|
||||
const db: SqlcipherDatabase = {
|
||||
exec: () => {},
|
||||
prepare: () => stmt,
|
||||
pragma: pragmaHandler,
|
||||
close: () => { state.closeCalls += 1; },
|
||||
};
|
||||
return db;
|
||||
}
|
||||
return { ctor: FakeDatabase as unknown as SqlcipherConstructor, instances };
|
||||
}
|
||||
|
||||
describe('openKeyed', () => {
|
||||
it('closes the connection before rethrowing when a pragma AFTER assertEncrypted fails', () => {
|
||||
// The bug this guards: `journal_mode = WAL` (or any pragma after the key
|
||||
// check) throwing must not leak the native handle - a `try` that only
|
||||
// wrapped SOME of these calls previously let exactly this escape.
|
||||
const { ctor, instances } = fakeSqlcipherCtor((source) => {
|
||||
if (source === 'cipher_version') return [{ cipher_version: 'fake-4.5.0' }];
|
||||
if (source === 'journal_mode = WAL') throw new Error('simulated pragma failure');
|
||||
return undefined;
|
||||
});
|
||||
expect(() => openKeyed(ctor, '/fake/path.db', randomBytes(32))).toThrow(/simulated pragma failure/);
|
||||
expect(instances).toHaveLength(1);
|
||||
expect(instances[0].closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it('tolerates the extra close when assertEncrypted itself is the failure (its own close is idempotent)', () => {
|
||||
// assertEncrypted closes on its own failure before throwing; openKeyed's
|
||||
// catch then calls close again. That second call must be harmless, not a
|
||||
// new crash - hence >= 1 rather than a fixed count.
|
||||
const { ctor, instances } = fakeSqlcipherCtor((source) => (source === 'cipher_version' ? [] : undefined));
|
||||
expect(() => openKeyed(ctor, '/fake/path.db', randomBytes(32))).toThrow(/no SQLCipher support/);
|
||||
expect(instances[0].closeCalls).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
function doc(overrides: Partial<IndexDoc> = {}): IndexDoc {
|
||||
return {
|
||||
jmapAccountId: 'acc1',
|
||||
|
||||
Reference in New Issue
Block a user