From aa2967d6733b36122313fc717b79256da835599b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:04:07 +0000 Subject: [PATCH 1/6] fix(spec, drivers): refuse a $like / $ilike pattern holding U+0000 at every face that refuses a dangling escape One spec predicate, hasNulInLikePattern, beside hasDanglingLikeEscape, asked at the driver-sql walk (through the withheld seam), the Turso RemoteTransport $like arm (through its seam), and driver-memory's shape gate and both of its translator floors. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../driver-memory/src/filter-refusal.ts | 36 ++++++++++- .../driver-memory/src/memory-driver.ts | 14 +++-- packages/drivers/driver-sql/src/sql-driver.ts | 48 ++++++++++++++- .../driver-turso/src/remote-transport.ts | 40 ++++++++++++- .../src/data/filter-like-nul-pattern.test.ts | 60 +++++++++++++++++++ packages/spec/src/data/filter.zod.ts | 29 +++++++++ 6 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 packages/spec/src/data/filter-like-nul-pattern.test.ts diff --git a/packages/drivers/driver-memory/src/filter-refusal.ts b/packages/drivers/driver-memory/src/filter-refusal.ts index f558a763964..ad59e3e54cd 100644 --- a/packages/drivers/driver-memory/src/filter-refusal.ts +++ b/packages/drivers/driver-memory/src/filter-refusal.ts @@ -36,8 +36,8 @@ import { FILTER_OPERATORS, LOGICAL_OPERATORS, RETIRED_FILTER_OPERATORS } from '@ // two backends cannot describe the accepted set differently. import { ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE } from '@objectstack/spec/data'; // [#7536] The `$like` pattern language's shared gate, so this driver refuses -// the same malformed patterns as every other face. -import { hasDanglingLikeEscape } from '@objectstack/spec/data'; +// the same malformed patterns as every other face. [#20041] The U+0000 gate too. +import { hasDanglingLikeEscape, hasNulInLikePattern } from '@objectstack/spec/data'; // [#16178] The canonical bucket-key vocabulary, quoted rather than hand-listed — // the refusal below names the accepted set from its one definition. import { BUCKET_GRANULARITIES } from '@objectstack/core'; @@ -926,6 +926,12 @@ function assertFieldConstraintShape( if (hasDanglingLikeEscape(spec[op] as string)) { throw danglingLikeEscapeError(field, op, spec[op] as string, `${path}.${op}`); } + // [#20041] This face ANSWERS such a pattern correctly; the SQLite faces + // cut it at the U+0000. Refused here too, so the accept set is one + // contract rather than one per backend. + if (hasNulInLikePattern(spec[op] as string)) { + throw nulLikePatternError(field, op, spec[op] as string, `${path}.${op}`); + } } } // [#5702] The `$options`-without-`$regex` companion check that stood here is @@ -998,6 +1004,32 @@ export function danglingLikeEscapeError( ); } +/** + * [#20041] A `$like` / `$ilike` pattern holding U+0000 (NUL). + * + * This driver could answer it — `likePatternToRegexSource` reads every + * character — and did, correctly. It is refused anyway because the SQLite faces + * cannot: their `GLOB` reads a pattern only up to its first U+0000 and answered + * a different question, with no NUL-safe primitive to compile to instead. An + * in-memory double that answers a filter production refuses would hide exactly + * that difference from an app's tests. `hasNulInLikePattern` is the spec's + * shared test, so every face refuses the SAME patterns, and the text is + * `driver-sql`'s author text word for word. + */ +export function nulLikePatternError( + field: string, + op: string, + pattern: string, + path = 'filter', +): Error { + return unsupportedFilterError( + `Operator "${op}" on field "${field}" at ${path} has a pattern holding the NUL character ` + + `U+0000 (${JSON.stringify(pattern)}). SQLite reads a pattern only up to its first NUL, so ` + + `such a pattern would match a different set of rows there than on the other backends, and ` + + `no escape makes the character portable. Remove it from the pattern.`, + ); +} + function icontainsComparandError(field: string, value: unknown, path: string): Error { const shown = typeof value === 'string' ? `""` : JSON.stringify(value) ?? String(value); return unsupportedFilterError( diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index a9b90d75465..ace3e3b2359 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -9,7 +9,7 @@ import { canonicalAstOperator, asciiCaseInsensitiveRegexSource } from '@objectst // [#7536] `$like`/`$ilike`'s pattern language, from the spec's one definition — // the same translation `formula` evaluates and the same pattern `driver-sql` // hands to LIKE/GLOB, so this face cannot answer a pattern differently. -import { hasDanglingLikeEscape, likePatternToRegexSource } from '@objectstack/spec/data'; +import { hasDanglingLikeEscape, hasNulInLikePattern, likePatternToRegexSource } from '@objectstack/spec/data'; import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; import { Logger, createLogger, nextUtcCalendarDay } from '@objectstack/core'; import { Query, Aggregator } from 'mingo'; @@ -32,6 +32,8 @@ import { // [#7536] The `$like`/`$ilike` comparand refusals, beside their siblings. likePatternComparandError, danglingLikeEscapeError, + // [#20041] ...and the U+0000 one. + nulLikePatternError, // [#10576] The per-aggregation `filter` refusal — this driver evaluates none. refusePerAggregationFilter, // [#13524] The declared authorable field vocabulary, IN DECLARATION ORDER — @@ -1389,6 +1391,8 @@ export class InMemoryDriver implements IDataDriver { case 'like': case 'ilike': { if (typeof value !== 'string') throw likePatternComparandError(field, canonical, value); if (hasDanglingLikeEscape(value)) throw danglingLikeEscapeError(field, canonical, value); + // [#20041] The same U+0000 refusal as the `$`-spelling's shape gate. + if (hasNulInLikePattern(value)) throw nulLikePatternError(field, canonical, value); return { [field]: { $regex: new RegExp(likePatternToRegexSource(value, canonical === 'ilike')) }, }; @@ -1628,11 +1632,13 @@ export class InMemoryDriver implements IDataDriver { case '$ilike': // The comparand's shape was settled by `assertFieldConstraintShape` // on the whole tree before this ran (the #5324/#5328 discipline every - // arm here follows), so `val` is a string with no dangling escape. - // The re-check is the totality floor a translator owes itself, the - // same one `driver-sql`'s emitter keeps beside its own gate. + // arm here follows), so `val` is a string with no dangling escape and + // no U+0000 (#20041). The re-check is the totality floor a translator + // owes itself, the same one `driver-sql`'s emitter keeps beside its + // own gate. if (typeof val !== 'string') throw likePatternComparandError(field, op, val, path); if (hasDanglingLikeEscape(val)) throw danglingLikeEscapeError(field, op, val, path); + if (hasNulInLikePattern(val)) throw nulLikePatternError(field, op, val, path); regexConditions.push({ $regex: new RegExp(likePatternToRegexSource(val, op === '$ilike')), }); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index a89054ee3aa..98b49f93622 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -63,7 +63,8 @@ import { // SQLite dialects need because GLOB is the only case-exact pattern operator // SQLite has and it does not speak `%`/`_`. `driver-turso`'s remote transport // compiles the same operator independently and calls the same two functions. -import { hasDanglingLikeEscape, likePatternToGlobPattern } from '@objectstack/spec/data'; +// [#20041] And the U+0000 gate beside the dangling-escape one, for the same reason. +import { hasDanglingLikeEscape, hasNulInLikePattern, likePatternToGlobPattern } from '@objectstack/spec/data'; import type { DriverQuery, IDataDriver } from '@objectstack/spec/contracts'; import { StandardErrorCode } from '@objectstack/spec/api'; import { StorageNameMapping } from '@objectstack/spec/system'; @@ -2481,6 +2482,46 @@ function danglingLikeEscapeError( ); } +/** + * [#20041] A `$like` / `$ilike` pattern holding U+0000 (NUL). + * + * Refused for the reason {@link danglingLikeEscapeError} is: no meaning survives + * every backend. This driver's SQLite arm compiles the pattern to `GLOB`, and + * SQLite reads a pattern only up to its first U+0000, so the pattern was CUT + * there and answered a different question than the JS faces, with nothing + * raised. Measured on better-sqlite3, sql.js and libSQL alike: `'%'` + U+0000 + * returned every non-NULL row, where `@objectstack/formula` returns only the + * values ending in U+0000. There is no NUL-safe SQLite pattern primitive to + * compile to instead. `hasNulInLikePattern` is the spec's shared test, asked on + * the same walk as the dangling escape, so every face refuses the SAME patterns + * — on every dialect of this driver, not only SQLite. + * + * Asked AFTER the dangling escape, so a pattern that has both keeps the refusal + * it already had. + */ +function nulLikePatternError( + field: string, + op: string, + pattern: string, + path: string, + subtree?: unknown, +): Error { + // [#20041, the #8220 contract] The pattern is the predicate's literal, and so + // are the field, the path and which of the two pattern operators it was. + return withheldFilterError( + `A pattern operator ("$like" / "$ilike") in this filter has a pattern holding the NUL character ` + + `U+0000. SQLite reads a pattern only up to its first NUL, so such a pattern would match a ` + + `different set of rows there than on the other backends, and no escape makes the character ` + + `portable. Remove it from the pattern. Which operator it was, the field it was aimed at and ` + + `the pattern are withheld from the message; the full diagnostic is in the server log.`, + `Operator "${op}" on field "${field}" at ${path} has a pattern holding the NUL character ` + + `U+0000 (${JSON.stringify(pattern)}). SQLite reads a pattern only up to its first NUL, so ` + + `such a pattern would match a different set of rows there than on the other backends, and ` + + `no escape makes the character portable. Remove it from the pattern.`, + subtree, + ); +} + /** * [#5041] The referenced field name when `value` is a Filter Protocol FIELD * REFERENCE (`{ $field: 'other_column' }` — spec `FieldReferenceSchema` in @@ -4573,6 +4614,11 @@ function classifyFilterKey( if (hasDanglingLikeEscape(pattern)) { throw danglingLikeEscapeError(key, op, pattern, `${here}.${op}`, value); } + // [#20041] A pattern holding U+0000 — SQLite's GLOB cuts it there. The + // operator map is the node, as for the dangling escape. + if (hasNulInLikePattern(pattern)) { + throw nulLikePatternError(key, op, pattern, `${here}.${op}`, value); + } } } diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index d0ed20b453e..01ce817d685 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -32,7 +32,8 @@ import { // the dangling-escape gate and the LIKE→GLOB translation. Shared with // `SqlDriver`'s local emitter so this transport and its local twin cannot fork // on what a pattern means (the fork `turso-local-remote-*` suites exist to catch). -import { hasDanglingLikeEscape, likePatternToGlobPattern } from '@objectstack/spec/data'; +// [#20041] And the U+0000 gate beside the dangling-escape one. +import { hasDanglingLikeEscape, hasNulInLikePattern, likePatternToGlobPattern } from '@objectstack/spec/data'; // [#8220] The read-scope provenance mark's consumer half — same resolution the // SqlDriver family applies, so which transport answered stays unobservable. import { resolveFilterSubtreeProvenance } from '@objectstack/spec/data'; @@ -3095,6 +3096,10 @@ export class RemoteTransport { if (hasDanglingLikeEscape(opValue)) { throw this.danglingLikeEscape(object, key, op, opValue, value); } + // [#20041] GLOB reads the pattern only up to its first U+0000. + if (hasNulInLikePattern(opValue)) { + throw this.nulLikePattern(object, key, op, opValue, value); + } if (this.pushTextOverNonTextColumn(clauses, object, key, op)) break; this.pushLikePattern(clauses, args, column, opValue, op === '$ilike'); break; @@ -3463,6 +3468,39 @@ export class RemoteTransport { ); } + /** + * [#20041] The error for a `$like` / `$ilike` pattern holding U+0000 (NUL). + * {@link pushLikePattern} sends it as a `GLOB` operand, and SQLite reads a + * pattern only up to its first U+0000, so the pattern was cut there and + * matched a different set of rows than the JS faces (measured on a real + * libSQL engine, identical to the local twin). `hasNulInLikePattern` is the + * spec's shared test, asked right after the dangling escape, so this + * transport refuses the same patterns as its local twin and the JS faces. + * The withheld sentence is `driver-sql`'s behind this file's prefix. + */ + private nulLikePattern( + object: string, + field: string, + op: string, + pattern: string, + subtree?: unknown, + ): Error { + // [#20041, the #8220 contract] The pattern is the predicate's literal. + return this.withheldRefusal( + '[RemoteTransport] A pattern operator ("$like" / "$ilike") in this filter has a pattern ' + + 'holding the NUL character U+0000. SQLite reads a pattern only up to its first NUL, so such ' + + 'a pattern would match a different set of rows there than on the other backends, and no ' + + 'escape makes the character portable. Remove it from the pattern. Which operator it was, ' + + 'the field it was aimed at and the pattern are withheld from the message; the full ' + + 'diagnostic is in the server log.', + subtree, + `[RemoteTransport] Operator "${op}" on '${object}.${field}' has a pattern holding the NUL ` + + `character U+0000 (${JSON.stringify(pattern)}). SQLite reads a pattern only up to its ` + + `first NUL, so such a pattern would match a different set of rows there than on the other ` + + `backends, and no escape makes the character portable. Remove it from the pattern.`, + ); + } + /** * [#5298] Wrap a negative-polarity value test so a row whose column has no * value SATISFIES it: `(col IS NULL OR )`. diff --git a/packages/spec/src/data/filter-like-nul-pattern.test.ts b/packages/spec/src/data/filter-like-nul-pattern.test.ts new file mode 100644 index 00000000000..b4db2016630 --- /dev/null +++ b/packages/spec/src/data/filter-like-nul-pattern.test.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20041] `hasNulInLikePattern` — the shared test every `$like` / `$ilike` + * refusal door asks, beside `hasDanglingLikeEscape`. + * + * The SQLite faces compile a pattern to `GLOB`, and SQLite reads a pattern only + * up to its first U+0000, so a pattern holding one was cut there and answered a + * different question than the JS translation. Every face that refuses a + * dangling escape now refuses such a pattern too; the driver suites pin each + * door's envelope. This file pins the predicate itself, and the two things it + * deliberately does NOT change. + */ + +import { describe, it, expect } from 'vitest'; +import { + hasNulInLikePattern, + hasDanglingLikeEscape, + likePatternToRegexSource, + matchesLikePattern, +} from './filter.zod'; + +const NUL = String.fromCharCode(0x00); + +describe('[#20041] hasNulInLikePattern', () => { + it('finds U+0000 at the start, in the middle, at the end, and alone', () => { + expect(hasNulInLikePattern(NUL + '%')).toBe(true); + expect(hasNulInLikePattern('a' + NUL + 'b')).toBe(true); + expect(hasNulInLikePattern('%' + NUL)).toBe(true); + expect(hasNulInLikePattern(NUL)).toBe(true); + }); + + it('finds an ESCAPED U+0000 too: a backslash makes it literal, not absent', () => { + expect(hasNulInLikePattern('\\' + NUL)).toBe(true); + // ...and that pattern has no dangling escape, so only this predicate refuses it. + expect(hasDanglingLikeEscape('\\' + NUL)).toBe(false); + }); + + it('answers false for every pattern without U+0000, wildcards and escapes included', () => { + for (const pattern of ['', '%', '_', 'a%b', '100\\% match', 'a\\\\b', 'U+0000', '\\0', '%00%']) { + expect(hasNulInLikePattern(pattern), JSON.stringify(pattern)).toBe(false); + } + }); + + it('is a different test from the dangling escape: neither implies the other', () => { + expect(hasNulInLikePattern('abc\\')).toBe(false); + expect(hasDanglingLikeEscape('abc\\')).toBe(true); + expect(hasNulInLikePattern('a' + NUL + '\\')).toBe(true); + expect(hasDanglingLikeEscape('a' + NUL + '\\')).toBe(true); + }); + + it('does not change the JS translation: the refusal lives at the doors, not in the converters', () => { + // `@objectstack/formula` evaluates through `matchesLikePattern` and is total + // by contract, so folding the refusal into the converters would have turned + // its answer into a caught throw, i.e. `false`. The doors refuse instead. + expect(() => likePatternToRegexSource('%' + NUL)).not.toThrow(); + expect(matchesLikePattern('ab' + NUL, '%' + NUL)).toBe(true); + expect(matchesLikePattern('ab', '%' + NUL)).toBe(false); + }); +}); diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index be90963c907..5f1f9c74fac 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1242,6 +1242,35 @@ export function hasDanglingLikeEscape(pattern: string): boolean { return backslashes % 2 === 1; } +/** U+0000, spelled by code point so no source file ever holds the raw byte. */ +const LIKE_PATTERN_NUL = String.fromCharCode(0x00); + +/** + * [#20041] Does this `$like`/`$ilike` pattern hold U+0000 (NUL) anywhere? + * + * Such a pattern is REFUSED everywhere rather than answered, for the reason + * {@link hasDanglingLikeEscape} gives for its own shape: the backends cannot be + * made to agree on what it means. The SQLite faces (`driver-sql` on SQLite, + * `driver-sqlite-wasm`, `driver-turso` on both transports) compile `$like` to + * `GLOB` over {@link likePatternToGlobPattern}'s translation, and SQLite reads a + * pattern only up to its first U+0000 — so the pattern is CUT there, silently, + * and matches a different set of rows than {@link likePatternToRegexSource} + * gives the JS faces. Measured on every SQLite face: `'%'` followed by U+0000 + * returned every non-NULL row, where the JS translation returns only the values + * that END in U+0000; `'a'` + U+0000 + `'b'` returned `'a'` as well. SQLite has + * no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, + * `replace()` cannot target U+0000, and `instr()` has no wildcards. + * + * So the check is shared, and every face that refuses a dangling escape refuses + * this too, in its own ADR-0112 `INVALID_FILTER` envelope. It judges the + * PATTERN only, and an escaped U+0000 (a backslash before it) holds one as + * well. A pattern without U+0000 matched against a STORED value holding one is + * a different question, and no refusal of the pattern can reach it. + */ +export function hasNulInLikePattern(pattern: string): boolean { + return pattern.includes(LIKE_PATTERN_NUL); +} + /** * [#7536] A `$like`/`$ilike` pattern as a regular-expression SOURCE with the * SAME meaning — the one translation every JS evaluation face must share, for From f706b0cf7ee09904eaafeb046134dcb201c3529c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:09:07 +0000 Subject: [PATCH 2/6] test(driver-sql): pin the U+0000 $like refusal on every position and dialect, and enumerate its seam door Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../sql-driver-20041-like-nul-pattern.test.ts | 219 ++++++++++++++++++ .../sql-driver-compile-refusal-seam.test.ts | 8 + 2 files changed, 227 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-20041-like-nul-pattern.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-20041-like-nul-pattern.test.ts b/packages/drivers/driver-sql/src/sql-driver-20041-like-nul-pattern.test.ts new file mode 100644 index 00000000000..84718ae7555 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-20041-like-nul-pattern.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20041] A `$like` / `$ilike` pattern holding U+0000 is REFUSED by + * `SqlDriver`, in the envelope its dangling-escape sibling answers with. + * + * ## What was measured before this file existed + * + * The SQLite arm compiles a pattern to `GLOB`, and SQLite reads a pattern only + * up to its first U+0000, so the pattern was cut there and nothing was raised. + * Measured at base `8d76c2d38c` through the public `find` on better-sqlite3, + * against `@objectstack/formula` over the same rows: `$like: '%'` + U+0000 + * returned all 12 non-NULL rows where `formula` returns the two ending in + * U+0000, and `$like: 'a'` + U+0000 + `'b'` also returned `'a'`. All 20 + * U+0000 cases of that probe (10 patterns, bare and under `$not`) differed on + * this face, on `SqliteWasmDriver` and on `TursoDriver` local and remote, + * identically; none differed on `driver-memory`. + * + * ## What is pinned + * + * - every U+0000 pattern, `$like` and `$ilike`, bare, under `$not`, and inside + * `$or` / `$and`, through `find` and `count`: `INVALID_FILTER` / 400, the + * error's own keys `code` and `status` only; + * - an UNMARKED caller gets the class statement and no operator, field, path + * or pattern (the #8220 fail direction), and the log gets all of them; + * - an `'author'`-marked caller gets the operator, field and PATH; + * - the dangling escape keeps its own refusal, also when the pattern holds + * U+0000 as well; + * - NUL-free `$like` / `$ilike` controls keep answering `formula`'s rows; + * - the refusal fires on the walk, before the dialect is chosen, so the + * Postgres and MySQL compiles refuse it too (compiled with no server). + * + * The withheld seam's own four-way pin for this builder lives in + * `sql-driver-compile-refusal-seam.test.ts`, beside every other door. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { Knex } from 'knex'; +import { markFilterSubtreeProvenance, type DriverOptions, type FilterCondition } from '@objectstack/spec/data'; +import { matchesFilterCondition } from '@objectstack/formula'; +import { SqlDriver, type SqlDriverConfig } from './index.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const NUL = String.fromCharCode(0x00); +const TABLE = 'like_nul_pattern'; +const BYPASS: DriverOptions = { bypassTenantAudit: true }; +const CLASS_STATEMENT = 'has a pattern holding the NUL character U+0000'; + +/** Stored values WITHOUT U+0000: a stored U+0000 is #20024's other half, not this card's. */ +const ROWS: Readonly> = { + ab: 'ab', + a: 'a', + b: 'b', + axb: 'axb', + AB: 'AB', + plain: 'plain', + empty: '', + missing: null, +}; + +/** [operator, pattern] — U+0000 at the start, middle and end, alone, escaped, beside wildcards. */ +const NUL_PATTERNS: ReadonlyArray = [ + ['$like', '%' + NUL], + ['$like', NUL + '%'], + ['$like', '%' + NUL + '%'], + ['$like', 'a' + NUL + 'b'], + ['$like', '_' + NUL + '_'], + ['$like', '\\' + NUL], + ['$like', NUL], + ['$ilike', '%' + NUL + 'B'], + ['$ilike', 'AB' + NUL], +]; + +const shown = (s: string) => JSON.stringify(s); + +const CONTROLS: ReadonlyArray = [ + ['$like', '%'], + ['$like', 'a%'], + ['$like', '%b'], + ['$like', 'a_b'], + ['$like', ''], + ['$like', '_'], + ['$ilike', '%B'], + ['$ilike', 'ab'], +]; + +describe('[#20041] SqlDriver (better-sqlite3): a $like / $ilike pattern holding U+0000 is refused', () => { + let driver: SqlDriver; + let logged: string[]; + + beforeAll(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.initObjects([{ name: TABLE, fields: { label: { type: 'string' }, v: { type: 'text' } } }]); + for (const [label, v] of Object.entries(ROWS)) await driver.create(TABLE, { label, v }, BYPASS); + logged = []; + (driver as unknown as { logger: unknown }).logger = { + warn: (m: string) => { logged.push(String(m)); }, + error: () => {}, + info: () => {}, + debug: () => {}, + }; + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + const refusalOf = async (where: unknown, via: 'find' | 'count' = 'find'): Promise => { + try { + if (via === 'find') await driver.find(TABLE, { where: where as FilterCondition }, BYPASS); + else await driver.count(TABLE, { where: where as FilterCondition }, BYPASS); + } catch (e) { + return e as WireBearingError; + } + throw new Error(`expected a refusal for ${JSON.stringify(where)}, but the filter resolved`); + }; + + const labels = async (where: FilterCondition): Promise => + ((await driver.find(TABLE, { where }, BYPASS)) as Array<{ label: string }>).map((r) => r.label).sort(); + + const formulaLabels = (where: FilterCondition): string[] => + Object.entries(ROWS) + .filter(([, v]) => matchesFilterCondition({ v }, where)) + .map(([l]) => l) + .sort(); + + for (const [op, pattern] of NUL_PATTERNS) { + const positions: ReadonlyArray Record, string]> = [ + ['bare', () => ({ v: { [op]: pattern } }), `filter.v.${op}`], + ['under $not', () => ({ $not: { v: { [op]: pattern } } }), `filter.$not.v.${op}`], + ['inside $or', () => ({ $or: [{ label: 'ab' }, { v: { [op]: pattern } }] }), `filter.$or[1].v.${op}`], + ['inside $and', () => ({ $and: [{ label: 'ab' }, { v: { [op]: pattern } }] }), `filter.$and[1].v.${op}`], + ]; + for (const [where, build, path] of positions) { + it(`${op} ${shown(pattern)} ${where}: unmarked ⇒ INVALID_FILTER / 400, class statement only`, async () => { + for (const via of ['find', 'count'] as const) { + logged = []; + const err = await refusalOf(build(), via); + expect(err.code, via).toBe('INVALID_FILTER'); + expect(err.status, via).toBe(400); + expect(Object.keys(err).sort(), via).toEqual(['code', 'status']); + expect(err.message, via).toContain(CLASS_STATEMENT); + expect(err.message, via).not.toContain(path); + expect(err.message, via).not.toContain('field "v"'); + expect(err.message, via).not.toContain(NUL); + // Relocated, not deleted: the operator, the field and the path are in the log. + const log = logged.join('\n'); + expect(log, via).toContain(`Operator "${op}" on field "v" at ${path}`); + } + }); + + it(`${op} ${shown(pattern)} ${where}: author-marked ⇒ the operator, the field and the path`, async () => { + const err = await refusalOf(markFilterSubtreeProvenance(build(), 'author')); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain(`Operator "${op}" on field "v" at ${path} ${CLASS_STATEMENT}`); + expect(err.message).toContain(shown(pattern)); + }); + } + } + + it('the dangling escape keeps its own refusal, including when the pattern also holds U+0000', async () => { + for (const pattern of ['abc\\', 'a' + NUL + '\\']) { + const err = await refusalOf(markFilterSubtreeProvenance({ v: { $like: pattern } }, 'author')); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('Operator "$like" on field "v" at filter.v.$like has a pattern ending in a lone unpaired backslash'); + expect(err.message).not.toContain(CLASS_STATEMENT); + } + }); + + for (const [op, pattern] of CONTROLS) { + it(`control: ${op} ${shown(pattern)} (no U+0000) answers formula's rows, bare and under $not`, async () => { + for (const where of [{ v: { [op]: pattern } }, { $not: { v: { [op]: pattern } } }] as FilterCondition[]) { + expect(await labels(where), JSON.stringify(where)).toEqual(formulaLabels(where)); + } + }); + } +}); + +describe('[#20041] every dialect of SqlDriver refuses it on the walk, before emitting SQL', () => { + class CompilerProbeDriver extends SqlDriver { + compileWhere(where: FilterCondition): string { + const builder: Knex.QueryBuilder = this.getKnex()(TABLE); + this.applyFilters(builder, where); + return builder.toString(); + } + } + + const DIALECTS: Array<[string, SqlDriverConfig]> = [ + ['sqlite', { client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }], + ['postgres', { client: 'pg', connection: { host: '127.0.0.1' } }], + ['mysql', { client: 'mysql2', connection: { host: '127.0.0.1' } }], + ]; + + for (const [label, config] of DIALECTS) { + it(`${label}: refused with INVALID_FILTER / 400; the NUL-free control still compiles`, () => { + const d = new CompilerProbeDriver(config); + d.registerExternalObject({ name: TABLE, fields: { label: { type: 'string' }, v: { type: 'text' } } }); + for (const op of ['$like', '$ilike'] as const) { + let err: WireBearingError | undefined; + try { + d.compileWhere({ v: { [op]: 'a' + NUL + '%' } } as FilterCondition); + } catch (e) { + err = e as WireBearingError; + } + expect(err, `${label} ${op}`).toBeDefined(); + expect(err!.code).toBe('INVALID_FILTER'); + expect(err!.status).toBe(400); + expect(err!.message).toContain(CLASS_STATEMENT); + expect(d.compileWhere({ v: { [op]: 'a%' } } as FilterCondition), `${label} ${op} control`).toMatch(/LIKE|GLOB/); + } + }); + } +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts index 040291653a5..408e9cb3a3e 100644 --- a/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-compile-refusal-seam.test.ts @@ -99,6 +99,14 @@ const DOORS: readonly Door[] = [ secrets: [POLICY_COL, SECRET], klass: 'ending in a lone unpaired backslash', }, + // ── #20041: born in the seam ─────────────────────────────────────────────── + { + builder: 'nulLikePatternError', + // U+0000 spelled by code point: a source file never holds the raw byte. + where: () => ({ [POLICY_COL]: { $like: `%${SECRET}${String.fromCharCode(0x00)}` } }), + secrets: [POLICY_COL, SECRET], + klass: 'has a pattern holding the NUL character U+0000', + }, { builder: 'unrenderableTextComparandError', where: () => ({ [POLICY_COL]: { $startsWith: { k: SECRET } } }), From d8d1d2dc91c54ed686f5b7fb2ff3a24e1ef9169e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:11:57 +0000 Subject: [PATCH 3/6] test(driver-sqlite-wasm, driver-turso, driver-memory): pin the U+0000 $like refusal on every face and door Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .../src/memory-20041-like-nul-pattern.test.ts | 152 +++++++++++++ ...sqlite-wasm-20041-like-nul-pattern.test.ts | 140 ++++++++++++ ...ote-transport-compile-refusal-seam.test.ts | 11 + .../src/turso-20041-like-nul-pattern.test.ts | 213 ++++++++++++++++++ 4 files changed, 516 insertions(+) create mode 100644 packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts create mode 100644 packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-20041-like-nul-pattern.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-20041-like-nul-pattern.test.ts diff --git a/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts b/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts new file mode 100644 index 00000000000..b96e96797f8 --- /dev/null +++ b/packages/drivers/driver-memory/src/memory-20041-like-nul-pattern.test.ts @@ -0,0 +1,152 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20041] `driver-memory` refuses a `$like` / `$ilike` pattern holding U+0000 + * on every door that refuses a dangling escape — although it answered one + * correctly. + * + * Measured at base `8d76c2d38c`: this driver's query path and its reference + * matcher both answered every U+0000 pattern of the probe exactly as + * `@objectstack/formula` did (20 of 20 cases, bare and under `$not`), because + * `likePatternToRegexSource` reads every character. The SQLite faces did not: + * their `GLOB` reads a pattern only up to its first U+0000, and there is no + * NUL-safe primitive to compile to instead. An in-memory double that answers a + * filter production refuses is the "one filter, two answers" divergence this + * driver exists to avoid, so it refuses the same patterns, through the spec's + * shared `hasNulInLikePattern`, with `driver-sql`'s author text word for word. + * + * The doors, all three pinned: + * + * - the `$`-spelling on the live query path (`assertFieldConstraintShape`); + * - the QueryAST `comparison` spelling (`convertConditionToMongo`'s `like` / + * `ilike` arm), which has its own dangling-escape refusal; + * - the reference matcher `match()`, through the same shape gate. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryDriver } from './memory-driver.js'; +import { match } from './memory-matcher.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const NUL = String.fromCharCode(0x00); +const TABLE = 'like_nul_pattern'; +const CLASS_STATEMENT = 'has a pattern holding the NUL character U+0000'; + +const ROWS: ReadonlyArray<{ label: string; v: string | null }> = [ + { label: 'ab', v: 'ab' }, + { label: 'a', v: 'a' }, + { label: 'b', v: 'b' }, + { label: 'axb', v: 'axb' }, + { label: 'AB', v: 'AB' }, + { label: 'plain', v: 'plain' }, + { label: 'empty', v: '' }, + { label: 'missing', v: null }, +]; + +const NUL_PATTERNS: ReadonlyArray = [ + ['$like', '%' + NUL], + ['$like', NUL + '%'], + ['$like', 'a' + NUL + 'b'], + ['$like', '\\' + NUL], + ['$ilike', '%' + NUL + 'B'], +]; + +/** NUL-free controls, with the rows `@objectstack/formula` answers over ROWS. */ +const CONTROLS: ReadonlyArray<{ where: Record; expected: readonly string[] }> = [ + { where: { v: { $like: '%' } }, expected: ['AB', 'a', 'ab', 'axb', 'b', 'empty', 'plain'] }, + { where: { v: { $like: 'a%' } }, expected: ['a', 'ab', 'axb'] }, + { where: { v: { $like: 'a_b' } }, expected: ['axb'] }, + { where: { v: { $like: '' } }, expected: ['empty'] }, + { where: { v: { $ilike: '%B' } }, expected: ['AB', 'ab', 'axb', 'b'] }, +]; + +describe('[#20041] driver-memory — a $like / $ilike pattern holding U+0000 is refused on every door', () => { + let driver: InMemoryDriver; + + beforeEach(async () => { + driver = new InMemoryDriver({ persistence: false, logger: { info() {}, warn() {}, error() {}, debug() {} } as never }); + await driver.connect(); + for (const row of ROWS) await driver.create(TABLE, { ...row }); + }); + + const refusalOf = async (where: unknown): Promise => { + const err = await driver + .find(TABLE, { where: where as never }) + .then(() => null, (e: unknown) => e as WireBearingError); + if (!err) throw new Error(`expected a refusal for ${JSON.stringify(where)}, but it ran`); + return err; + }; + + const matcherRefusalOf = (where: unknown): WireBearingError => { + try { + match({ v: 'ab' }, where); + } catch (e) { + return e as WireBearingError; + } + throw new Error(`expected the matcher to refuse ${JSON.stringify(where)}, but it answered`); + }; + + const expectEnvelope = (err: WireBearingError, located: string) => { + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(Object.keys(err).sort()).toEqual(['code', 'status']); + expect(err.message).toContain(`${located} ${CLASS_STATEMENT}`); + }; + + for (const [op, pattern] of NUL_PATTERNS) { + const shown = JSON.stringify(pattern); + + it(`${op} ${shown}: the live query path refuses it, bare and under $not, naming the path`, async () => { + expectEnvelope(await refusalOf({ v: { [op]: pattern } }), `Operator "${op}" on field "v" at filter.v.${op}`); + expectEnvelope( + await refusalOf({ $not: { v: { [op]: pattern } } }), + `Operator "${op}" on field "v" at filter.$not.v.${op}`, + ); + expectEnvelope( + await refusalOf({ $or: [{ label: 'ab' }, { v: { [op]: pattern } }] }), + `Operator "${op}" on field "v" at filter.$or[1].v.${op}`, + ); + }); + + it(`${op} ${shown}: the QueryAST comparison spelling refuses it`, async () => { + const operator = op.slice(1); + expectEnvelope( + await refusalOf({ type: 'comparison', field: 'v', operator, value: pattern }), + `Operator "${operator}" on field "v" at filter`, + ); + }); + + it(`${op} ${shown}: the reference matcher refuses it rather than answering`, () => { + expectEnvelope(matcherRefusalOf({ v: { [op]: pattern } }), `Operator "${op}" on field "v" at filter.v.${op}`); + }); + } + + it('the dangling escape keeps its own refusal on every door, also beside a U+0000', async () => { + for (const pattern of ['abc\\', 'a' + NUL + '\\']) { + for (const err of [ + await refusalOf({ v: { $like: pattern } }), + await refusalOf({ type: 'comparison', field: 'v', operator: 'like', value: pattern }), + matcherRefusalOf({ v: { $like: pattern } }), + ]) { + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('has a pattern ending in a lone unpaired backslash'); + expect(err.message).not.toContain(CLASS_STATEMENT); + } + } + }); + + for (const c of CONTROLS) { + it(`control: ${JSON.stringify(c.where)} (no U+0000) answers the same rows on the query path and the matcher`, async () => { + const queried = (await driver.find(TABLE, { where: c.where as never })) + .map((r: Record) => String(r.label)) + .sort(); + const matched = ROWS.filter((r) => match(r, c.where)).map((r) => r.label).sort(); + expect({ queried, matched }).toEqual({ queried: [...c.expected], matched: [...c.expected] }); + }); + } +}); diff --git a/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-20041-like-nul-pattern.test.ts b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-20041-like-nul-pattern.test.ts new file mode 100644 index 00000000000..dcfa1d5aece --- /dev/null +++ b/packages/drivers/driver-sqlite-wasm/src/sqlite-wasm-20041-like-nul-pattern.test.ts @@ -0,0 +1,140 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20041] `SqliteWasmDriver` refuses a `$like` / `$ilike` pattern holding + * U+0000, in the same envelope as `SqlDriver`, whose filter compiler it + * inherits. + * + * sql.js compiles the pattern to `GLOB` and reads it only up to its first + * U+0000, so the pattern was cut there and answered a different question. + * Measured at base `8d76c2d38c` through `find`, over the same rows as + * `driver-sql`'s probe: all 20 U+0000 cases differed from + * `@objectstack/formula`, byte-identical to better-sqlite3 (for example + * `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns + * two). The inherited walk now refuses it; this suite pins that the inheritance + * holds on this engine, and that NUL-free controls still answer `formula`'s + * rows. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { markFilterSubtreeProvenance, type DriverOptions, type FilterCondition } from '@objectstack/spec/data'; +import { matchesFilterCondition } from '@objectstack/formula'; +import { SqliteWasmDriver } from './index.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const NUL = String.fromCharCode(0x00); +const BYPASS: DriverOptions = { bypassTenantAudit: true }; +const TABLE = 'like_nul_pattern'; +const CLASS_STATEMENT = 'has a pattern holding the NUL character U+0000'; + +/** Stored values WITHOUT U+0000: a stored U+0000 is #20024's other half, not this card's. */ +const ROWS: Readonly> = { + ab: 'ab', + a: 'a', + b: 'b', + axb: 'axb', + AB: 'AB', + plain: 'plain', + empty: '', + missing: null, +}; + +const NUL_PATTERNS: ReadonlyArray = [ + ['$like', '%' + NUL], + ['$like', NUL + '%'], + ['$like', 'a' + NUL + 'b'], + ['$like', '\\' + NUL], + ['$ilike', '%' + NUL + 'B'], +]; + +const CONTROLS: ReadonlyArray = [ + ['$like', '%'], + ['$like', 'a%'], + ['$like', 'a_b'], + ['$like', ''], + ['$ilike', '%B'], +]; + +describe('[#20041] SqliteWasmDriver: a $like / $ilike pattern holding U+0000 is refused', () => { + let driver: SqliteWasmDriver; + let logged: string[]; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.initObjects([{ name: TABLE, fields: { label: { type: 'string' }, v: { type: 'text' } } }]); + for (const [label, v] of Object.entries(ROWS)) await driver.create(TABLE, { label, v }, BYPASS); + logged = []; + (driver as unknown as { logger: unknown }).logger = { + warn: (m: string) => { logged.push(String(m)); }, + error: () => {}, + info: () => {}, + debug: () => {}, + }; + }); + + afterAll(async () => { + await driver.disconnect(); + }); + + const refusalOf = async (where: unknown): Promise => { + try { + await driver.find(TABLE, { where: where as FilterCondition }, BYPASS); + } catch (e) { + return e as WireBearingError; + } + throw new Error(`expected a refusal for ${JSON.stringify(where)}, but the filter resolved`); + }; + + const labels = async (where: FilterCondition): Promise => + ((await driver.find(TABLE, { where }, BYPASS)) as Array<{ label: string }>).map((r) => r.label).sort(); + + const formulaLabels = (where: FilterCondition): string[] => + Object.entries(ROWS) + .filter(([, v]) => matchesFilterCondition({ v }, where)) + .map(([l]) => l) + .sort(); + + for (const [op, pattern] of NUL_PATTERNS) { + for (const [position, build, path] of [ + ['bare', () => ({ v: { [op]: pattern } }), `filter.v.${op}`], + ['under $not', () => ({ $not: { v: { [op]: pattern } } }), `filter.$not.v.${op}`], + ] as const) { + it(`${op} ${JSON.stringify(pattern)} ${position}: INVALID_FILTER / 400; the path reaches an author, and only the log otherwise`, async () => { + logged = []; + const unmarked = await refusalOf(build()); + expect(unmarked.code).toBe('INVALID_FILTER'); + expect(unmarked.status).toBe(400); + expect(Object.keys(unmarked).sort()).toEqual(['code', 'status']); + expect(unmarked.message).toContain(CLASS_STATEMENT); + expect(unmarked.message).not.toContain(path); + expect(logged.join('\n')).toContain(`Operator "${op}" on field "v" at ${path}`); + + const author = await refusalOf(markFilterSubtreeProvenance(build(), 'author')); + expect(author.code).toBe('INVALID_FILTER'); + expect(author.status).toBe(400); + expect(author.message).toContain(`Operator "${op}" on field "v" at ${path} ${CLASS_STATEMENT}`); + }); + } + } + + it('the dangling escape keeps its own refusal, including when the pattern also holds U+0000', async () => { + for (const pattern of ['abc\\', 'a' + NUL + '\\']) { + const err = await refusalOf(markFilterSubtreeProvenance({ v: { $like: pattern } }, 'author')); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('at filter.v.$like has a pattern ending in a lone unpaired backslash'); + } + }); + + for (const [op, pattern] of CONTROLS) { + it(`control: ${op} ${JSON.stringify(pattern)} (no U+0000) answers formula's rows, bare and under $not`, async () => { + for (const where of [{ v: { [op]: pattern } }, { $not: { v: { [op]: pattern } } }] as FilterCondition[]) { + expect(await labels(where), JSON.stringify(where)).toEqual(formulaLabels(where)); + } + }); + } +}); diff --git a/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts b/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts index cc4acefc65c..b19b1200d8a 100644 --- a/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts +++ b/packages/drivers/driver-turso/src/remote-transport-compile-refusal-seam.test.ts @@ -81,6 +81,15 @@ const DOORS: readonly Door[] = [ secrets: [POLICY_COL, SECRET], klass: 'ending in a lone unpaired backslash', }, + // ── #20041: born in the seam ─────────────────────────────────────────────── + { + builder: 'nulLikePattern', + // U+0000 spelled by code point: a source file never holds the raw byte. + where: () => ({ [POLICY_COL]: { $like: `%${SECRET}${String.fromCharCode(0x00)}` } }), + secrets: [POLICY_COL, SECRET], + klass: 'has a pattern holding the NUL character U+0000', + label: '{ secret_policy_col: { $like: "%PSECRET_LITERAL" + U+0000 } }', + }, { builder: 'undefinedComparand', where: () => ({ [POLICY_COL]: { $eq: undefined } }), @@ -441,6 +450,8 @@ describe('[#20039] TursoDriver LOCAL and REMOTE withhold these classes alike', ( ['undefined comparand', () => ({ [POLICY_COL]: { $eq: undefined } }), POLICY_COL], ['non-node $or element', () => ({ $or: [SECRET] }), SECRET], ['undeclared combinator', () => ({ [UNDECLARED_KEY]: 'x' }), UNDECLARED_KEY], + // [#20041] Written on both compilers as one sentence from the start. + ['U+0000 in a pattern', () => ({ [POLICY_COL]: { $like: `${SECRET}${String.fromCharCode(0x00)}` } }), SECRET], ]; for (const [label, where, secret] of SHARED) { diff --git a/packages/drivers/driver-turso/src/turso-20041-like-nul-pattern.test.ts b/packages/drivers/driver-turso/src/turso-20041-like-nul-pattern.test.ts new file mode 100644 index 00000000000..1e4d95c217b --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-20041-like-nul-pattern.test.ts @@ -0,0 +1,213 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#20041] Both of `TursoDriver`'s compilers refuse a `$like` / `$ilike` + * pattern holding U+0000, and send nothing to the engine for it. + * + * LOCAL mode inherits `SqlDriver`'s walk; REMOTE mode compiles in + * `RemoteTransport.buildWhereSQL`, whose `$like` arm sends the pattern through + * `pushLikePattern` as a `GLOB` operand. SQLite reads a pattern only up to its + * first U+0000, so on both the pattern was cut there and answered a different + * question. Measured at base `8d76c2d38c`, over the same rows as `driver-sql`'s + * probe: all 20 U+0000 cases differed from `@objectstack/formula` on local + * mode, on remote mode over `makeLibsqlSqliteStub` and on remote mode over a + * real `@libsql/client` `file::memory:` engine, identically (for example + * `$like: '%'` + U+0000 returned all 12 non-NULL rows where `formula` returns + * two). + * + * REMOTE runs twice, on the stub and on the real libSQL engine, for the reason + * `turso-20024-glob-stored-nul.test.ts` gives. A real Turso server is not + * measured here. The per-mark pin of the transport's door is in + * `remote-transport-compile-refusal-seam.test.ts`. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient, type Client, type InStatement } from '@libsql/client'; +import type { DriverQuery } from '@objectstack/spec/contracts'; +import { markFilterSubtreeProvenance } from '@objectstack/spec/data'; +import { TursoDriver } from './turso-driver.js'; +import { asLibsqlClient, makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; + +interface WireBearingError extends Error { + code?: string; + status?: number; +} + +const NUL = String.fromCharCode(0x00); +const OBJECT = { name: 'like_nul_pattern', fields: { label: { type: 'string' }, v: { type: 'text' } } }; +const CLASS_STATEMENT = 'has a pattern holding the NUL character U+0000'; + +/** Stored values WITHOUT U+0000: a stored U+0000 is #20024's other half, not this card's. */ +const ROWS: Readonly> = { + ab: 'ab', + a: 'a', + b: 'b', + axb: 'axb', + AB: 'AB', + plain: 'plain', + empty: '', + missing: null, +}; + +const NUL_PATTERNS: ReadonlyArray = [ + ['$like', '%' + NUL], + ['$like', 'a' + NUL + 'b'], + ['$like', '\\' + NUL], + ['$ilike', '%' + NUL + 'B'], +]; + +/** NUL-free controls with the rows `@objectstack/formula` answers over ROWS (checked there by the driver-sql twin). */ +const CONTROLS: ReadonlyArray<{ where: DriverQuery['where']; expected: readonly string[] }> = [ + { where: { v: { $like: '%' } }, expected: ['AB', 'a', 'ab', 'axb', 'b', 'empty', 'plain'] }, + { where: { $not: { v: { $like: '%' } } }, expected: ['missing'] }, + { where: { v: { $like: 'a%' } }, expected: ['a', 'ab', 'axb'] }, + { where: { $not: { v: { $like: 'a%' } } }, expected: ['AB', 'b', 'empty', 'missing', 'plain'] }, + { where: { v: { $like: 'a_b' } }, expected: ['axb'] }, + { where: { v: { $like: '' } }, expected: ['empty'] }, + { where: { v: { $ilike: '%B' } }, expected: ['AB', 'ab', 'axb', 'b'] }, + { where: { $not: { v: { $ilike: '%B' } } }, expected: ['a', 'empty', 'missing', 'plain'] }, +]; + +/** A real libSQL client whose `execute` also records each statement. */ +function recordingClient(inner: Client): { client: Client; calls: string[] } { + const calls: string[] = []; + const client = new Proxy(inner, { + get(target, prop) { + if (prop === 'execute') { + return (stmt: InStatement, ...rest: unknown[]) => { + calls.push(typeof stmt === 'string' ? stmt : stmt.sql); + return (target.execute as (...a: unknown[]) => unknown).call(target, stmt, ...rest); + }; + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + return { client, calls }; +} + +describe('[#20041] TursoDriver LOCAL and REMOTE refuse a $like / $ilike pattern holding U+0000', () => { + let local: TursoDriver; + let remote: TursoDriver; + let remoteLibsql: TursoDriver; + let stub: LibsqlSqliteStub; + let libsql: Client; + let libsqlCalls: string[]; + let logged: string[]; + + const quietLogger = () => ({ + warn: (m: string) => { logged.push(String(m)); }, + error: () => {}, + info: () => {}, + debug: () => {}, + }); + + beforeAll(async () => { + local = new TursoDriver({ url: ':memory:' }); + expect(local.transportMode).toBe('local'); + await local.initObjects([OBJECT]); + + stub = makeLibsqlSqliteStub(); + remote = new TursoDriver({ url: 'libsql://like-nul-pattern.turso.io', client: asLibsqlClient(stub) }); + await remote.connect(); + expect(remote.transportMode).toBe('remote'); + await remote.syncSchema(OBJECT.name, OBJECT); + + libsql = createClient({ url: 'file::memory:' }); + const recorded = recordingClient(libsql); + libsqlCalls = recorded.calls; + remoteLibsql = new TursoDriver({ url: 'libsql://like-nul-pattern-libsql.turso.io', client: recorded.client }); + await remoteLibsql.connect(); + expect(remoteLibsql.transportMode).toBe('remote'); + await remoteLibsql.syncSchema(OBJECT.name, OBJECT); + + for (const [label, v] of Object.entries(ROWS)) { + await local.create(OBJECT.name, { label, v }, { bypassTenantAudit: true }); + await remote.create(OBJECT.name, { label, v }); + await remoteLibsql.create(OBJECT.name, { label, v }); + } + logged = []; + for (const d of [local, remote, remoteLibsql]) (d as unknown as { logger: unknown }).logger = quietLogger(); + }); + + afterAll(async () => { + await local.disconnect(); + await remote.disconnect(); + await remoteLibsql.disconnect(); + stub.close(); + libsql.close(); + }); + + const refusalOf = async (driver: TursoDriver, where: unknown): Promise => { + const err = await driver + .find(OBJECT.name, { where } as DriverQuery, { bypassTenantAudit: true }) + .then(() => null, (e: unknown) => e as WireBearingError); + if (!err) throw new Error(`expected a refusal for ${JSON.stringify(where)}, but the filter resolved`); + return err; + }; + + const expectEnvelope = (err: WireBearingError, label: string) => { + expect(err.code, label).toBe('INVALID_FILTER'); + expect(err.status, label).toBe(400); + expect(Object.keys(err).sort(), label).toEqual(['code', 'status']); + expect(err.message, label).toContain(CLASS_STATEMENT); + }; + + for (const [op, pattern] of NUL_PATTERNS) { + for (const [position, build, localPath] of [ + ['bare', () => ({ v: { [op]: pattern } }), `filter.v.${op}`], + ['under $not', () => ({ $not: { v: { [op]: pattern } } }), `filter.$not.v.${op}`], + ] as const) { + it(`${op} ${JSON.stringify(pattern)} ${position}: refused on every transport, nothing sent`, async () => { + // Unmarked: the class statement on every transport, the location in the log. + for (const [label, driver] of [['local', local], ['remote', remote], ['remoteLibsql', remoteLibsql]] as const) { + logged = []; + libsqlCalls.length = 0; + const err = await refusalOf(driver, build()); + expectEnvelope(err, label); + expect(err.message, label).not.toContain(localPath); + expect(err.message, label).not.toContain(`'${OBJECT.name}.v'`); + const log = logged.join('\n'); + if (label === 'local') expect(log).toContain(`Operator "${op}" on field "v" at ${localPath}`); + else expect(log, label).toContain(`[RemoteTransport] Operator "${op}" on '${OBJECT.name}.v'`); + expect(libsqlCalls.filter((sql) => /\bFROM\b/i.test(sql)), label).toEqual([]); + } + + // Author-marked: LOCAL names the operator, the field and the path... + const author = await refusalOf(local, markFilterSubtreeProvenance(build(), 'author')); + expectEnvelope(author, 'local author'); + expect(author.message).toContain(`Operator "${op}" on field "v" at ${localPath} ${CLASS_STATEMENT}`); + // ...REMOTE cannot: `toRemoteFilter` rebuilds every node, so no mark + // reaches the transport and it withholds (the declared fail-closed cost). + for (const driver of [remote, remoteLibsql]) { + const err = await refusalOf(driver, markFilterSubtreeProvenance(build(), 'author')); + expectEnvelope(err, 'remote author'); + expect(err.message).toMatch(/^\[RemoteTransport\] A pattern operator/); + } + }); + } + } + + it('the dangling escape keeps its own refusal on every transport, also beside a U+0000', async () => { + for (const pattern of ['abc\\', 'a' + NUL + '\\']) { + for (const driver of [local, remote, remoteLibsql]) { + const err = await refusalOf(driver, { v: { $like: pattern } }); + expect(err.code).toBe('INVALID_FILTER'); + expect(err.status).toBe(400); + expect(err.message).toContain('ending in a lone unpaired backslash'); + expect(err.message).not.toContain(CLASS_STATEMENT); + } + } + }); + + for (const c of CONTROLS) { + it(`control: ${JSON.stringify(c.where)} (no U+0000) answers the same rows on every transport`, async () => { + const labels = async (driver: TursoDriver) => + ((await driver.find(OBJECT.name, { where: c.where }, { bypassTenantAudit: true })) as Array<{ label: string }>) + .map((r) => r.label) + .sort(); + expect({ local: await labels(local), remote: await labels(remote), remoteLibsql: await labels(remoteLibsql) }) + .toEqual({ local: [...c.expected], remote: [...c.expected], remoteLibsql: [...c.expected] }); + }); + } +}); From 5f2e6f246d0084d16f64bafb034cb04b97407ccb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:22:40 +0000 Subject: [PATCH 4/6] feat(spec): the $like operator's description names the U+0000 refusal; changeset and generated artifacts The regenerated api-surface / export-origins shards carry hasNulInLikePattern, and content/docs/references/data/filter.mdx the one-sentence describe change. The pending #20024 changeset's last bullet said a pattern holding U+0000 is still cut on SQLite; it is refused now, so that clause is corrected. Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20024-sqlite-glob-stored-nul.md | 2 +- .changeset/20041-like-nul-pattern-refused.md | 44 ++++++++++++++++++++ content/docs/references/data/filter.mdx | 10 ++--- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + packages/spec/src/data/filter.zod.ts | 5 ++- 6 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 .changeset/20041-like-nul-pattern-refused.md diff --git a/.changeset/20024-sqlite-glob-stored-nul.md b/.changeset/20024-sqlite-glob-stored-nul.md index fc876a2c905..93b5bd0ebd5 100644 --- a/.changeset/20024-sqlite-glob-stored-nul.md +++ b/.changeset/20024-sqlite-glob-stored-nul.md @@ -26,4 +26,4 @@ What does not change: - `$startsWith` with a comparand without U+0000 compiles to the same `GLOB` with the same bound pattern as before. The stored value's cut cannot change its answer. - No index is lost. The SQL `SqlDriver` compiles, run under `EXPLAIN QUERY PLAN` over an indexed TEXT column on all three engines, scanned the table for these four operators under `GLOB` and still does; `$startsWith` keeps its index search. - The Postgres and MySQL arms are untouched. -- `$like` and `$ilike` still compile to `GLOB` on SQLite, so they still read a stored value only up to its first U+0000, and a pattern holding U+0000 is still cut there. +- `$like` and `$ilike` still compile to `GLOB` on SQLite, so they still read a stored value only up to its first U+0000. A pattern holding U+0000 is no longer cut there: every driver that answers `$like` now refuses it (`INVALID_FILTER` / 400), by its own entry in this release. diff --git a/.changeset/20041-like-nul-pattern-refused.md b/.changeset/20041-like-nul-pattern-refused.md new file mode 100644 index 00000000000..0305500bdd8 --- /dev/null +++ b/.changeset/20041-like-nul-pattern-refused.md @@ -0,0 +1,44 @@ +--- +'@objectstack/spec': minor +'@objectstack/driver-sql': minor +'@objectstack/driver-sqlite-wasm': minor +'@objectstack/driver-turso': minor +'@objectstack/driver-memory': minor +--- + +fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite + +Clause-②: no (narrowing) + +On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: + +- `$like: '%'` + U+0000 returned all 12 non-NULL rows, where `formula` returns the two ending in U+0000; +- `$like: 'a'` + U+0000 + `'b'` also returned `'a'`; +- `$ilike: 'AB'` + U+0000 also returned `'AB'` and `'ab'`. + +`driver-memory` answered all 20 as `formula` does. SQLite has no NUL-safe pattern primitive to compile to instead: `LIKE` cuts the same way, `replace()` cannot target U+0000, and `instr()` has no wildcards. So the one contract is a refusal, the way a pattern ending in a lone unpaired backslash is refused. + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A filter that answered before is now refused**: a `$like` or `$ilike` pattern holding U+0000 anywhere (at the start, in the middle, at the end, alone, or after a backslash) gets `INVALID_FILTER` / 400, on every door that already refused the lone trailing backslash: + +- `@objectstack/driver-sql`: on the filter walk, before a dialect is chosen, so SQLite, Postgres and MySQL all refuse it. `@objectstack/driver-sqlite-wasm` and `TursoDriver`'s local mode inherit it; `@objectstack/driver-sqlite-wasm`'s own code does not change. +- `@objectstack/driver-turso`: the remote transport's `$like` / `$ilike` arm, before anything is sent to the engine. +- `@objectstack/driver-memory`: the shape gate of the query path and of the reference matcher `match()`, and the QueryAST `comparison` spelling (`like` / `ilike`). +- `@objectstack/spec` exports the shared test, `hasNulInLikePattern`, beside `hasDanglingLikeEscape`, and the `$like` operator's description now names the refusal. + +On `driver-sql` and the Turso remote transport the refusal goes through the read-scope provenance seam, like every other filter-compile refusal there. On `driver-sql` (and so `driver-sqlite-wasm` and Turso's local mode), a caller whose predicate is marked `'author'` reads the operator, the field, the filter path and the pattern, with U+0000 written as `\u0000`. Any other caller gets only the class statement, and the rest goes to the server log. The remote transport withholds the same way, and through `TursoDriver` in remote mode no mark reaches it, so every caller gets the class statement there. On `driver-memory` every caller reads the full text, as for its dangling-escape refusal. + +A pattern that ends in a lone unpaired backslash AND holds U+0000 keeps the dangling-escape refusal it had before. + +**What stays accepted**, pinned per face: every `$like` / `$ilike` pattern without U+0000 answers exactly as before. + +**Not changed here:** + +- A pattern without U+0000 matched against a STORED value that holds U+0000 still differs from `formula` on the SQLite faces, because `GLOB` also reads the stored value only up to its first U+0000. No refusal of the pattern can reach that half. +- `@objectstack/formula` still evaluates such a pattern. It refuses nothing, and answers `false` for a dangling escape rather than refusing it, so it is not one of these doors. +- `driver-mongodb`, objectql `having` and `service-analytics` refused every `$like` / `$ilike` before this change, and still do. + +**What an affected author does.** Remove the U+0000 from the pattern. No escape makes it portable: a backslash before it still leaves a U+0000 in the pattern. + +Blast radius, measured on this tree: no example or template writes a `$like` or `$ilike`, and the published `objectstack-query` skill and the hand-written docs that show one show no pattern holding U+0000. Whether any out-of-repo caller sends one is NOT measured and is not claimed to be zero. + + diff --git a/content/docs/references/data/filter.mdx b/content/docs/references/data/filter.mdx index 7595ccdaeec..b03de8e538f 100644 --- a/content/docs/references/data/filter.mdx +++ b/content/docs/references/data/filter.mdx @@ -115,7 +115,7 @@ const result = ComparisonOperatorSchema.parse(data); | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$endsWith** | `string` | optional | Ends with suffix, CASE-SENSITIVELY. Lowered to `LIKE '%?'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) and on every JS evaluation face, so it is portable across every backend the platform ships. | -| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | +| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash, or holding the NUL character U+0000, is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | @@ -241,7 +241,7 @@ Type: `[FilterArray](#filterarray)[]` | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$endsWith** | `string` | optional | Ends with suffix, CASE-SENSITIVELY. Lowered to `LIKE '%?'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) and on every JS evaluation face, so it is portable across every backend the platform ships. | -| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | +| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash, or holding the NUL character U+0000, is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | @@ -264,7 +264,7 @@ Type: `[FilterArray](#filterarray)[]` | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$endsWith** | `string` | optional | Ends with suffix, CASE-SENSITIVELY. Lowered to `LIKE '%?'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) and on every JS evaluation face, so it is portable across every backend the platform ships. | -| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | +| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash, or holding the NUL character U+0000, is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | @@ -287,7 +287,7 @@ Type: `[FilterArray](#filterarray)[]` | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$endsWith** | `string` | optional | Ends with suffix, CASE-SENSITIVELY. Lowered to `LIKE '%?'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) and on every JS evaluation face, so it is portable across every backend the platform ships. | -| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | +| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash, or holding the NUL character U+0000, is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | | **$null** | `boolean` | optional | Is-null check. `true` matches rows where the field is null, `false` matches rows where it is not null. Lowered to `IS NULL` (true) / `IS NOT NULL` (false) on the SQL family and to `{ field: null }` (true) / `{ $ne: null }` (false) on MongoDB. | | **$exists** | `boolean` | optional | Has-a-value check — the exact inverse of `$null`. `true` matches rows where the field holds a value (`!= null`), `false` matches rows where it holds none. Portable across every backend the platform ships: lowered to `IS NOT NULL` (true) / `IS NULL` (false) on the SQL family and to `{ $ne: null }` (true) / `{ $eq: null }` (false) on MongoDB. | @@ -352,7 +352,7 @@ Type: `[FilterArray](#filterarray)[]` | **$startsWith** | `string` | optional | Starts with prefix, CASE-SENSITIVELY. Lowered to `LIKE '?%'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$endsWith** | `string` | optional | Ends with suffix, CASE-SENSITIVELY. Lowered to `LIKE '%?'` (case-exact) on the SQL family. The comparand is matched LITERALLY: "%", "_" and regex metacharacters are ordinary characters, because this family escapes and anchors the comparand on the caller's behalf. To bind the wildcards yourself, write $like (case-exact) or $ilike (ASCII-folded). | | **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. Lowered on the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports) and on every JS evaluation face, so it is portable across every backend the platform ships. | -| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | +| **$like** | `string` | optional | Whole-string pattern match with CALLER-bound wildcards: "%" matches any sequence (including empty), "_" matches exactly one character, and a backslash escapes the character after it ("\\%", "\\_", "\\\\") so it matches literally. The pattern must cover the WHOLE value — a pattern with no wildcards is an exact comparison, NOT a substring search; write $contains for containment. A pattern ending in a lone unpaired backslash, or holding the NUL character U+0000, is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract as $contains (Q2 = A); $ilike is the case-insensitive twin. Answered by the SQL family (driver-sql, driver-sqlite-wasm, driver-turso on both transports), by driver-memory and by @objectstack/formula. driver-mongodb, objectql `having` and service-analytics REFUSE it in the INVALID_FILTER envelope rather than approximating it — see FILTER_OPERATORS for why it is staged out of that allowlist. | | **$ilike** | `string` | optional | Whole-string pattern match like $like — "%" / "_" wildcards bound by the caller, backslash escapes — but ignoring ASCII case (A-Z against a-z) and ONLY ASCII case: "café" does NOT match "CAFÉ", the same Q1 = A boundary $icontains declares, because SQLite's fold is ASCII-only and three of the five backends are SQLite underneath. Staged with $like — see FILTER_OPERATORS. | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 1ed8d5051a3..797535f1c88 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -763,6 +763,7 @@ "getTursoConfigJsonSchema (const)", "hasDanglingLikeEscape (function)", "hasDynamicTokens (function)", + "hasNulInLikePattern (function)", "hookForm (const)", "injectedSystemColumnDefs (function)", "isAcceptedFilterComparand (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 584291b590d..172c24774b8 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -750,6 +750,7 @@ "getTursoConfigJsonSchema": "src/data/driver/turso.zod.ts#getTursoConfigJsonSchema (const)", "hasDanglingLikeEscape": "src/data/filter.zod.ts#hasDanglingLikeEscape (function)", "hasDynamicTokens": "src/data/autonumber-format.ts#hasDynamicTokens (function)", + "hasNulInLikePattern": "src/data/filter.zod.ts#hasNulInLikePattern (function)", "hookForm": "src/data/hook.form.ts#hookForm (const)", "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", "isAcceptedFilterComparand": "src/data/filter-comparand-type.ts#isAcceptedFilterComparand (function)", diff --git a/packages/spec/src/data/filter.zod.ts b/packages/spec/src/data/filter.zod.ts index 5f1f9c74fac..ced35530652 100644 --- a/packages/spec/src/data/filter.zod.ts +++ b/packages/spec/src/data/filter.zod.ts @@ -1057,8 +1057,9 @@ const LIKE_DESCRIPTION = + 'backslash escapes the character after it ("\\%", "\\_", "\\\\") so it ' + 'matches literally. The pattern must cover the WHOLE value — a pattern ' + 'with no wildcards is an exact comparison, NOT a substring search; write ' - + '$contains for containment. A pattern ending in a lone unpaired backslash ' - + 'is refused (INVALID_FILTER). Comparison is case-SENSITIVE, same contract ' + + '$contains for containment. A pattern ending in a lone unpaired backslash, ' + + 'or holding the NUL character U+0000, is refused (INVALID_FILTER). ' + + 'Comparison is case-SENSITIVE, same contract ' + 'as $contains (Q2 = A); $ilike is the case-insensitive twin. ' + 'Answered by the SQL family (driver-sql, driver-sqlite-wasm, ' + 'driver-turso on both transports), by driver-memory and by ' From 049b3a6c95fa9a8b4743d7d0496c5f0349ead6a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 05:56:53 +0000 Subject: [PATCH 5/6] docs(driver-memory): the translator floor's comment says which half driver-sql's emitter shares Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- packages/drivers/driver-memory/src/memory-driver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-driver.ts b/packages/drivers/driver-memory/src/memory-driver.ts index ace3e3b2359..8d6d7ae8c6c 100644 --- a/packages/drivers/driver-memory/src/memory-driver.ts +++ b/packages/drivers/driver-memory/src/memory-driver.ts @@ -1634,8 +1634,8 @@ export class InMemoryDriver implements IDataDriver { // on the whole tree before this ran (the #5324/#5328 discipline every // arm here follows), so `val` is a string with no dangling escape and // no U+0000 (#20041). The re-check is the totality floor a translator - // owes itself, the same one `driver-sql`'s emitter keeps beside its - // own gate. + // owes itself; for the dangling escape it is the same one + // `driver-sql`'s emitter keeps beside its own gate. if (typeof val !== 'string') throw likePatternComparandError(field, op, val, path); if (hasDanglingLikeEscape(val)) throw danglingLikeEscapeError(field, op, val, path); if (hasNulInLikePattern(val)) throw nulLikePatternError(field, op, val, path); From b88ca46d901e0b7d509b89e716b6a89a885d359b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 07:09:49 +0000 Subject: [PATCH 6/6] =?UTF-8?q?changeset(20041):=20Clause-=E2=91=A1=20is?= =?UTF-8?q?=20yes=20(narrowing)=20=E2=80=94=20the=20PR=20adds=20a=20public?= =?UTF-8?q?=20export,=20hasNulInLikePattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Bvd69VPa6puiNzzPUroDBx Co-authored-by: Claude --- .changeset/20041-like-nul-pattern-refused.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/20041-like-nul-pattern-refused.md b/.changeset/20041-like-nul-pattern-refused.md index 0305500bdd8..f841dfec92b 100644 --- a/.changeset/20041-like-nul-pattern-refused.md +++ b/.changeset/20041-like-nul-pattern-refused.md @@ -8,7 +8,7 @@ fix(spec, drivers)!: a `$like` / `$ilike` pattern holding U+0000 is refused by every driver that answers `$like`, instead of being cut at the NUL on SQLite -Clause-②: no (narrowing) +Clause-②: yes (narrowing) On the SQLite faces `$like` / `$ilike` compile to `GLOB`, and SQLite reads a pattern only up to its first U+0000. A pattern holding U+0000 was cut there, so the filter answered a different question, and nothing raised. Measured through `find` over 13 stored values (12 non-NULL), against `@objectstack/formula` on the same rows: all 20 U+0000 cases of the probe (10 patterns, bare and under `$not`) differed on `SqlDriver` over better-sqlite3, on `SqliteWasmDriver`, on `TursoDriver`'s local mode, and on its remote mode over a stub and over a real `@libsql/client` engine, with identical answers on all five. For example: