Files
TREK/server/src/nest/plugins/host/plugin-data.service.ts
T
Maurice 83d591dc6c fix(db): keep SQLite temp files in memory so trips can be deleted (#2518)
Deleting a trip failed with SQLITE_IOERR_GETTEMPPATH when the container
runs with a read-only root filesystem and no tmpfs on /tmp. The cascade
behind DELETE FROM trips keeps a statement journal, SQLite moves it to a
temp file once it passes 64 KiB, and it looks for a directory for that
file in SQLITE_TMPDIR, TMPDIR, /var/tmp, /usr/tmp, /tmp and the working
directory. None of them is writable there. Installs on the shipped
compose file have a tmpfs on /tmp and never hit it.

Every connection the server opens now comes from openDatabase() in
src/db/connection.ts, which sets temp_store = MEMORY: the main database
(also when it is reopened after a restore), each plugin's own database
and the uploaded backup checked before a restore. reset-admin.js and the
key rotation script cannot import src/ in the image, so they set the
pragma themselves. A test pins that nothing else in src/ constructs a
handle, and one deletes a full trip on the real schema and watches for
temp files.
2026-09-26 10:36:02 +02:00

253 lines
12 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import type { Database as Db } from 'better-sqlite3';
import { openDatabase } from '../../../db/connection';
import { pluginDataDir, pluginDbFile, pluginsDataRoot } from '../paths';
/**
* A plugin's own sqlite database (#plugins, db:own). The HOST owns the handle;
* the plugin child never gets a path or a connection — it can only reach this
* through RPC (db.exec / db.query / db.migrate). Because it is a SEPARATE FILE,
* containment is a filesystem fact: the plugin physically cannot read trek.db,
* and we don't have to police table-name prefixes in its SQL.
*
* A thin guard still rejects statements that would let a plugin escape its file
* (ATTACH another db, VACUUM INTO elsewhere, PRAGMA fiddling) or DoS via
* oversize SQL.
*/
const MAX_SQL_LENGTH = 100_000;
// RECURSIVE is the one construct that generates unbounded rows/CPU independent of
// the (capped) data size — a `WITH RECURSIVE …` can spin the synchronous host
// forever even with an empty database, which neither the size quota nor the
// result-row cap can stop (an aggregate over it never yields a first row). Refuse
// it outright; the row/size caps below bound everything else.
// load_extension is included as defense-in-depth: better-sqlite3 disables
// extension loading by default (so it's inert today), but banning it in the guard
// means a future connection-option slip can't turn it into an arbitrary-.so RCE.
const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA|RECURSIVE|LOAD_EXTENSION)\b/i;
// Transaction-control keywords, matched only at statement start (so CASE…END and
// identifiers are unaffected). Refused inside db.tx() so a plugin can't COMMIT the
// batch's earlier writes and then have the wrapper report failure — breaking atomicity.
const TX_CONTROL = /^\s*(BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE|END)\b/i;
// Per-plugin on-disk quota. better-sqlite3 is synchronous and runs in the HOST
// process, so an unbounded plugin DB is both a disk-exhaustion DoS on the shared
// trek.db volume and (via a huge scan) an event-loop stall. max_page_count caps
// the file (writes past it fail SQLITE_FULL, contained to the plugin) and bounds
// the worst-case scan cost. Result sets are additionally row-capped below so a
// recursive CTE / cartesian product can't materialize an unbounded array.
const QUOTA_BYTES = 256 * 1024 * 1024;
const MAX_ROWS = 100_000;
// Cap statements per atomic batch so a single tx() can't monopolise the synchronous
// host — generous for real write batches, far below anything abusive.
const MAX_TX_OPS = 100;
// Every live per-plugin handle, so a backup can WAL-checkpoint them before archiving
// (the host keeps these open, so their .db files would otherwise be copied with recent
// commits still stranded in the -wal sidecar → a stale/torn snapshot in the backup).
const openDbs = new Set<PluginDataDb>();
/** Fold the WAL back into each open plugin.db so a subsequent file copy is a complete,
* consistent snapshot — mirrors the wal_checkpoint the core backup runs on travel.db.
* Best-effort per handle; never throws. */
export function checkpointAllPluginDataDbs(): void {
for (const d of openDbs) {
try { d.checkpoint(); } catch { /* a busy/closed handle is skipped, not fatal */ }
}
}
export class PluginDataDb {
private db: Db;
readonly pluginId: string;
constructor(pluginId: string) {
this.pluginId = pluginId;
fs.mkdirSync(pluginDataDir(pluginId), { recursive: true });
this.db = openDatabase(pluginDbFile(pluginId));
this.db.pragma('journal_mode = WAL');
this.db.pragma('foreign_keys = ON');
openDbs.add(this);
// Cap the file size (per-connection; not persisted, so set on every open).
const pageSize = Number(this.db.pragma('page_size', { simple: true })) || 4096;
this.db.pragma(`max_page_count = ${Math.max(1, Math.floor(QUOTA_BYTES / pageSize))}`);
// Track applied migrations so db.migrate is idempotent per (plugin, id).
this.db.exec(
`CREATE TABLE IF NOT EXISTS _plugin_migrations (id TEXT PRIMARY KEY, applied_at INTEGER)`,
);
}
private guard(sql: string): void {
if (typeof sql !== 'string') throw new Error('sql must be a string');
if (sql.length > MAX_SQL_LENGTH) throw new Error('sql too long');
if (FORBIDDEN.test(sql)) throw new Error('statement type not allowed for plugin databases');
}
/** Read query — returns rows up to MAX_ROWS. Single statement only. */
query(sql: string, args: unknown[] = []): unknown[] {
this.guard(sql);
// iterate() pulls one row at a time, so a recursive CTE that would yield
// unboundedly is halted at the cap instead of materializing via all().
const rows: unknown[] = [];
for (const row of this.db.prepare(sql).iterate(...(args as never[]))) {
rows.push(row);
if (rows.length > MAX_ROWS) throw new Error(`query returned more than ${MAX_ROWS} rows`);
}
return rows;
}
/** Write statement(s). exec() allows multiple statements (e.g. a small setup script). */
exec(sql: string, args: unknown[] = []): { changes: number } {
this.guard(sql);
if (args.length > 0) {
const info = this.db.prepare(sql).run(...(args as never[]));
return { changes: info.changes };
}
this.db.exec(sql);
return { changes: 0 };
}
/**
* Atomic batch on the plugin's OWN db: every op runs in a single transaction, so
* they all commit or all roll back — the primitive a plugin needs for a consistent
* multi-write (e.g. move an item between two tables). Each op is ONE statement;
* a read (SELECT/RETURNING) yields `{ rows }`, a write yields `{ changes }`, and
* reads within the batch see the batch's own earlier writes (read-modify-write).
*/
tx(ops: Array<{ sql: string; args?: unknown[] }>): { results: Array<{ changes?: number; rows?: unknown[] }> } {
if (!Array.isArray(ops)) throw new Error('tx requires an array of { sql, args }');
if (ops.length === 0) return { results: [] };
if (ops.length > MAX_TX_OPS) throw new Error(`tx allows at most ${MAX_TX_OPS} statements`);
for (const op of ops) {
this.guard(op?.sql);
// Reject transaction-control statements: a raw COMMIT/ROLLBACK inside the batch
// would break atomicity — it commits the earlier writes even though the wrapper
// then reports the tx as failed. Strip any LEADING comments/whitespace first so a
// `/* */COMMIT` or `-- x\nCOMMIT` can't slip past the start-anchored check; these
// keywords are only valid at statement start, so CASE ... END is unaffected.
const head = String(op?.sql ?? '').replace(/^(?:\s|--[^\n]*\n?|\/\*[\s\S]*?\*\/)*/, '');
if (TX_CONTROL.test(head)) throw new Error('transaction-control statements are not allowed inside tx()');
}
let batchRows = 0; // one row budget for the WHOLE batch, not per statement
const run = this.db.transaction((batch: Array<{ sql: string; args?: unknown[] }>) => {
const results: Array<{ changes?: number; rows?: unknown[] }> = [];
for (const op of batch) {
const stmt = this.db.prepare(op.sql);
const args = (op.args ?? []) as never[];
if (stmt.reader) {
const rows: unknown[] = [];
for (const row of stmt.iterate(...args)) {
rows.push(row);
if (++batchRows > MAX_ROWS) throw new Error(`tx returned more than ${MAX_ROWS} rows in total`);
}
results.push({ rows });
} else {
results.push({ changes: stmt.run(...args).changes });
}
}
return results;
});
return { results: run(ops) };
}
/** Run a migration once, keyed by id. Re-running with the same id is a no-op. */
migrate(id: string, sql: string): { applied: boolean } {
this.guard(sql);
const seen = this.db.prepare('SELECT 1 FROM _plugin_migrations WHERE id = ?').get(id);
if (seen) return { applied: false };
this.db.transaction(() => {
this.db.exec(sql);
this.db.prepare('INSERT INTO _plugin_migrations (id, applied_at) VALUES (?, ?)').run(id, Date.now());
})();
return { applied: true };
}
/** Whether the underlying sqlite handle is still open (better-sqlite3 `.open`).
* The host uses this to detect a handle closed by a terminal-failure dispose that
* left the instance cached, so it can recreate it instead of throwing on reuse. */
isOpen(): boolean {
return this.db.open;
}
/** Fold the WAL back into the main db file (checkpoint TRUNCATE) so a file-level copy
* is a complete snapshot. No-op on a closed handle. */
checkpoint(): void {
if (this.db.open) this.db.pragma('wal_checkpoint(TRUNCATE)');
}
/** Write a fully-consistent copy of this DB to `destPath` via VACUUM INTO. Unlike a
* file copy it folds in the WAL and reads a point-in-time snapshot, so the result is
* correct even while the plugin is writing — no torn page, no separate -wal to keep in
* sync. This is a host op on the host's own handle, not plugin SQL, so it bypasses the
* FORBIDDEN guard by design. */
snapshotInto(destPath: string): void {
fs.rmSync(destPath, { force: true }); // VACUUM INTO fails if the target already exists
this.db.exec(`VACUUM INTO '${destPath.replaceAll("'", "''")}'`);
}
close(): void {
try {
openDbs.delete(this);
this.db.close();
} catch {
/* already closed */
}
}
}
/** Delete a plugin's data directory (uninstall "delete data"). */
export function removePluginData(pluginId: string): void {
fs.rmSync(pluginDataDir(pluginId), { recursive: true, force: true });
}
/**
* Copy every plugin's data dir into `destRoot` as a CONSISTENT snapshot, for a backup to
* archive instead of the live tree. An open plugin.db is captured with VACUUM INTO (safe
* under concurrent writes); a plugin with no live handle is copied as-is (no writer). The
* -wal/-shm sidecars are never copied — the snapshot folds them in, and copying them out
* of step with the .db is exactly what produced torn/corrupt restores when the archiver
* read the live files lazily while a plugin kept writing. Blobs and any other files a
* plugin wrote to its dir are copied verbatim. Best-effort per file; never throws.
*/
export function snapshotAllPluginDataDbs(destRoot: string): void {
const root = pluginsDataRoot();
if (!fs.existsSync(root)) return;
const openById = new Map<string, PluginDataDb>();
for (const d of openDbs) if (d.isOpen()) openById.set(d.pluginId, d);
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const srcDir = path.join(root, entry.name);
const destDir = path.join(destRoot, entry.name);
fs.mkdirSync(destDir, { recursive: true });
const open = openById.get(entry.name);
// Handle the live db up front so we know whether its WAL got folded in. If both
// the VACUUM INTO snapshot and the checkpoint fail, the -wal/-shm are NOT folded,
// so they must be copied alongside the .db — a .db stripped of an un-checkpointed
// WAL loses committed transactions, whereas the .db + its WAL is a recoverable set.
let foldedIn = false;
if (open) {
try { open.snapshotInto(path.join(destDir, 'plugin.db')); foldedIn = true; }
catch {
try { open.checkpoint(); foldedIn = true; } catch { /* WAL not folded — keep sidecars */ }
try { fs.copyFileSync(path.join(srcDir, 'plugin.db'), path.join(destDir, 'plugin.db')); }
catch { /* unreadable live db — best effort */ }
}
}
for (const f of fs.readdirSync(srcDir, { withFileTypes: true })) {
if (f.name === 'plugin.db' && open) continue; // already snapshotted above
// Skip the .db sidecars only when the live handle's WAL was folded in — VACUUM
// INTO / checkpoint absorbs them, and copying them out of step with a live writer
// is what produced torn restores. For a plugin with NO open handle there is no
// writer, so the -wal/-shm are a consistent set with the .db; copy them too, or an
// unclean shutdown's committed-but-uncheckpointed transactions (still sitting in
// the WAL) would be lost from the backup.
if ((f.name.endsWith('-wal') || f.name.endsWith('-shm')) && foldedIn) continue;
const src = path.join(srcDir, f.name);
const dest = path.join(destDir, f.name);
try {
if (f.isDirectory()) fs.cpSync(src, dest, { recursive: true });
else fs.copyFileSync(src, dest);
} catch { /* skip an unreadable entry rather than fail the whole backup */ }
}
}
}