Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/turso-patches/01-stub-macro.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
Neutralize Turso's stub! macro (sqlite3/src/lib.rs).

Many SQLite C API functions are stubbed out via `stub!()`, which expands
to `todo!("X is not implemented")`. pdo_sqlite hits one during PDO
construction (sqlite3_set_authorizer) and the panic aborts the PHP
process. Rewrite the body to return a zeroed value of the function's
return type (0 / SQLITE_OK for ints, NULL for pointers) instead.
"""

import sys

PATH = 'sqlite3/src/lib.rs'
OLD = 'todo!("{} is not implemented", stringify!($fn));'
NEW = 'return unsafe { std::mem::zeroed() };'

with open(PATH) as f:
src = f.read()
if OLD not in src:
sys.exit(f'{PATH}: stub! todo!() body not found')
n = src.count(OLD)
src = src.replace(OLD, NEW)
with open(PATH, 'w') as f:
f.write(src)
print(f'patched stub! macro ({n} occurrence{"s" if n != 1 else ""})')
53 changes: 53 additions & 0 deletions .github/turso-patches/02-column-functions-null-row.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
sqlite3_column_* — early-return when no row is present (sqlite3/src/lib.rs).

The sqlite3_column_* functions `.expect()` that a row is present, but
pdo_sqlite legitimately calls them on statements that have not yet
stepped to SQLITE_ROW (e.g. for column metadata). Replace the expect
with an early return of the type's "null" value, matching SQLite's
actual behaviour.
"""

import re
import sys

PATH = 'sqlite3/src/lib.rs'

DEFAULTS = {
'sqlite3_column_type': 'SQLITE_NULL',
'sqlite3_column_int': '0',
'sqlite3_column_int64': '0',
'sqlite3_column_double': '0.0',
'sqlite3_column_blob': 'std::ptr::null()',
'sqlite3_column_bytes': '0',
'sqlite3_column_text': 'std::ptr::null()',
}

PATTERN = re.compile(
r'(pub unsafe extern "C" fn (sqlite3_column_\w+)\([^)]*\)[^{]*\{)'
r'((?:[^{}]|\{[^{}]*\})*?)'
r'(let row = stmt\s*\.stmt\s*\.row\(\)\s*'
r'\.expect\("Function should only be called after `SQLITE_ROW`"\);)',
re.DOTALL,
)


def repl(m):
header, name, body, _ = m.group(1), m.group(2), m.group(3), m.group(4)
default = DEFAULTS.get(name, '0')
guarded = (
f'let row = match stmt.stmt.row() {{ '
f'Some(r) => r, None => return {default} }};'
)
return header + body + guarded


with open(PATH) as f:
src = f.read()
src, n = PATTERN.subn(repl, src)
if n == 0:
sys.exit(f'{PATH}: no sqlite3_column_* expect-row blocks matched')
with open(PATH, 'w') as f:
f.write(src)
print(f'patched {n} sqlite3_column_* functions')
46 changes: 46 additions & 0 deletions .github/turso-patches/03-create-function-v2-skip-old-destroy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""
create_function_v2 — don't fire the old destroy callback on slot reuse (sqlite3/src/lib.rs).

Turso's create_function_v2 invokes the previous FuncSlot's destroy
callback when re-registering a UDF with the same name. In practice
(PHPUnit) this means:
- setUp #1 opens PDO A, registers 44 UDFs, each with a destroy
callback + p_app pointing to A.
- tearDown closes PDO A — Turso's sqlite3_close doesn't clear those
FuncSlots.
- setUp #2 opens PDO B, re-registers the same 44 names. Turso invokes
the OLD destroy callback with the now-dangling A p_app, which trips
pdo_sqlite and hangs the process.

Comment the destroy invocation out; the callbacks still fire at real
PDO-destruction time from the PHP side.
"""

import sys

PATH = 'sqlite3/src/lib.rs'
OLD = (
' // Reuse existing slot — invoke old destroy callback on old user data.\n'
' if let Some(old) = slots[id].take() {\n'
' if old.destroy != 0 {\n'
' let old_destroy: unsafe extern "C" fn(*mut ffi::c_void) =\n'
' std::mem::transmute(old.destroy);\n'
' old_destroy(old.p_app as *mut ffi::c_void);\n'
' }\n'
' }\n'
)
NEW = (
" // Don't invoke the old destroy callback here — in PDO\n"
" // usage the previous slot's p_app often belongs to a db\n"
' // that has already been closed, so the callback UAFs.\n'
' let _ = slots[id].take();\n'
)

with open(PATH) as f:
s = f.read()
if OLD not in s:
sys.exit(f'{PATH}: slot destroy block not found')
with open(PATH, 'w') as f:
f.write(s.replace(OLD, NEW, 1))
print('patched slot-reuse destroy invocation')
109 changes: 109 additions & 0 deletions .github/turso-patches/04-finalize-try-lock-on-gc-reentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
sqlite3_finalize / stmt_run_to_completion — try_lock to dodge GC re-entry deadlock.

sqlite3_finalize uses a non-reentrant std::sync::Mutex on the db. PHP's
cycle GC can fire a PDO statement destructor while another statement's
sqlite3_step is in progress (i.e., from inside a UDF callback whose
bridge re-enters PHP). The outer step holds the mutex, the inner
finalize blocks on it, and we deadlock.

Fix: use try_lock; on contention, skip the stmt_list unlink and the
drain. The list is only traversed by sqlite3_next_stmt (which
pdo_sqlite doesn't call) and dropped on sqlite3_close, so a stale entry
is harmless; the stmt's own Box is still freed below.
"""

import sys

PATH = 'sqlite3/src/lib.rs'

OLD_FINALIZE = (
' if !stmt_ref.db.is_null() {\n'
' let db = &mut *stmt_ref.db;\n'
' let mut db_inner = db.inner.lock().unwrap();\n'
'\n'
' if db_inner.stmt_list == stmt {\n'
' db_inner.stmt_list = stmt_ref.next;\n'
' } else {\n'
' let mut current = db_inner.stmt_list;\n'
' while !current.is_null() {\n'
' let current_ref = &mut *current;\n'
' if current_ref.next == stmt {\n'
' current_ref.next = stmt_ref.next;\n'
' break;\n'
' }\n'
' current = current_ref.next;\n'
' }\n'
' }\n'
' }\n'
)
NEW_FINALIZE = (
' if !stmt_ref.db.is_null() {\n'
' let db = &mut *stmt_ref.db;\n'
' // try_lock to avoid deadlock when finalize is invoked\n'
' // re-entrantly (GC destructor during UDF callback).\n'
' if let Ok(mut db_inner) = db.inner.try_lock() {\n'
' if db_inner.stmt_list == stmt {\n'
' db_inner.stmt_list = stmt_ref.next;\n'
' } else {\n'
' let mut current = db_inner.stmt_list;\n'
' while !current.is_null() {\n'
' let current_ref = &mut *current;\n'
' if current_ref.next == stmt {\n'
' current_ref.next = stmt_ref.next;\n'
' break;\n'
' }\n'
' current = current_ref.next;\n'
' }\n'
' }\n'
' }\n'
' }\n'
)

OLD_DRAIN = (
'unsafe fn stmt_run_to_completion(stmt: *mut sqlite3_stmt) -> ffi::c_int {\n'
' let stmt_ref = &mut *stmt;\n'
' while stmt_ref.stmt.execution_state().is_running() {\n'
' let result = sqlite3_step(stmt);\n'
' if result != SQLITE_DONE && result != SQLITE_ROW {\n'
' return result;\n'
' }\n'
' }\n'
' SQLITE_OK\n'
'}\n'
)
NEW_DRAIN = (
'unsafe fn stmt_run_to_completion(stmt: *mut sqlite3_stmt) -> ffi::c_int {\n'
' let stmt_ref = &mut *stmt;\n'
" // Skip drain if we can't acquire the db mutex: we're\n"
" // re-entering from a UDF callback's GC destructor, and\n"
' // sqlite3_step would block forever. The stmt will be\n'
' // freed anyway by the caller.\n'
' if !stmt_ref.db.is_null() {\n'
' let db = &*stmt_ref.db;\n'
' if db.inner.try_lock().is_err() {\n'
' return SQLITE_OK;\n'
' }\n'
' }\n'
' while stmt_ref.stmt.execution_state().is_running() {\n'
' let result = sqlite3_step(stmt);\n'
' if result != SQLITE_DONE && result != SQLITE_ROW {\n'
' return result;\n'
' }\n'
' }\n'
' SQLITE_OK\n'
'}\n'
)

with open(PATH) as f:
s = f.read()
if OLD_FINALIZE not in s:
sys.exit(f'{PATH}: sqlite3_finalize stmt_list block not found')
s = s.replace(OLD_FINALIZE, NEW_FINALIZE, 1)
if OLD_DRAIN not in s:
sys.exit(f'{PATH}: stmt_run_to_completion block not found')
s = s.replace(OLD_DRAIN, NEW_DRAIN, 1)
with open(PATH, 'w') as f:
f.write(s)
print('patched sqlite3_finalize + stmt_run_to_completion for GC re-entry')
47 changes: 47 additions & 0 deletions .github/turso-patches/05-max-custom-funcs-32-to-64.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""
Bump MAX_CUSTOM_FUNCS from 32 to 64 (sqlite3/src/lib.rs).

Turso's custom-function registry is capped at 32 pre-generated bridge
trampolines; the driver registers 44 UDFs, so the last 12 silently fail.
Bump to 64 by adding 32 more func_bridge!/FUNC_BRIDGES entries.
"""

import re
import sys

PATH = 'sqlite3/src/lib.rs'

with open(PATH) as f:
s = f.read()

OLD_MAX = 'const MAX_CUSTOM_FUNCS: usize = 32;'
NEW_MAX = 'const MAX_CUSTOM_FUNCS: usize = 64;'
if OLD_MAX not in s:
sys.exit(f'{PATH}: MAX_CUSTOM_FUNCS not found')
s = s.replace(OLD_MAX, NEW_MAX, 1)

# Inject 32 more func_bridge! declarations after func_bridge_31.
bridge_marker = 'func_bridge!(31, func_bridge_31);\n'
if bridge_marker not in s:
sys.exit(f'{PATH}: func_bridge_31 marker not found')
extra_bridges = ''.join(
f'func_bridge!({i}, func_bridge_{i});\n' for i in range(32, 64)
)
s = s.replace(bridge_marker, bridge_marker + extra_bridges, 1)

# Extend the FUNC_BRIDGES array: find the closing `];` of the static
# and inject the extra entries before it.
pat = re.compile(
r'(static FUNC_BRIDGES: \[ScalarFunction; MAX_CUSTOM_FUNCS\] = \[\n'
r'(?:\s*func_bridge_\d+,\n)+)(\];\n)'
)
m = pat.search(s)
if m is None:
sys.exit(f'{PATH}: FUNC_BRIDGES array not found')
extra_entries = ''.join(f' func_bridge_{i},\n' for i in range(32, 64))
s = s[:m.start(2)] + extra_entries + s[m.start(2):]

with open(PATH, 'w') as f:
f.write(s)
print('patched MAX_CUSTOM_FUNCS 32 -> 64')
57 changes: 57 additions & 0 deletions .github/turso-patches/06-collation-mysql-aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""
Alias MySQL collation names to the nearest Turso built-in (core/translate/collate.rs).

Turso recognizes built-in and ICU locale collation names. Driver-emitted
SQL references MySQL collations like `utf8mb4_bin` (byte-compare) and
`utf8mb4_0900_ai_ci` (case-insensitive), which are not ICU locale names.
Map these to the closest built-in at lookup time.
"""

import sys

PATH = 'core/translate/collate.rs'

OLD = (
' pub fn new(collation: &str) -> crate::Result<Self> {\n'
' match crate::util::normalize_ident(collation).as_str() {\n'
' "binary" => return Ok(Self::Binary),\n'
' "nocase" => return Ok(Self::NoCase),\n'
' "rtrim" => return Ok(Self::Rtrim),\n'
' _ => {}\n'
' }\n'
'\n'
' LocaleCollationRegistry::global()\n'
' .get_or_register(collation)\n'
' .map(Self::Locale)\n'
' }\n'
)
NEW = (
' pub fn new(collation: &str) -> crate::Result<Self> {\n'
' // Alias common MySQL collation names to the nearest\n'
' // Turso built-in before ICU locale parsing rejects them.\n'
' match crate::util::normalize_ident(collation).as_str() {\n'
' "binary" | "utf8mb4_bin" | "utf8_bin" | "ascii_bin" | "latin1_bin" => {\n'
' return Ok(Self::Binary)\n'
' }\n'
' "nocase" | "utf8mb4_0900_ai_ci" | "utf8mb4_general_ci"\n'
' | "utf8_general_ci" | "latin1_general_ci" | "latin1_swedish_ci" => {\n'
' return Ok(Self::NoCase)\n'
' }\n'
' "rtrim" => return Ok(Self::Rtrim),\n'
' _ => {}\n'
' }\n'
'\n'
' LocaleCollationRegistry::global()\n'
' .get_or_register(collation)\n'
' .map(Self::Locale)\n'
' }\n'
)

with open(PATH) as f:
s = f.read()
if OLD not in s:
sys.exit(f'{PATH}: CollationSeq::new body not found')
with open(PATH, 'w') as f:
f.write(s.replace(OLD, NEW, 1))
print('patched CollationSeq to alias MySQL collations')
Loading
Loading