Skip to content

Commit 342c8dd

Browse files
authored
fix(snapshot): close TOCTOU race on save/restore/delete (#1003)
* fix(snapshot): close TOCTOU race on save/restore/delete Use temp-file + atomic rename for `snapshotSave --force` and `snapshotRestore` so two concurrent saves or a restore racing with a reader can't observe a missing or partially-written database file. `snapshotDelete` now unlinks directly and maps ENOENT to the existing "not found" error instead of a separate existsSync check. Adds two regression tests: temp-file cleanup on success, and two concurrent --force saves producing a valid SQLite destination. Closes #995 Impact: 3 functions changed, 1 affected * fix(snapshot): close non-force TOCTOU and fix race test (#1003) Address Greptile review feedback: 1. Non-force path still had a TOCTOU window: two concurrent callers could both pass existsSync(dest) and overwrite each other silently. Replace the final rename with linkSync(tmp, dest), which fails atomically with EEXIST if another writer won the race, then unlinkSync(tmp). The fail-fast existsSync above is retained as a cheap guard; linkSync is the authoritative check. 2. The original race regression test used Promise.resolve().then(...) on synchronous better-sqlite3 calls, so both microtasks executed sequentially and the test could not reproduce the TOCTOU it claimed to guard. Replace it with a worker_threads-based harness that spawns two workers racing on snapshotSave, plus a new non-force race test that verifies exactly one caller wins and the other sees a clear "already exists" error. 3. process.pid is shared across worker_threads in the same process, so add randomBytes(6) to the temp filename to keep concurrent callers in any thread from colliding on the same tmp path. 4. Log best-effort cleanup failures at debug so repeated tmp leaks surface in troubleshooting without spamming normal runs (per Claude review's minor suggestion). Impact: 2 functions changed, 1 affected
1 parent 552c4eb commit 342c8dd

3 files changed

Lines changed: 221 additions & 14 deletions

File tree

src/features/snapshot.ts

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomBytes } from 'node:crypto';
12
import fs from 'node:fs';
23
import path from 'node:path';
34
import { getDatabase } from '../db/better-sqlite3.js';
@@ -37,24 +38,77 @@ export function snapshotSave(
3738
const dir = snapshotsDir(dbPath);
3839
const dest = path.join(dir, `${name}.db`);
3940

40-
if (fs.existsSync(dest)) {
41-
if (!options.force) {
42-
throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
43-
}
44-
fs.unlinkSync(dest);
45-
debug(`Deleted existing snapshot: ${dest}`);
41+
// Cheap fail-fast for the common non-force case; the authoritative check
42+
// below uses an atomic linkSync that closes the TOCTOU window.
43+
if (!options.force && fs.existsSync(dest)) {
44+
throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
4645
}
4746

4847
fs.mkdirSync(dir, { recursive: true });
4948

49+
// VACUUM INTO a unique temp path on the same filesystem, then atomically
50+
// place it at the destination. This closes the TOCTOU window between
51+
// existsSync/unlinkSync/VACUUM INTO where two concurrent saves could
52+
// observe a missing file or interleave their VACUUM writes.
53+
//
54+
// Unique temp name: process.pid is shared across worker_threads in the
55+
// same process, so we add random bytes to keep concurrent callers in any
56+
// thread from colliding on the temp path.
57+
const tmp = path.join(
58+
dir,
59+
`.${name}.db.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`,
60+
);
61+
try {
62+
fs.unlinkSync(tmp);
63+
} catch (err) {
64+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
65+
}
66+
5067
const Database = getDatabase();
5168
const db = new Database(dbPath, { readonly: true });
5269
try {
53-
db.exec(`VACUUM INTO '${dest.replace(/'/g, "''")}'`);
70+
db.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`);
5471
} finally {
5572
db.close();
5673
}
5774

75+
try {
76+
if (options.force) {
77+
// renameSync overwrites atomically — the correct semantics for --force.
78+
fs.renameSync(tmp, dest);
79+
} else {
80+
// Non-force path: linkSync fails atomically with EEXIST if dest exists,
81+
// closing the TOCTOU window between existsSync above and the final
82+
// placement. We then unlink the temp file; on POSIX and NTFS, link
83+
// creates a second reference so tmp can safely be removed.
84+
try {
85+
fs.linkSync(tmp, dest);
86+
} catch (err) {
87+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
88+
throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
89+
}
90+
throw err;
91+
}
92+
try {
93+
fs.unlinkSync(tmp);
94+
} catch (cleanupErr) {
95+
// Best-effort — dest is already in place, so a leftover tmp file is
96+
// harmless. Log at debug so repeated failures surface during
97+
// troubleshooting without noising up normal operation.
98+
debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`);
99+
}
100+
}
101+
} catch (err) {
102+
try {
103+
fs.unlinkSync(tmp);
104+
} catch (cleanupErr) {
105+
if ((cleanupErr as NodeJS.ErrnoException).code !== 'ENOENT') {
106+
debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`);
107+
}
108+
}
109+
throw err;
110+
}
111+
58112
const stat = fs.statSync(dest);
59113
debug(`Snapshot saved: ${dest} (${stat.size} bytes)`);
60114
return { name, path: dest, size: stat.size };
@@ -74,16 +128,38 @@ export function snapshotRestore(name: string, options: SnapshotDbPathOptions = {
74128
throw new DbError(`Snapshot "${name}" not found at ${src}`, { file: src });
75129
}
76130

77-
// Remove WAL/SHM sidecar files for a clean restore
131+
// Remove WAL/SHM sidecars first so the old journal can't be replayed over
132+
// the restored DB. unlink then check ENOENT — avoids the existsSync/unlinkSync
133+
// race another process could wedge into.
78134
for (const suffix of ['-wal', '-shm']) {
79135
const sidecar = dbPath + suffix;
80-
if (fs.existsSync(sidecar)) {
136+
try {
81137
fs.unlinkSync(sidecar);
82138
debug(`Removed sidecar: ${sidecar}`);
139+
} catch (err) {
140+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
141+
}
142+
}
143+
144+
// Copy to a temp path next to the DB, then rename atomically. Readers that
145+
// open dbPath during restore see either the pre-restore or post-restore
146+
// file, never a partially-written one.
147+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
148+
const tmp = `${dbPath}.restore-tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
149+
try {
150+
fs.copyFileSync(src, tmp);
151+
fs.renameSync(tmp, dbPath);
152+
} catch (err) {
153+
try {
154+
fs.unlinkSync(tmp);
155+
} catch (cleanupErr) {
156+
if ((cleanupErr as NodeJS.ErrnoException).code !== 'ENOENT') {
157+
debug(`snapshotRestore: failed to remove temp file ${tmp}: ${cleanupErr}`);
158+
}
83159
}
160+
throw err;
84161
}
85162

86-
fs.copyFileSync(src, dbPath);
87163
debug(`Restored snapshot "${name}" → ${dbPath}`);
88164
}
89165

@@ -122,10 +198,13 @@ export function snapshotDelete(name: string, options: SnapshotDbPathOptions = {}
122198
const dir = snapshotsDir(dbPath);
123199
const target = path.join(dir, `${name}.db`);
124200

125-
if (!fs.existsSync(target)) {
126-
throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target });
201+
try {
202+
fs.unlinkSync(target);
203+
} catch (err) {
204+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
205+
throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target });
206+
}
207+
throw err;
127208
}
128-
129-
fs.unlinkSync(target);
130209
debug(`Deleted snapshot: ${target}`);
131210
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Worker entry used by snapshot.test.ts to create genuine concurrency
2+
// between two snapshotSave calls. better-sqlite3 is synchronous, so
3+
// Promise-based concurrency cannot exercise the TOCTOU race — only
4+
// separate threads can.
5+
//
6+
// This file is .mjs so Node loads it directly without a TypeScript loader;
7+
// it imports the compiled dist build when available, falling back to the
8+
// TS source via the project's ts-resolve-hooks when running under vitest
9+
// (which propagates the strip-types flag to workers via NODE_OPTIONS).
10+
11+
import { parentPort, workerData } from 'node:worker_threads';
12+
13+
const { dbPath, name, force } = workerData;
14+
15+
try {
16+
const mod = await import('../../src/features/snapshot.ts');
17+
mod.snapshotSave(name, { dbPath, force });
18+
parentPort.postMessage({ ok: true });
19+
} catch (err) {
20+
parentPort.postMessage({ ok: false, error: err?.message ?? String(err) });
21+
}

tests/unit/snapshot.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
4+
import { fileURLToPath } from 'node:url';
5+
import { Worker } from 'node:worker_threads';
46
import Database from 'better-sqlite3';
57
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
68
import {
@@ -12,6 +14,8 @@ import {
1214
validateSnapshotName,
1315
} from '../../src/features/snapshot.js';
1416

17+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
18+
1519
let tmpDir: string;
1620
let dbPath: string;
1721

@@ -132,6 +136,109 @@ describe('snapshotSave', () => {
132136
it('rejects invalid name', () => {
133137
expect(() => snapshotSave('bad name', { dbPath })).toThrow(/Invalid snapshot name/);
134138
});
139+
140+
it('leaves no temp files in snapshots dir after success', () => {
141+
snapshotSave('clean', { dbPath });
142+
const entries = fs.readdirSync(snapshotsDir(dbPath));
143+
expect(entries).toContain('clean.db');
144+
// Temp files are named `.<name>.db.tmp-<pid>-<ts>` — none should remain.
145+
expect(entries.filter((f) => f.includes('.tmp-'))).toEqual([]);
146+
});
147+
148+
// Worker infrastructure for genuine cross-thread concurrency on
149+
// snapshotSave. better-sqlite3 is synchronous, so Promise-based
150+
// concurrency would queue two sequential microtasks — only separate
151+
// threads exercise the TOCTOU race.
152+
const nodeMajor = Number(process.versions.node.split('.')[0]);
153+
const raceWorkerPath = path.join(__dirname, 'snapshot-race-worker.mjs');
154+
// --import requires a URL (file://…) or a bare/relative specifier, not a
155+
// drive-letter path on Windows. Use the file:// URL directly.
156+
const loaderUrl = new URL('../../scripts/ts-resolve-loader.js', import.meta.url).href;
157+
const raceExecArgv = [
158+
nodeMajor >= 23 ? '--strip-types' : '--experimental-strip-types',
159+
'--import',
160+
loaderUrl,
161+
];
162+
const spawnSaveWorker = (workerData: {
163+
dbPath: string;
164+
name: string;
165+
force: boolean;
166+
}): Promise<{ ok: boolean; error?: string }> =>
167+
new Promise((resolve, reject) => {
168+
const w = new Worker(raceWorkerPath, { workerData, execArgv: raceExecArgv });
169+
let messageReceived = false;
170+
w.once('message', (msg) => {
171+
messageReceived = true;
172+
resolve(msg);
173+
});
174+
w.once('error', reject);
175+
w.once('exit', (code) => {
176+
if (!messageReceived) {
177+
reject(new Error(`worker exited with code ${code} before posting a message`));
178+
}
179+
});
180+
});
181+
182+
it('does not corrupt output when two --force saves race on the same name', async () => {
183+
// Prime the target so both workers take the --force overwrite path.
184+
snapshotSave('race', { dbPath });
185+
186+
// Spawn two worker threads racing on the same name. Post-fix, the atomic
187+
// rename ensures the winner's file is intact and the loser either
188+
// overwrites cleanly or leaves no corrupt artifact.
189+
const results = await Promise.allSettled([
190+
spawnSaveWorker({ dbPath, name: 'race', force: true }),
191+
spawnSaveWorker({ dbPath, name: 'race', force: true }),
192+
]);
193+
194+
// At least one save must have succeeded.
195+
const succeeded = results.filter((r) => r.status === 'fulfilled' && r.value.ok === true);
196+
expect(succeeded.length).toBeGreaterThanOrEqual(1);
197+
198+
// The final file must be a valid SQLite DB with the expected contents.
199+
const finalPath = path.join(snapshotsDir(dbPath), 'race.db');
200+
const db = new Database(finalPath, { readonly: true });
201+
const rows = db.prepare('SELECT name FROM nodes').all();
202+
db.close();
203+
expect(rows).toEqual([{ name: 'hello' }]);
204+
205+
// No temp files should leak from either worker.
206+
const entries = fs.readdirSync(snapshotsDir(dbPath));
207+
expect(entries.filter((f) => f.includes('.tmp-'))).toEqual([]);
208+
});
209+
210+
it('atomically rejects a concurrent non-force save when one already won', async () => {
211+
// With no existing snapshot, two concurrent non-force saves race on the
212+
// same name. Post-fix, the atomic linkSync(tmp, dest) makes the guard
213+
// authoritative: exactly one must succeed, the other must fail with
214+
// "already exists". Pre-fix, both could pass existsSync and silently
215+
// overwrite each other.
216+
const results = await Promise.allSettled([
217+
spawnSaveWorker({ dbPath, name: 'nonforce-race', force: false }),
218+
spawnSaveWorker({ dbPath, name: 'nonforce-race', force: false }),
219+
]);
220+
const fulfilled = results.filter((r) => r.status === 'fulfilled');
221+
expect(fulfilled).toHaveLength(2);
222+
223+
const outcomes = fulfilled.map(
224+
(r) => (r as PromiseFulfilledResult<{ ok: boolean; error?: string }>).value,
225+
);
226+
const wins = outcomes.filter((o) => o.ok);
227+
const losses = outcomes.filter((o) => !o.ok);
228+
expect(wins).toHaveLength(1);
229+
expect(losses).toHaveLength(1);
230+
expect(losses[0].error).toMatch(/already exists/);
231+
232+
// Final snapshot must be valid.
233+
const finalPath = path.join(snapshotsDir(dbPath), 'nonforce-race.db');
234+
const db = new Database(finalPath, { readonly: true });
235+
const rows = db.prepare('SELECT name FROM nodes').all();
236+
db.close();
237+
expect(rows).toEqual([{ name: 'hello' }]);
238+
239+
const entries = fs.readdirSync(snapshotsDir(dbPath));
240+
expect(entries.filter((f) => f.includes('.tmp-'))).toEqual([]);
241+
});
135242
});
136243

137244
// ─── snapshotRestore ────────────────────────────────────────────────────

0 commit comments

Comments
 (0)