Skip to content

Commit b0236c6

Browse files
committed
test(spec,driver-turso): pin the forced-remote local-url encryptionKey refusal (wip)
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
1 parent 0833772 commit b0236c6

3 files changed

Lines changed: 183 additions & 3 deletions

File tree

‎packages/drivers/driver-turso/src/spec/turso-config-constructor-parity.test.ts‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@
3737
* non-empty `encryptionKey` in a local or replica mode, refused on
3838
* `encryptionKey` — the fifth copy, one text per mode. Those rows used to
3939
* be accepted everywhere, and the driver wrote the file in plaintext: its
40-
* local engine (better-sqlite3) has no encryption codec.
40+
* local engine (better-sqlite3) has no encryption codec. And for the same
41+
* key beside a `file:` url or `:memory:` under a forced `mode: 'remote'`,
42+
* whose libSQL client is handed no key — the sixth copy, one text for a
43+
* file and one for an in-memory database. The mirror declares no `mode`, so
44+
* it judges those configs as the local database their url selects (it
45+
* refuses them on `encryptionKey` in the local arm's words); its own copy
46+
* of the remote arm is unreachable through it and kept byte-identical, as
47+
* its other forced-mode branches are.
4148
*
4249
* ⚠️ The mirror declares no `mode`, so zod strips an authored one before its
4350
* refinement runs: rows that FORCE a mode are judged by the constructor and the
@@ -121,6 +128,12 @@ const ROWS: Row[] = [
121128
// [#25020] An empty key is unset, as the loaders read it.
122129
{ name: 'an empty encryptionKey (unset) beside a file: url', config: { url: FILE, encryptionKey: '' }, ctor: 'accept' },
123130
{ name: 'an empty encryptionKey (unset) on a replica', config: { url: FILE, syncUrl: REMOTE, sync: { onConnect: false }, encryptionKey: '' }, ctor: 'accept' },
131+
// [#25020, patch round 1] The forced-remote local-url arm's controls: no key, an empty key, and a
132+
// remote url with a key — a remote server's storage, which this card does not judge.
133+
{ name: ":memory: under a forced mode: 'remote'", config: { url: ':memory:', mode: 'remote' }, ctor: 'accept' },
134+
{ name: "an empty encryptionKey (unset) beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', encryptionKey: '' }, ctor: 'accept' },
135+
{ name: 'encryptionKey beside a libsql:// url (remote: not judged by this card)', config: { url: REMOTE, encryptionKey: KEY }, ctor: 'accept' },
136+
{ name: "encryptionKey beside libsql:// under a forced mode: 'remote' (not judged by this card)", config: { url: REMOTE, mode: 'remote', encryptionKey: KEY }, ctor: 'accept' },
124137

125138
// ── a remote url in a local or replica mode ─────────────────────────────
126139
{ name: 'libsql:// beside syncUrl', config: { url: REMOTE, syncUrl: REMOTE }, ctor: 'refuse', refusedOn: 'url' },
@@ -210,6 +223,19 @@ const ROWS: Row[] = [
210223
{ name: 'encryptionKey beside sync with no syncUrl: the sync refusal first', config: { url: FILE, sync: { intervalSeconds: 60 }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'sync', issues: 2 },
211224
{ name: "encryptionKey under a forced mode: 'replica' with no syncUrl: the mode refusal first", config: { url: FILE, mode: 'replica', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'mode' },
212225
{ name: "encryptionKey + syncUrl under a forced mode: 'local': the mode refusal first", config: { url: FILE, mode: 'local', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'mode' },
226+
227+
// ── the same key beside a local url under a forced mode: 'remote' (#25020, patch round 1) ──
228+
// Judged by where the database lives, not by the mode: `connect()` hands the url to the libSQL
229+
// client the driver builds, which is handed no key. The mirror declares no `mode`, so these rows
230+
// are judged by the constructor and the spec contract only (see the header).
231+
{ name: "encryptionKey beside file: under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' },
232+
{ name: "encryptionKey beside an uppercase FILE: url under a forced mode: 'remote'", config: { url: `FILE:${DIR}/upper-remote-key.db`, mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' },
233+
{ name: "encryptionKey beside file: + timeoutMs under a forced mode: 'remote'", config: { url: FILE, mode: 'remote', timeoutMs: 5000, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' },
234+
{ name: "encryptionKey beside :memory: under a forced mode: 'remote'", config: { url: ':memory:', mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' },
235+
{ name: "encryptionKey beside file::memory: under a forced mode: 'remote'", config: { url: 'file::memory:', mode: 'remote', encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'encryptionKey' },
236+
// ORDER against the forced-remote refusals: the spec contract raises BOTH issues (theirs first); the constructor throws the first.
237+
{ name: "encryptionKey + syncUrl beside file: under a forced mode: 'remote': the syncUrl refusal first", config: { url: FILE, mode: 'remote', syncUrl: REMOTE, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'syncUrl', issues: 2 },
238+
{ name: "encryptionKey + sync with no syncUrl beside file: under a forced mode: 'remote': the sync refusal first", config: { url: FILE, mode: 'remote', sync: { onConnect: true }, encryptionKey: KEY }, ctor: 'refuse', refusedOn: 'sync', issues: 2 },
213239
];
214240

215241
/**
@@ -285,9 +311,11 @@ describe('turso config: the constructor, the spec contract and this mirror agree
285311
expect(ROWS.filter((r) => r.refusedOn === 'mode' && r.config.mode === 'local').length).toBeGreaterThanOrEqual(8);
286312
// [#25020] Both modes of the `encryptionKey` refusal, floored apart so the
287313
// replica half cannot shrink behind the local half.
288-
expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey').length).toBeGreaterThanOrEqual(8);
314+
expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey').length).toBeGreaterThanOrEqual(13);
289315
expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey' && (r.config.syncUrl || r.config.mode === 'replica')).length).toBeGreaterThanOrEqual(3);
290-
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(31);
316+
// [#25020, patch round 1] The forced-remote local-url half, floored on its own.
317+
expect(ROWS.filter((r) => r.refusedOn === 'encryptionKey' && r.config.mode === 'remote').length).toBeGreaterThanOrEqual(5);
318+
expect(SYNC_KEY_REFUSALS.length).toBeGreaterThanOrEqual(38);
291319
// [#20200] Exactly zero, not a floor: every key the constructor used to
292320
// build and ignore is refused at construction now (see the header).
293321
expect(ROWS.filter((r) => r.inert).length).toBe(0);

‎packages/drivers/driver-turso/src/turso-driver-local-encryption-key-refusal.test.ts‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@
3333
* contract's issue byte for byte; that equality is pinned in
3434
* `spec/turso-config-constructor-parity.test.ts`, not here.
3535
*
36+
* [#25020, patch round 1] The same key beside a `file:` url or `:memory:` under
37+
* a forced `mode: 'remote'` is refused too, in its own words: that mode skipped
38+
* every local-engine refusal, and `connect()` handed the local url to the
39+
* libSQL client the driver builds, which is handed no key. Its controls: the
40+
* same shape with no key or an empty one, and a `libsql://` url with a key,
41+
* still construct.
42+
*
3643
* The replica pins need no sync server: the refusal fires at construction,
3744
* before any client is built. A replica with the key and a REACHABLE sync
3845
* server was not measured — no libSQL sync server runs in this package's test
@@ -90,6 +97,17 @@ const REPLICA_FIRST_SENTENCE =
9097
'engine): nothing would encrypt the file, so it would be written in plaintext that any SQLite reader ' +
9198
'opens without the key — the turso driver refuses this configuration when it starts.';
9299

100+
const REMOTE_FILE_FIRST_SENTENCE =
101+
"`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local `file:` " +
102+
"url, and `mode: 'remote'` runs every read and write of that file through the libSQL client this driver " +
103+
'builds, which is handed no key: nothing would encrypt the file, so it would be written in plaintext that ' +
104+
'any SQLite reader opens without the key — the turso driver refuses this configuration when it starts.';
105+
const REMOTE_MEMORY_FIRST_SENTENCE =
106+
"`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory " +
107+
"database, and `mode: 'remote'` runs every read and write of it through the libSQL client this driver " +
108+
'builds, which is handed no key: there is no file for the key to encrypt, so it would change nothing — ' +
109+
'the turso driver refuses this configuration when it starts.';
110+
93111
/** A client that never touches a network: the replica arm takes it instead of building one. */
94112
const offlineClient = () => ({ close() {}, sync: async () => ({}) }) as unknown as TursoDriverConfig['client'];
95113

@@ -159,6 +177,55 @@ describe('an embedded replica: `encryptionKey` is refused at construction', () =
159177
});
160178
});
161179

180+
// [#25020, patch round 1] A forced `mode: 'remote'` answers first in `detectMode`, so this shape
181+
// skipped every local-engine refusal, and `connect()` handed the local url to the libSQL client the
182+
// driver builds, with no key. Judged by where the database lives, not by the mode.
183+
describe("a local url under a forced mode: 'remote': `encryptionKey` is refused at construction", () => {
184+
it.each<[string, (path: string) => TursoDriverConfig]>([
185+
['a file: url', (path) => ({ url: `file:${path}`, mode: 'remote', encryptionKey: KEY })],
186+
['an uppercase FILE: url', (path) => ({ url: `FILE:${path}`, mode: 'remote', encryptionKey: KEY })],
187+
['a file: url beside timeout', (path) => ({ url: `file:${path}`, mode: 'remote', timeout: 5000, encryptionKey: KEY })],
188+
])('%s: VALIDATION_ERROR / 400, naming the forced remote mode, and no file is opened', (_name, configOf) => {
189+
const path = nextPath();
190+
const refusal = refusalOf(() => new TursoDriver(configOf(path)));
191+
192+
expect(refusal).not.toBeNull();
193+
expect(refusal!.code).toBe('VALIDATION_ERROR');
194+
expect(refusal!.status).toBe(400);
195+
expect(refusal!.message.startsWith(REMOTE_FILE_FIRST_SENTENCE), refusal!.message).toBe(true);
196+
expect(existsSync(path)).toBe(false);
197+
});
198+
199+
it.each([':memory:', 'file::memory:'])('%s: refused in the in-memory words', (url) => {
200+
const refusal = refusalOf(() => new TursoDriver({ url, mode: 'remote', encryptionKey: KEY }));
201+
202+
expect(refusal?.code).toBe('VALIDATION_ERROR');
203+
expect(refusal?.status).toBe(400);
204+
expect(refusal!.message.startsWith(REMOTE_MEMORY_FIRST_SENTENCE), refusal!.message).toBe(true);
205+
});
206+
207+
it('createTursoDriver() refuses the same config, and the message names no url and no key', () => {
208+
const refusal = refusalOf(
209+
() => createTursoDriver({ url: `file:${join(DIR, 'private-dir.db')}`, mode: 'remote', encryptionKey: 'private-key-value' }),
210+
);
211+
212+
expect(refusal?.code).toBe('VALIDATION_ERROR');
213+
expect(refusal?.status).toBe(400);
214+
expect(refusal!.message).not.toContain('private-dir');
215+
expect(refusal!.message).not.toContain('private-key-value');
216+
});
217+
218+
it.each<[string, TursoDriverConfig]>([
219+
['the same file: url with no key', { url: `file:${join(DIR, 'remote-no-key.db')}`, mode: 'remote' }],
220+
['the same file: url with an empty key (unset)', { url: `file:${join(DIR, 'remote-empty-key.db')}`, mode: 'remote', encryptionKey: '' }],
221+
[':memory: with no key', { url: ':memory:', mode: 'remote' }],
222+
['a libsql:// url with a key (a remote server\'s storage: not judged by this card)', { url: 'libsql://db.example.invalid', encryptionKey: KEY }],
223+
["a libsql:// url with a key under a forced mode: 'remote'", { url: 'libsql://db.example.invalid', mode: 'remote', encryptionKey: KEY }],
224+
])('CONTROL: %s still constructs, in remote mode', (_name, config) => {
225+
expect(new TursoDriver(config).transportMode).toBe('remote');
226+
});
227+
});
228+
162229
describe('CONTROLS — a datasource without a key works exactly as before', () => {
163230
const Database = createRequire(import.meta.url)('better-sqlite3') as new (
164231
filename: string,

‎packages/spec/src/data/driver/turso.test.ts‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,91 @@ describe('TursoConfigSchema refuses what the turso driver refuses', () => {
579579
const description = TursoConfigSchema.shape.encryptionKey.description ?? '';
580580
expect(description).not.toMatch(/AES-256/);
581581
expect(description).toContain('refused for a local or replica datasource');
582+
expect(description).toContain('under a forced remote mode');
583+
});
584+
});
585+
586+
// #25020, patch round 1 — the same key beside a local url under a FORCED
587+
// `mode: 'remote'`. The forced mode skipped every local-engine refusal, and
588+
// `connect()` handed the url to the libSQL client the driver builds, which is
589+
// handed no key: plaintext all the same. Judged by where the database lives,
590+
// not by the mode, in its own words.
591+
describe("a non-empty `encryptionKey` beside a `file:` url or `:memory:` under a forced `mode: 'remote'` — refused on `encryptionKey`", () => {
592+
const FILE_FIRST_SENTENCE =
593+
"`encryptionKey` asks for this datasource's database file to be encrypted, but `url` is a local `file:` "
594+
+ "url, and `mode: 'remote'` runs every read and write of that file through the libSQL client this driver "
595+
+ 'builds, which is handed no key: nothing would encrypt the file, so it would be written in plaintext that '
596+
+ 'any SQLite reader opens without the key — the turso driver refuses this configuration when it starts.';
597+
const MEMORY_FIRST_SENTENCE =
598+
"`encryptionKey` asks for this datasource's database to be encrypted, but `url` names an in-memory "
599+
+ "database, and `mode: 'remote'` runs every read and write of it through the libSQL client this driver "
600+
+ 'builds, which is handed no key: there is no file for the key to encrypt, so it would change nothing — '
601+
+ 'the turso driver refuses this configuration when it starts.';
602+
const KEY = 'test-key-25020';
603+
604+
it('a `file:` url in any case, behind whitespace, beside `timeoutMs`', () => {
605+
for (const config of [
606+
{ url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY },
607+
{ url: 'FILE:./data/app.db', mode: 'remote', encryptionKey: KEY },
608+
{ url: ' file:./data/app.db', mode: 'remote', encryptionKey: KEY },
609+
{ url: 'file:./data/app.db', mode: 'remote', timeoutMs: 5000, encryptionKey: KEY },
610+
]) {
611+
const { path, message } = refusal(config);
612+
expect(path, JSON.stringify(config)).toBe('encryptionKey');
613+
expect(message.startsWith(FILE_FIRST_SENTENCE), message).toBe(true);
614+
expect(message).toContain('Drop `encryptionKey`, and encrypt the disk or volume the file lives on instead');
615+
}
616+
});
617+
618+
it('an in-memory url — `:memory:`, `file::memory:`, a `:memory:?` query — in its own words', () => {
619+
for (const url of [':memory:', 'file::memory:', 'FILE::memory:', 'file::memory:?cache=shared']) {
620+
const { path, message } = refusal({ url, mode: 'remote', encryptionKey: KEY });
621+
expect(path, url).toBe('encryptionKey');
622+
expect(message.startsWith(MEMORY_FIRST_SENTENCE), message).toBe(true);
623+
}
624+
});
625+
626+
it('never echoes the key or the url', () => {
627+
const { message } = refusal({ url: 'file:./private-dir/app.db', mode: 'remote', encryptionKey: 'private-key-value' });
628+
expect(message).not.toContain('private-dir');
629+
expect(message).not.toContain('private-key-value');
630+
});
631+
632+
it('comes last in the remote arm: a `syncUrl` or a lone `sync` keeps its own refusal, first', () => {
633+
for (const [config, first] of [
634+
[{ url: 'file:./data/app.db', mode: 'remote', syncUrl: 'libsql://db.turso.io', encryptionKey: KEY }, 'syncUrl'],
635+
[{ url: 'file:./data/app.db', mode: 'remote', sync: { onConnect: true }, encryptionKey: KEY }, 'sync'],
636+
] as const) {
637+
const result = TursoConfigSchema.safeParse(config);
638+
expect(result.success).toBe(false);
639+
expect(result.error!.issues.map((i) => i.path.join('.')), JSON.stringify(config)).toEqual([first, 'encryptionKey']);
640+
}
641+
});
642+
643+
it('reaches both authoring doors: `DatasourceSchema` (re-pathed under `config`) and `validateDriverConfig`', () => {
644+
const config = { url: 'file:./data/app.db', mode: 'remote', encryptionKey: KEY };
645+
const datasource = DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config });
646+
expect(datasource.success).toBe(false);
647+
const issue = datasource.error!.issues.find((i) => i.path.join('.') === 'config.encryptionKey');
648+
expect(issue, JSON.stringify(datasource.error!.issues)).toBeDefined();
649+
expect(issue!.message.startsWith(FILE_FIRST_SENTENCE)).toBe(true);
650+
651+
const verdict = validateDriverConfig('turso', config);
652+
expect(verdict.known).toBe(true);
653+
expect(verdict.known && verdict.issues.map((i) => i.path.join('.'))).toEqual(['encryptionKey']);
654+
});
655+
656+
it('controls: no key or an empty one, and a remote url with a key (not judged by this card), stay accepted', () => {
657+
for (const config of [
658+
{ url: 'file:./data/app.db', mode: 'remote' },
659+
{ url: 'file:./data/app.db', mode: 'remote', encryptionKey: '' },
660+
{ url: ':memory:', mode: 'remote' },
661+
{ url: 'libsql://db.turso.io', encryptionKey: KEY },
662+
{ url: 'libsql://db.turso.io', mode: 'remote', encryptionKey: KEY },
663+
]) {
664+
const result = TursoConfigSchema.safeParse(config);
665+
expect(result.success, JSON.stringify(result.error?.issues)).toBe(true);
666+
}
582667
});
583668
});
584669

0 commit comments

Comments
 (0)