mirror of
https://github.com/nodejs/node.git
synced 2026-10-28 10:15:07 +08:00
The localStorage backing file is a user-specified path, and the schema is created with CREATE TABLE IF NOT EXISTS, so a file that already contains tables of those names is adopted as-is. Its stored values may then have any SQLite type, but every read asserted the expected type with CHECK, so a wrong-typed value aborted the process. A bad schema_version was the worst case: that assertion is in Storage::Open(), so any access aborted and the application had no chance to inspect or repair the file. Report these as ERR_INVALID_STATE instead, matching the throw four lines below the schema_version assertion for a version that is too new. Storage::GetAll() has no JavaScript caller to throw at, so it returns std::nullopt and the DOM storage inspector agent reports a protocol error. Now that a failed open returns instead of aborting, Open() has to clean up after itself: adopt the sqlite3* into a conn_unique_ptr immediately, so that an error does not leak the connection and leave the next access to open another one. Storage::GetAll() also ignored the result of sqlite3_prepare_v2() and the status its row loop ended on, reporting a malformed file or a mid-scan error as an empty store. Both now return std::nullopt. Also drop a redundant second sqlite3_exec() of the init SQL that clobbered the result of the sqlite3_prepare_v2() above it, hiding prepare failures behind a misleading "bad parameter or other API misuse". Signed-off-by: Trevor Burnham <trevorburnham@gmail.com> Assisted-by: Claude Opus 5 PR-URL: https://github.com/nodejs/node/pull/65879 Fixes: https://github.com/nodejs/node/issues/65878 Fixes: https://github.com/nodejs/node/issues/64640 Reviewed-By: Colin Ihrig <cjihrig@gmail.com> Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
const {
|
|
isLinux, isMacOS, skipIfSQLiteMissing, spawnPromisified,
|
|
} = require('../common');
|
|
skipIfSQLiteMissing();
|
|
const tmpdir = require('../common/tmpdir');
|
|
const assert = require('node:assert');
|
|
const { join } = require('node:path');
|
|
const { readdir } = require('node:fs/promises');
|
|
const { endianness } = require('node:os');
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
const { test, describe } = require('node:test');
|
|
let cnt = 0;
|
|
|
|
tmpdir.refresh();
|
|
|
|
function nextLocalStorage() {
|
|
return join(tmpdir.path, `${++cnt}.localstorage`);
|
|
}
|
|
|
|
// The tests below assert on which .localstorage files exist, so malformed
|
|
// fixtures are named so as not to be counted among them.
|
|
function nextMalformedLocalStorage() {
|
|
return join(tmpdir.path, `malformed-${++cnt}.db`);
|
|
}
|
|
|
|
async function localStorageFiles() {
|
|
return (await readdir(tmpdir.path)).filter((f) => f.endsWith('.localstorage'));
|
|
}
|
|
|
|
test('Storage instances cannot be created in userland', async () => {
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'-e', 'new globalThis.Storage()',
|
|
]);
|
|
|
|
assert.strictEqual(cp.code, 1);
|
|
assert.strictEqual(cp.signal, null);
|
|
assert.strictEqual(cp.stdout, '');
|
|
assert.match(cp.stderr, /Error: Illegal constructor/);
|
|
});
|
|
|
|
test('calling "length" getter on invalid this throws', async () => {
|
|
assert.throws(() => Storage.prototype.length, TypeError);
|
|
const { get } = Object.getOwnPropertyDescriptor(Storage.prototype, 'length');
|
|
for (const thisArg of [null, undefined, 1n, -0, NaN, true, false, '', [], {}, Symbol()]) {
|
|
assert.throws(() => get.call(thisArg), TypeError);
|
|
}
|
|
});
|
|
|
|
test('sessionStorage is not persisted', async () => {
|
|
let cp = await spawnPromisified(process.execPath, [
|
|
'-pe', 'sessionStorage.foo = "barbaz"',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.match(cp.stdout, /barbaz/);
|
|
|
|
cp = await spawnPromisified(process.execPath, [
|
|
'-pe', 'sessionStorage.foo',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.match(cp.stdout, /undefined/);
|
|
assert.deepStrictEqual(await localStorageFiles(), []);
|
|
});
|
|
|
|
test('localStorage returns undefined and warns without --localstorage-file', async () => {
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'-pe', 'localStorage',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.strictEqual(cp.signal, null);
|
|
assert.match(cp.stdout, /undefined/);
|
|
assert.match(cp.stderr, /ExperimentalWarning:.*localStorage is not available/);
|
|
});
|
|
|
|
test('localStorage is not enumerable without --localstorage-file', async () => {
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'-pe', 'Object.keys(globalThis).includes("localStorage")',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.match(cp.stdout, /false/);
|
|
});
|
|
|
|
test('localStorage is not persisted if it is unused', async () => {
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'--localstorage-file', nextLocalStorage(),
|
|
'-pe', 'localStorage === globalThis.localStorage',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.match(cp.stdout, /true/);
|
|
assert.deepStrictEqual(await localStorageFiles(), []);
|
|
});
|
|
|
|
test('localStorage is persisted if it is used', async () => {
|
|
const localStorageFile = nextLocalStorage();
|
|
let cp = await spawnPromisified(process.execPath, [
|
|
'--localstorage-file', localStorageFile,
|
|
'-pe', 'localStorage.foo = "barbaz"',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.match(cp.stdout, /barbaz/);
|
|
const entries = await localStorageFiles();
|
|
assert.strictEqual(entries.length, 1);
|
|
assert.match(entries[0], /\d+\.localstorage/);
|
|
|
|
cp = await spawnPromisified(process.execPath, [
|
|
'--localstorage-file', localStorageFile,
|
|
'-pe', 'localStorage.foo',
|
|
]);
|
|
assert.strictEqual(cp.code, 0);
|
|
assert.match(cp.stdout, /barbaz/);
|
|
});
|
|
|
|
|
|
describe('webstorage quota for localStorage and sessionStorage', () => {
|
|
const MAX_STORAGE_SIZE = 10 * 1024 * 1024;
|
|
|
|
for (const storage of ['localStorage', 'sessionStorage']) {
|
|
test(`${storage} can store a max of 10 MB quota`, async () => {
|
|
const args = [];
|
|
if (storage === 'localStorage') {
|
|
args.push('--localstorage-file', nextLocalStorage());
|
|
}
|
|
// Each character is 2 bytes
|
|
args.push('-e', `
|
|
const assert = require('assert');
|
|
${storage}['a'.repeat(${MAX_STORAGE_SIZE} / 2)] = '';
|
|
assert.throws(
|
|
() => { ${storage}.anything = 'should fail'; },
|
|
(err) => {
|
|
assert.strictEqual(err.name, 'QuotaExceededError');
|
|
assert.strictEqual(err.code, 22);
|
|
assert(err instanceof DOMException);
|
|
assert(err instanceof QuotaExceededError);
|
|
assert.strictEqual(err.quota, null);
|
|
assert.strictEqual(err.requested, null);
|
|
return true;
|
|
},
|
|
);
|
|
`);
|
|
const cp = await spawnPromisified(process.execPath, args);
|
|
assert.strictEqual(cp.code, 0);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('disabled with --no-webstorage', async () => {
|
|
for (const api of ['Storage', 'localStorage', 'sessionStorage']) {
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'--no-webstorage',
|
|
'--localstorage-file',
|
|
'./test/fixtures/localstoragefile-global-test',
|
|
'-e',
|
|
api,
|
|
]);
|
|
|
|
assert.strictEqual(cp.code, 1);
|
|
assert.strictEqual(cp.signal, null);
|
|
assert.strictEqual(cp.stdout, '');
|
|
assert(cp.stderr.includes(`ReferenceError: ${api} is not defined`));
|
|
}
|
|
});
|
|
|
|
describe('a malformed localStorage file throws instead of aborting', () => {
|
|
// Node's own tables are STRICT, so it cannot store a wrong-typed value
|
|
// itself. But they are created with IF NOT EXISTS, so a file that already
|
|
// contains tables of those names is adopted as-is. Declare the same schema
|
|
// without STRICT: BLOB columns have no affinity, so TEXT stays TEXT.
|
|
function malformedLocalStorage(fill) {
|
|
const file = nextMalformedLocalStorage();
|
|
const db = new DatabaseSync(file);
|
|
db.exec(`
|
|
CREATE TABLE nodejs_webstorage(
|
|
key BLOB NOT NULL, value BLOB NOT NULL, PRIMARY KEY(key)
|
|
);
|
|
CREATE TABLE nodejs_webstorage_state(
|
|
max_size INTEGER NOT NULL DEFAULT 10485760,
|
|
total_size INTEGER NOT NULL,
|
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1),
|
|
PRIMARY KEY(single_row_)
|
|
);
|
|
`);
|
|
fill({
|
|
insert: (key, value) => db.prepare(
|
|
'INSERT INTO nodejs_webstorage (key, value) VALUES (?, ?)',
|
|
).run(key, value),
|
|
setSchemaVersion: (schemaVersion) => db.prepare(
|
|
'INSERT INTO nodejs_webstorage_state (total_size, schema_version)' +
|
|
' VALUES (0, ?)',
|
|
).run(schemaVersion),
|
|
});
|
|
db.close();
|
|
return file;
|
|
}
|
|
|
|
// Keys are stored as UTF-16 code units in the platform's byte order, so a
|
|
// key only matches a lookup if it is encoded the same way.
|
|
const utf16 = (str) => {
|
|
const buf = Buffer.from(str, 'utf16le');
|
|
return endianness() === 'BE' ? buf.swap16() : buf;
|
|
};
|
|
|
|
for (const [name, fill, expression, detail] of [
|
|
[
|
|
'a text schema_version',
|
|
({ setSchemaVersion }) => setSchemaVersion('one'),
|
|
'localStorage.length',
|
|
'expected schema_version to be an integer',
|
|
],
|
|
[
|
|
'a text key read by key()',
|
|
({ insert, setSchemaVersion }) => {
|
|
insert('greeting', utf16('hello'));
|
|
setSchemaVersion(1);
|
|
},
|
|
'localStorage.key(0)',
|
|
'expected key to be a blob',
|
|
],
|
|
[
|
|
'a text key read by enumeration',
|
|
({ insert, setSchemaVersion }) => {
|
|
insert('greeting', utf16('hello'));
|
|
setSchemaVersion(1);
|
|
},
|
|
'Object.keys(localStorage)',
|
|
'expected key to be a blob',
|
|
],
|
|
[
|
|
'a text value',
|
|
({ insert, setSchemaVersion }) => {
|
|
insert(utf16('greeting'), 'hello');
|
|
setSchemaVersion(1);
|
|
},
|
|
"localStorage.getItem('greeting')",
|
|
'expected value to be a blob',
|
|
],
|
|
]) {
|
|
test(`${name}, via ${expression}`, async () => {
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'--localstorage-file', malformedLocalStorage(fill),
|
|
'-e', expression,
|
|
]);
|
|
|
|
assert.strictEqual(cp.code, 1);
|
|
assert.strictEqual(cp.signal, null);
|
|
assert(cp.stderr.includes(
|
|
`Error: localStorage database is malformed: ${detail}`,
|
|
));
|
|
assert(cp.stderr.includes("code: 'ERR_INVALID_STATE'"));
|
|
});
|
|
}
|
|
});
|
|
|
|
test('a malformed localStorage file does not leak connections', {
|
|
// Counting the process's own descriptors needs a /proc/self/fd or /dev/fd
|
|
// that lists all of them. AIX and IBM i expose only 0, 1 and 2 there, which
|
|
// would make the count constant and the test vacuous.
|
|
skip: (!isLinux && !isMacOS) && 'cannot enumerate open descriptors',
|
|
}, async () => {
|
|
const file = nextMalformedLocalStorage();
|
|
const db = new DatabaseSync(file);
|
|
db.exec(`
|
|
CREATE TABLE nodejs_webstorage_state(
|
|
max_size INTEGER NOT NULL DEFAULT 10485760,
|
|
total_size INTEGER NOT NULL,
|
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
single_row_ INTEGER NOT NULL DEFAULT 1 CHECK(single_row_ = 1),
|
|
PRIMARY KEY(single_row_)
|
|
);
|
|
`);
|
|
db.prepare('INSERT INTO nodejs_webstorage_state (total_size, schema_version)' +
|
|
' VALUES (0, ?)').run('one');
|
|
db.close();
|
|
|
|
// A failed open used to leave its sqlite3* behind, two descriptors at a time,
|
|
// so repeated access exhausted the descriptor limit and degraded the error
|
|
// into a misleading "unable to open database file".
|
|
const cp = await spawnPromisified(process.execPath, [
|
|
'--localstorage-file', file,
|
|
'-e', `
|
|
const assert = require('assert');
|
|
const { readdirSync } = require('fs');
|
|
const fdDir = process.platform === 'linux' ? '/proc/self/fd' : '/dev/fd';
|
|
const openDescriptors = () => readdirSync(fdDir).length;
|
|
const attempt = () => assert.throws(() => localStorage.length, {
|
|
code: 'ERR_INVALID_STATE',
|
|
message: /expected schema_version to be an integer/,
|
|
});
|
|
|
|
attempt();
|
|
const before = openDescriptors();
|
|
for (let i = 0; i < 200; i++) attempt();
|
|
const leaked = openDescriptors() - before;
|
|
assert.ok(leaked < 20, 'leaked ' + leaked + ' descriptors');
|
|
`,
|
|
]);
|
|
assert.strictEqual(cp.code, 0, cp.stderr);
|
|
assert.strictEqual(cp.stdout, '');
|
|
});
|