Files
webmail/lib/store/adapters/wasm.ts
T
Linus Rath e0abc7fa18 chore: merge the reviewed store, pop-outs and desktop core into the settings branch
Eviction now goes by the base's signal: `storeLost` and the `store-reset`
notice, with `bulwark-store-opened` as the marker and `prune-files` for files
with no key. The storage page and the lost count hang off that. What a user
picks is stored under `bulwark-offline-scope`, the key `offlineScopeFor`
reads, and `email-snapshot` follows the base's rule. `OfflineEnabled` is read
from the desktop bridge when it's there.
2026-11-03 10:40:47 +01:00

208 lines
7.1 KiB
TypeScript

/**
* SQLite3MultipleCiphers on OPFS, for the owner tab's worker.
*
* Rules that come from bugs reproduced in 2.6.1, see
* local-data/desktop-offline-facts-parts/sqlite-wasm.md section 5:
* - PRAGMA key is the very first thing on a connection. Reading first deletes
* a hot journal (#292).
* - The page size must be 8192. On 4096, rollback doesn't work (#293).
* - No PRAGMA rekey (#291, #294). The key is random and only ever re-wrapped.
* - Always the multipleciphers VFS. On plain opfs-sahpool, PRAGMA key throws
* and the connection still writes in the clear if that's ignored.
*/
import { runTransaction, type SqlRow, type SqlValue, type StorageAdapter } from '../storage';
import { POOL_DIRECTORY } from '../worker/protocol';
const VFS = 'multipleciphers-opfs-sahpool';
const INIT_TIMEOUT_MS = 30_000;
const REQUIRED_PAGE_SIZE = 8192;
const INITIAL_CAPACITY = 16;
/** A database and its journal, plus room for one more. */
const SLOTS_PER_DATABASE = 3;
/* eslint-disable @typescript-eslint/no-explicit-any */
interface Pool {
getFileNames(): string[];
getCapacity(): number;
getFileCount(): number;
addCapacity(n: number): Promise<number>;
unlink(name: string): boolean;
wipeFiles(): Promise<void>;
pauseVfs(): void;
unpauseVfs(): Promise<void>;
isPaused(): boolean;
}
export class StoreKeyError extends Error {
constructor() {
super('The local store could not be opened with this key');
this.name = 'StoreKeyError';
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, what: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${what} timed out`)), ms);
promise.then((value) => {
clearTimeout(timer);
resolve(value);
}, (error) => {
clearTimeout(timer);
reject(error);
});
});
}
class WasmAdapter implements StorageAdapter {
private readonly tx = { depth: 0 };
private readonly statements = new Map<string, any>();
constructor(private readonly sqlite3: any, private readonly db: any) {}
exec(sql: string): void {
this.db.exec(sql);
}
private statement(sql: string): any {
let stmt = this.statements.get(sql);
if (!stmt) {
stmt = this.db.prepare(sql);
this.statements.set(sql, stmt);
}
return stmt;
}
private bindAndRun<T>(sql: string, params: readonly SqlValue[] | undefined, read: (stmt: any) => T): T {
const stmt = this.statement(sql);
try {
if (params && params.length > 0) stmt.bind(params as SqlValue[]);
return read(stmt);
} finally {
stmt.reset(true);
}
}
run(sql: string, params?: readonly SqlValue[]): { changes: number; lastInsertRowid: number } {
this.bindAndRun(sql, params, (stmt) => {
while (stmt.step()) { /* no rows wanted */ }
});
return {
changes: Number(this.db.changes()),
lastInsertRowid: Number(this.sqlite3.capi.sqlite3_last_insert_rowid(this.db.pointer)),
};
}
all<T extends object = SqlRow>(sql: string, params?: readonly SqlValue[]): T[] {
return this.bindAndRun(sql, params, (stmt) => {
const rows: T[] = [];
while (stmt.step()) rows.push(stmt.get({}) as T);
return rows;
});
}
get<T extends object = SqlRow>(sql: string, params?: readonly SqlValue[]): T | undefined {
return this.bindAndRun(sql, params, (stmt) => (stmt.step() ? stmt.get({}) as T : undefined));
}
transaction<T>(fn: () => T): T {
return runTransaction(this.tx, (sql) => this.db.exec(sql), fn);
}
close(): void {
for (const stmt of this.statements.values()) stmt.finalize();
this.statements.clear();
this.db.close();
}
}
export function installMayPass(error: unknown): boolean {
return (error as Error)?.name !== 'SecurityError';
}
/** The module and the pool, once. Only the owner gets here. */
export class WasmStorage {
private constructor(private readonly sqlite3: any, private readonly pool: Pool) {}
/**
* Call only while holding the owner lock. The holder before may still be
* letting go, so a refusal is retried for a while.
*/
static async install(moduleUrl: string): Promise<WasmStorage> {
const { default: init } = await import(/* webpackIgnore: true */ moduleUrl) as { default: (o: unknown) => Promise<any> };
// A failed wasm load never settles the promise from the module's init.
const sqlite3 = await withTimeout(init({ print() {}, printErr() {} }), INIT_TIMEOUT_MS, 'Loading the store engine');
let lastError: unknown;
for (let attempt = 0; attempt < 20; attempt += 1) {
try {
const pool = await sqlite3.installOpfsSAHPoolVfs({
name: 'opfs-sahpool',
directory: POOL_DIRECTORY,
initialCapacity: INITIAL_CAPACITY,
preserveOnInitFailure: true,
forceReinitIfPreviouslyFailed: true,
}) as Pool;
return new WasmStorage(sqlite3, pool);
} catch (error) {
// The browser won't allow it here at all, a private window: waiting won't help.
if (!installMayPass(error)) throw error;
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 100 + attempt * 50));
}
}
throw lastError;
}
/** Opens one account's database. `keyHex` is 32 random bytes in hex. */
async open(file: string, keyHex: string): Promise<StorageAdapter> {
if (!/^\/a[0-9a-f]+\.db$/.test(file)) throw new Error('Store file names carry no account data');
if (!/^[0-9a-f]{64}$/.test(keyHex)) throw new Error('A store key is 32 bytes of hex');
const needed = this.pool.getFileCount() + SLOTS_PER_DATABASE;
if (this.pool.getCapacity() < needed) await this.pool.addCapacity(needed - this.pool.getCapacity());
const db = new this.sqlite3.oo1.DB({ filename: file, vfs: VFS, flags: 'c' });
try {
db.exec(`PRAGMA key = "raw:${keyHex}"`);
try {
db.selectValue('SELECT count(*) FROM sqlite_master');
} catch (error) {
// Only "this isn't a database": a passing read error must not read as a lost file.
if (error instanceof Error && /NOTADB|not a database/i.test(error.message)) throw new StoreKeyError();
throw error;
}
const pageSize = Number(db.selectValue('PRAGMA page_size'));
if (pageSize !== REQUIRED_PAGE_SIZE) throw new Error(`Refusing an unsafe page size: ${pageSize}`);
db.exec('PRAGMA temp_store = MEMORY');
// Every page read is a decryption, so a miss costs more than usual.
db.exec('PRAGMA cache_size = -65536');
db.exec('PRAGMA foreign_keys = OFF');
db.exec('PRAGMA synchronous = NORMAL');
} catch (error) {
db.close();
throw error;
}
return new WasmAdapter(this.sqlite3, db);
}
remove(file: string): boolean {
let removed = false;
for (const name of this.pool.getFileNames()) {
if (name === file || name === `${file}-journal`) removed = this.pool.unlink(name) || removed;
}
return removed;
}
/** Lets go of the OPFS handles. Every database must be closed first. */
pause(): void {
this.pool.pauseVfs();
}
async unpause(): Promise<void> {
await this.pool.unpauseVfs();
}
files(): string[] {
return this.pool.getFileNames();
}
}