|
| 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