Skip to content

Commit 281d27b

Browse files
committed
test(driver-sql,service-datasource): pin the damaged-header refusal, the dangling-link directory on the writing boot, and the dangling file link on the read-only boot
Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent f57b34b commit 281d27b

2 files changed

Lines changed: 521 additions & 0 deletions

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The last members of the SQLite file-failure family: a declared SQLite file
5+
* the datasource cannot use is classified by its own driver at connect, naming
6+
* the file, and nothing is written (#25019).
7+
*
8+
* - A SQLite database whose header or schema SQLite cannot read (a file
9+
* truncated inside its 100-byte header, or short of the pages its schema
10+
* names). The connection's first statement, `PRAGMA auto_vacuum`, answers
11+
* `SQLITE_CORRUPT`, which `connect()` logged and swallowed: it resolved
12+
* over a file no statement could read, and on a development boot the
13+
* step-down then moved the file aside. `SQLITE_CANTOPEN` (#23696) and
14+
* `SQLITE_NOTADB` (#24960) were already refused this way.
15+
* - A dangling symbolic link as the file's directory, on the boot that
16+
* writes. `makeDatabaseDirectory` took `mkdir`'s `EEXIST` on the link for
17+
* a concurrent creator, `connect()` resolved, and the first statement
18+
* failed with a code-less "directory does not exist". The read-only boot
19+
* already refused the same path (#24961), so the two boots disagreed.
20+
* - A dangling symbolic link as the file itself, on the read-only boot: it
21+
* read the file absent although the directory the link points into does
22+
* not exist, where the boot that writes refuses it with `SQLITE_CANTOPEN`.
23+
*
24+
* Every refusal is the driver's own, so the first one is pinned under both
25+
* `NODE_ENV` values.
26+
*/
27+
28+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
29+
import {
30+
existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, rmSync, symlinkSync,
31+
truncateSync, writeSync, closeSync,
32+
} from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join } from 'node:path';
35+
import { SqlDriver, resolveSqliteAbsentFileTarget } from './index.js';
36+
import { SqliteDatabaseFileError } from './sqlite-database-file.js';
37+
38+
const DANGLING_WORDS = (link: string) => `'${link}' is a symbolic link to a path that does not exist`;
39+
40+
const drivers: SqlDriver[] = [];
41+
function make(filename: string): SqlDriver {
42+
const d = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true } as any);
43+
drivers.push(d);
44+
return d;
45+
}
46+
47+
/** `connect()`'s rejection, or a failure saying it resolved. */
48+
async function refusalOf(driver: SqlDriver): Promise<any> {
49+
try {
50+
await driver.connect();
51+
} catch (error) {
52+
return error;
53+
}
54+
throw new Error('connect() resolved for a file the datasource cannot use');
55+
}
56+
57+
/** What `fn` threw; a failure naming its answer when it threw nothing. */
58+
function thrownBy(fn: () => unknown): any {
59+
let answer: unknown;
60+
try {
61+
answer = fn();
62+
} catch (error) {
63+
return error;
64+
}
65+
throw new Error(`answered ${JSON.stringify(answer)} for a file that cannot be created`);
66+
}
67+
68+
function expectFileRefusal(error: any, filename: string, code: string): void {
69+
expect(error).toBeInstanceOf(SqliteDatabaseFileError);
70+
expect(error.code).toBe(code);
71+
expect(error.filename).toBe(filename);
72+
expect(error.message).toContain(filename);
73+
}
74+
75+
/** Every path under `root`, each with its kind, so a link, a file or a directory that appears is seen. */
76+
function tree(root: string): string[] {
77+
return readdirSync(root, { recursive: true }).map(String).sort()
78+
.map((name) => `${name}:${lstatSync(join(root, name)).isSymbolicLink() ? 'link' : lstatSync(join(root, name)).isDirectory() ? 'dir' : 'file'}`);
79+
}
80+
81+
/** A real SQLite database, written by this driver, holding one row in `t`. */
82+
async function seedDatabase(filename: string): Promise<void> {
83+
const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename }, useNullAsDefault: true } as any);
84+
await seed.connect();
85+
await seed.execute('CREATE TABLE t (a INTEGER)');
86+
await seed.execute('INSERT INTO t (a) VALUES (7)');
87+
await seed.disconnect();
88+
}
89+
90+
/**
91+
* The refused file holds the bytes it was found with, and nothing is left
92+
* beside it. The header still says WAL (this driver's journal mode), so SQLite
93+
* keeps its `-shm` and an empty `-wal` while the refused connection is open:
94+
* no `.corrupt-*` sibling then, and nothing at all once it is closed.
95+
*/
96+
async function expectLeftAsItWas(driver: SqlDriver, filename: string, before: Buffer): Promise<void> {
97+
expect(readdirSync(dir).filter((name) => name.includes('.corrupt-'))).toEqual([]);
98+
await driver.disconnect();
99+
expect(readFileSync(filename).equals(before)).toBe(true);
100+
expect(readdirSync(dir)).toEqual(['archive.db']);
101+
}
102+
103+
let dir: string;
104+
beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'os-25019-')); });
105+
afterEach(async () => {
106+
vi.unstubAllEnvs();
107+
await Promise.all(drivers.splice(0).map((d) => d.disconnect().catch(() => {})));
108+
rmSync(dir, { recursive: true, force: true });
109+
});
110+
111+
describe('SqlDriver.connect — a declared SQLite file whose header or schema SQLite cannot read (#25019)', () => {
112+
for (const nodeEnv of ['development', 'production'] as const) {
113+
it(`refuses a database truncated inside its header with SQLITE_CORRUPT, naming it, and leaves its bytes untouched (NODE_ENV=${nodeEnv})`, async () => {
114+
const filename = join(dir, 'archive.db');
115+
await seedDatabase(filename);
116+
truncateSync(filename, 50);
117+
const before = readFileSync(filename);
118+
vi.stubEnv('NODE_ENV', nodeEnv);
119+
120+
const driver = make(filename);
121+
const error = await refusalOf(driver);
122+
123+
expectFileRefusal(error, filename, 'SQLITE_CORRUPT');
124+
expect(error.message).toContain((error.cause as Error).message);
125+
await expectLeftAsItWas(driver, filename, before);
126+
});
127+
}
128+
129+
it('refuses a database cut short of the pages its schema names, the same way', async () => {
130+
const filename = join(dir, 'archive.db');
131+
await seedDatabase(filename);
132+
truncateSync(filename, 4096);
133+
const before = readFileSync(filename);
134+
135+
const driver = make(filename);
136+
expectFileRefusal(await refusalOf(driver), filename, 'SQLITE_CORRUPT');
137+
await expectLeftAsItWas(driver, filename, before);
138+
});
139+
140+
it('CONTROL: a database damaged deeper inside still connects, and the read that reaches the damage fails', async () => {
141+
// The refusal is the FIRST statement's verdict, never an integrity check:
142+
// here the header and schema read, and one table's page does not.
143+
const filename = join(dir, 'archive.db');
144+
await seedDatabase(filename);
145+
const probe = make(filename);
146+
await probe.connect();
147+
const [{ rootpage }] = await probe.execute("SELECT rootpage FROM sqlite_master WHERE name = 't'");
148+
const [{ page_size: pageSize }] = await probe.execute('PRAGMA page_size');
149+
await probe.disconnect();
150+
const fd = openSync(filename, 'r+');
151+
writeSync(fd, Buffer.alloc(pageSize, 0xab), 0, pageSize, (rootpage - 1) * pageSize);
152+
closeSync(fd);
153+
154+
const driver = make(filename);
155+
await driver.connect();
156+
const failure = await driver.execute('SELECT a FROM t').then(
157+
() => { throw new Error('a read of the damaged page answered'); },
158+
(error: any) => error,
159+
);
160+
expect(failure?.cause?.code).toBe('SQLITE_CORRUPT');
161+
});
162+
});
163+
164+
describe('SqlDriver.connect — a dangling symbolic link as the directory, on the boot that writes (#25019)', () => {
165+
it('refuses it with the read-only boot\'s code, naming the file and the link, and creates nothing through it', async () => {
166+
const link = join(dir, 'link');
167+
symlinkSync(join(dir, 'nowhere'), link);
168+
const filename = join(link, 'archive.db');
169+
const before = tree(dir);
170+
171+
const writing = await refusalOf(make(filename));
172+
expectFileRefusal(writing, filename, 'ENOENT');
173+
expect(writing.message).toContain(DANGLING_WORDS(link));
174+
175+
// The read-only boot over the same path answers with the same error class,
176+
// code, file and words (#24961).
177+
const readOnly = thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory'));
178+
expectFileRefusal(readOnly, filename, 'ENOENT');
179+
expect(readOnly.message).toContain(DANGLING_WORDS(link));
180+
181+
expect(lstatSync(link).isSymbolicLink()).toBe(true);
182+
expect(existsSync(join(dir, 'nowhere'))).toBe(false);
183+
expect(tree(dir)).toEqual(before);
184+
});
185+
186+
it('refuses a deeper path under the link the same way, naming the link', async () => {
187+
const link = join(dir, 'link');
188+
symlinkSync(join(dir, 'nowhere'), link);
189+
const filename = join(link, 'sub', 'archive.db');
190+
const before = tree(dir);
191+
192+
const writing = await refusalOf(make(filename));
193+
expectFileRefusal(writing, filename, 'ENOENT');
194+
expect(writing.message).toContain(DANGLING_WORDS(link));
195+
expect(tree(dir)).toEqual(before);
196+
});
197+
198+
it('CONTROL: a directory that is a link to an existing directory connects, and the file is created in it', async () => {
199+
mkdirSync(join(dir, 'real'));
200+
symlinkSync(join(dir, 'real'), join(dir, 'link'));
201+
const driver = make(join(dir, 'link', 'archive.db'));
202+
await driver.connect();
203+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
204+
expect(readdirSync(join(dir, 'real'))).toContain('archive.db');
205+
});
206+
207+
it('CONTROL: an absent file under directories that can be made connects, and both are created', async () => {
208+
const filename = join(dir, 'new', 'deeper', 'archive.db');
209+
const driver = make(filename);
210+
await driver.connect();
211+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
212+
expect(existsSync(filename)).toBe(true);
213+
});
214+
});
215+
216+
describe('a dangling symbolic link as the database file itself (#25019)', () => {
217+
it('the read-only boot refuses it when the directory the chain of links ends in does not exist, as the writing boot does', async () => {
218+
const target = join(dir, 'missing', 'target.db');
219+
const direct = join(dir, 'archive.db');
220+
symlinkSync(target, direct);
221+
// The same end, reached through a second link.
222+
symlinkSync(target, join(dir, 'hop.db'));
223+
const chained = join(dir, 'chained.db');
224+
symlinkSync(join(dir, 'hop.db'), chained);
225+
const before = tree(dir);
226+
227+
for (const filename of [direct, chained]) {
228+
const readOnly = thrownBy(() => resolveSqliteAbsentFileTarget(filename, 'empty-in-memory'));
229+
expectFileRefusal(readOnly, filename, 'ENOENT');
230+
expect(readOnly.message).toContain(target);
231+
232+
// The writing boot refuses the same path with SQLite's own answer.
233+
expectFileRefusal(await refusalOf(make(filename)), filename, 'SQLITE_CANTOPEN');
234+
}
235+
expect(tree(dir)).toEqual(before);
236+
});
237+
238+
it('CONTROL: when that directory exists, the read-only boot reads it absent and writes nothing, and the writing boot creates the file through the link', async () => {
239+
symlinkSync(join(dir, 'target.db'), join(dir, 'archive.db'));
240+
// A relative target is read from the link's own directory.
241+
symlinkSync('relative-target.db', join(dir, 'relative.db'));
242+
const before = tree(dir);
243+
244+
for (const name of ['archive.db', 'relative.db']) {
245+
expect(resolveSqliteAbsentFileTarget(join(dir, name), 'empty-in-memory'))
246+
.toEqual({ filename: ':memory:', openedEmptyInMemory: true });
247+
}
248+
expect(tree(dir)).toEqual(before);
249+
250+
for (const name of ['archive.db', 'relative.db']) {
251+
const driver = make(join(dir, name));
252+
await driver.connect();
253+
expect(await driver.execute('SELECT 1 AS one')).toEqual([{ one: 1 }]);
254+
}
255+
expect(existsSync(join(dir, 'target.db'))).toBe(true);
256+
expect(existsSync(join(dir, 'relative-target.db'))).toBe(true);
257+
});
258+
});

0 commit comments

Comments
 (0)