Files
node/test/parallel/test-sqlite-backup.mjs
T
Guilherme Araújo e20da720c8 sqlite: validate backup() source is a Database
backup() only checked that the source argument was a BaseObject before
casting it to Database, so passing a Statement or Session caused a
type confusion. Check against the Database constructor template
instead.

Signed-off-by: Guilherme Araújo <arauujogui@gmail.com>
PR-URL: https://github.com/nodejs/node/pull/66797
Reviewed-By: Colin Ihrig <cjihrig@gmail.com>
Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
2026-10-25 21:35:13 +00:00

408 lines
11 KiB
JavaScript

// Flags: --expose-gc
import {
isWindows,
skipIfSQLiteMissing,
spawnPromisified,
} from '../common/index.mjs';
import fixtures from '../common/fixtures.js';
import tmpdir from '../common/tmpdir.js';
import { join } from 'node:path';
import { describe, test } from 'node:test';
import { writeFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';
skipIfSQLiteMissing();
const { backup, Database } = await import('node:sqlite');
const isRoot = !isWindows && process.getuid() === 0;
let cnt = 0;
tmpdir.refresh();
function nextDb() {
return join(tmpdir.path, `database-${cnt++}.db`);
}
function makeSourceDb(dbPath = ':memory:') {
const database = new Database(dbPath);
database.exec(`
CREATE TABLE data(
key INTEGER PRIMARY KEY,
value TEXT
) STRICT
`);
const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)');
for (let i = 1; i <= 2; i++) {
insert.run(i, `value-${i}`);
}
return database;
}
describe('backup()', () => {
test('throws if the source database is not provided', (t) => {
t.assert.throws(() => {
backup();
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "sourceDb" argument must be an instance of Database.'
});
});
test('throws if the source is not an instance of Database', (t) => {
const database = makeSourceDb();
for (const source of [
{},
database.prepare('SELECT 1'),
database.createSession(),
]) {
t.assert.throws(() => {
backup(source, 'out.db');
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "sourceDb" argument must be an instance of Database.'
});
}
});
test('throws if path is not a string, URL, or Buffer', (t) => {
const database = makeSourceDb();
t.assert.throws(() => {
backup(database);
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
});
t.assert.throws(() => {
backup(database, {});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
});
});
test('throws if the database path contains null bytes', (t) => {
const database = makeSourceDb();
t.assert.throws(() => {
backup(database, Buffer.from('l\0cation'));
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
});
t.assert.throws(() => {
backup(database, 'l\0cation');
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "path" argument must be a string, Uint8Array, or URL without null bytes.'
});
});
test('throws if options is not an object', (t) => {
const database = makeSourceDb();
t.assert.throws(() => {
backup(database, 'hello.db', 'invalid');
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "options" argument must be an object.'
});
});
test('throws if any of provided options is invalid', (t) => {
const database = makeSourceDb();
t.assert.throws(() => {
backup(database, 'hello.db', {
source: 42
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "options.source" argument must be a string.'
});
t.assert.throws(() => {
backup(database, 'hello.db', {
target: 42
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "options.target" argument must be a string.'
});
t.assert.throws(() => {
backup(database, 'hello.db', {
rate: 'invalid'
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "options.rate" argument must be an integer.'
});
for (const rate of [0, -1]) {
t.assert.throws(() => {
backup(database, 'hello.db', { rate });
}, {
code: 'ERR_OUT_OF_RANGE',
message: 'The "options.rate" argument must be a positive integer.'
});
}
t.assert.throws(() => {
backup(database, 'hello.db', {
progress: 'invalid'
});
}, {
code: 'ERR_INVALID_ARG_TYPE',
message: 'The "options.progress" argument must be a function.'
});
});
});
test('database backup', async (t) => {
const progressFn = t.mock.fn();
const database = makeSourceDb();
const destDb = nextDb();
await backup(database, destDb, {
rate: 1,
progress: progressFn,
});
const backupDb = new Database(destDb);
const rows = backupDb.prepare('SELECT * FROM data').all();
// The source database has two pages - using the default page size -,
// so the progress function should be called once (the last call is not made since
// the promise resolves)
t.assert.strictEqual(progressFn.mock.calls.length, 1);
t.assert.deepStrictEqual(progressFn.mock.calls[0].arguments, [{ totalPages: 2, remainingPages: 1 }]);
t.assert.deepStrictEqual(rows, [
{ __proto__: null, key: 1, value: 'value-1' },
{ __proto__: null, key: 2, value: 'value-2' },
]);
t.after(() => {
database.close();
backupDb.close();
});
});
test('backup database using location as URL', async (t) => {
const database = makeSourceDb();
const destDb = pathToFileURL(nextDb());
t.after(() => { database.close(); });
await backup(database, destDb);
const backupDb = new Database(destDb);
t.after(() => { backupDb.close(); });
const rows = backupDb.prepare('SELECT * FROM data').all();
t.assert.deepStrictEqual(rows, [
{ __proto__: null, key: 1, value: 'value-1' },
{ __proto__: null, key: 2, value: 'value-2' },
]);
});
test('backup database using location as Buffer', async (t) => {
const database = makeSourceDb();
const destDb = Buffer.from(nextDb());
t.after(() => { database.close(); });
await backup(database, destDb);
const backupDb = new Database(destDb);
t.after(() => { backupDb.close(); });
const rows = backupDb.prepare('SELECT * FROM data').all();
t.assert.deepStrictEqual(rows, [
{ __proto__: null, key: 1, value: 'value-1' },
{ __proto__: null, key: 2, value: 'value-2' },
]);
});
test('database backup in a single call', async (t) => {
const progressFn = t.mock.fn();
const database = makeSourceDb();
const destDb = nextDb();
// Let rate to be default (100) to backup in a single call
await backup(database, destDb, {
progress: progressFn,
});
const backupDb = new Database(destDb);
const rows = backupDb.prepare('SELECT * FROM data').all();
t.assert.strictEqual(progressFn.mock.calls.length, 0);
t.assert.deepStrictEqual(rows, [
{ __proto__: null, key: 1, value: 'value-1' },
{ __proto__: null, key: 2, value: 'value-2' },
]);
t.after(() => {
database.close();
backupDb.close();
});
});
test('throws exception when trying to start backup from a closed database', (t) => {
t.assert.throws(() => {
const database = new Database(':memory:');
database.close();
backup(database, 'backup.db');
}, {
code: 'ERR_INVALID_STATE',
message: 'database is not open'
});
});
test('throws if URL is not file: scheme', (t) => {
const database = new Database(':memory:');
t.after(() => { database.close(); });
t.assert.throws(() => {
backup(database, new URL('http://example.com/backup.db'));
}, {
code: 'ERR_INVALID_URL_SCHEME',
message: 'The URL must be of scheme file:',
});
});
test('throws if the URL-like path has an unparsable href', (t) => {
const database = new Database(':memory:');
t.after(() => { database.close(); });
t.assert.throws(() => {
backup(database, { href: 'not a url' });
}, { code: 'ERR_INVALID_URL' });
});
test('database backup fails when dest file is not writable', { skip: isRoot }, async (t) => {
const readonlyDestDb = nextDb();
writeFileSync(readonlyDestDb, '', { mode: 0o444 });
const database = makeSourceDb();
await t.assert.rejects(async () => {
await backup(database, readonlyDestDb);
}, {
code: 'ERR_SQLITE_ERROR',
message: 'attempt to write a readonly database'
});
});
test('backup fails when progress function throws', async (t) => {
const database = makeSourceDb();
const destDb = nextDb();
const progressFn = t.mock.fn(() => {
throw new Error('progress error');
});
await t.assert.rejects(async () => {
await backup(database, destDb, {
rate: 1,
progress: progressFn,
});
}, {
message: 'progress error'
});
});
test('backup fails when source db is invalid', async (t) => {
const database = makeSourceDb();
const destDb = nextDb();
await t.assert.rejects(async () => {
await backup(database, destDb, {
rate: 1,
source: 'invalid',
});
}, {
message: 'unknown database invalid'
});
});
test('backup fails when path cannot be opened', async (t) => {
const database = makeSourceDb();
await t.assert.rejects(async () => {
await backup(database, `${tmpdir.path}/invalid/backup.db`);
}, {
message: 'unable to open database file'
});
});
test('backup has correct name and length', (t) => {
t.assert.strictEqual(backup.name, 'backup');
t.assert.strictEqual(backup.length, 2);
});
test('source database is kept alive while a backup is in flight', async (t) => {
// Regression test: previously, BackupJob stored a raw Database* and the
// source could be garbage-collected while the backup was still running,
// leading to a use-after-free when BackupJob::Finalize() dereferenced the
// stale pointer via source_->RemoveBackup(this).
const destDb = nextDb();
let database = makeSourceDb();
// Insert enough rows to ensure the backup takes multiple steps.
const insert = database.prepare('INSERT INTO data (key, value) VALUES (?, ?)');
for (let i = 3; i <= 500; i++) {
insert.run(i, 'A'.repeat(1024) + i);
}
const p = backup(database, destDb, {
rate: 1,
progress() {},
});
// Drop the last strong JS reference to the source database. With the bug,
// the Database could be collected here and the in-flight backup would
// later crash while accessing the freed source.
database = null;
// Nudge the GC aggressively, but the backup must keep the source alive
// regardless. Without the fix, the source Database would be collected
// and BackupJob::Finalize() would crash the process.
for (let i = 0; i < 5; i++) {
global.gc();
await new Promise((resolve) => setImmediate(resolve));
}
const totalPages = await p;
t.assert.ok(totalPages > 0);
const backupDb = new Database(destDb);
t.after(() => { backupDb.close(); });
const rows = backupDb.prepare('SELECT COUNT(*) AS n FROM data').get();
t.assert.strictEqual(rows.n, 500);
});
test('backup promise settles when the backup is the last active request', async (t) => {
const { code, signal, stderr } = await spawnPromisified(process.execPath, [
fixtures.path('sqlite', 'backup-last-request.mjs'),
nextDb(),
]);
t.assert.strictEqual(signal, null);
t.assert.strictEqual(code, 0, stderr);
});