new file mode 100644
@@ -0,0 +1,280 @@
+From 5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d Mon Sep 17 00:00:00 2001
+From: =?UTF-8?q?Bern=C3=A1t=20G=C3=A1bor?= <gaborjbernat@gmail.com>
+Date: Sat, 17 Oct 2026 03:39:07 -0700
+Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(read-write):=20keep=20SQLite?=
+ =?UTF-8?q?=20off=20shared=20names,=20bound=20schema=20(#834)?=
+MIME-Version: 1.0
+Content-Type: text/plain; charset=UTF-8
+Content-Transfer-Encoding: 8bit
+
+CVE: CVE-2026-112915
+Upstream-Status: Backport [https://github.com/tox-dev/filelock/commit/5e3f2ec0c5fb17cb2635b60b2b68bc63ab81ca2d]
+
+Backport Changes:
+- Split the schema-loading guard from the shared-path changes,
+ which are applied separately for CVE-2026-112940.
+- Place the guard in _rw_security.py and wrap the existing 3.25.2
+ configure/begin path instead of newer operation-context classes.
+- Keep the per-instance connection and existing timeout semantics.
+- Disable statement caching so guarded statements are prepared
+ within the schema budget.
+- Use sqlite3.Connection.setlimit supplied by Wrynose Python 3.14;
+ this ReadWriteLock backport requires Python 3.11 or newer.
+- Retain both upstream malformed-schema fixtures in the dedicated
+ regression file and explicitly close persistent connections.
+- Omit unrelated path, Windows, fork and coverage-task changes
+ from this split patch; applicable path changes follow separately.
+
+Signed-off-by: Darsh Kelaiya <dkelaiya@cisco.com>
+---
+ src/filelock/_read_write.py | 45 ++++++-------
+ src/filelock/_rw_security.py | 34 ++++++++--
+ tests/test_read_write_security.py | 101 ++++++++++++++++++++++++++++++
+ 3 files changed, 154 insertions(+), 26 deletions(-)
+
+diff --git a/src/filelock/_read_write.py b/src/filelock/_read_write.py
+index e8b6420..66a4664 100644
+--- a/src/filelock/_read_write.py
++++ b/src/filelock/_read_write.py
+@@ -13,7 +13,7 @@ from weakref import WeakValueDictionary
+
+ from ._api import AcquireReturnProxy
+ from ._error import Timeout
+-from ._rw_security import canonical, connect
++from ._rw_security import bounded_schema, canonical, connect
+
+ if TYPE_CHECKING:
+ from collections.abc import Generator
+@@ -189,27 +189,28 @@ class ReadWriteLock(metaclass=_ReadWriteLockMeta):
+ def _configure_and_begin(
+ self, mode: Literal["read", "write"], timeout: float, *, blocking: bool, start_time: float
+ ) -> None:
+- waited = time.perf_counter() - start_time
+- timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)
+- self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close()
+- # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE
+- # write transaction is active, making read-write locking impossible without modifying table data.
+- # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB.
+- # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
+- #
+- # Set here (not in __init__) because this pragma itself may block on a locked database,
+- # so it must run after busy_timeout is configured above.
+- self._con.execute("PRAGMA journal_mode=MEMORY;").close()
+- # Recompute remaining timeout after the potentially blocking journal_mode pragma.
+- waited = time.perf_counter() - start_time
+- if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms:
+- self._con.execute(f"PRAGMA busy_timeout={recomputed};").close()
+- stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;"
+- self._con.execute(stmt).close()
+- if mode == "read":
+- # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database.
+- # https://www.sqlite.org/lockingv3.html#transaction_control
+- self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close()
++ with bounded_schema(self._con, self.lock_file):
++ waited = time.perf_counter() - start_time
++ timeout_ms = timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)
++ self._con.execute(f"PRAGMA busy_timeout={timeout_ms};").close()
++ # Use legacy journal mode (not WAL) because WAL does not block readers when a concurrent EXCLUSIVE
++ # write transaction is active, making read-write locking impossible without modifying table data.
++ # MEMORY is safe here since no actual writes happen — crashes cannot corrupt the DB.
++ # See https://sqlite.org/lang_transaction.html#deferred_immediate_and_exclusive_transactions
++ #
++ # Set here (not in __init__) because this pragma itself may block on a locked database,
++ # so it must run after busy_timeout is configured above.
++ self._con.execute("PRAGMA journal_mode=MEMORY;").close()
++ # Recompute remaining timeout after the potentially blocking journal_mode pragma.
++ waited = time.perf_counter() - start_time
++ if (recomputed := timeout_for_sqlite(timeout, blocking=blocking, already_waited=waited)) != timeout_ms:
++ self._con.execute(f"PRAGMA busy_timeout={recomputed};").close()
++ stmt = "BEGIN EXCLUSIVE TRANSACTION;" if mode == "write" else "BEGIN TRANSACTION;"
++ self._con.execute(stmt).close()
++ if mode == "read":
++ # A SELECT is needed to force SQLite to actually acquire the SHARED lock on the database.
++ # https://www.sqlite.org/lockingv3.html#transaction_control
++ self._con.execute("SELECT name FROM sqlite_schema LIMIT 1;").close()
+
+ def _acquire(self, mode: Literal["read", "write"], timeout: float, *, blocking: bool) -> AcquireReturnProxy:
+ opposite = "write" if mode == "read" else "read"
+diff --git a/src/filelock/_rw_security.py b/src/filelock/_rw_security.py
+index 6916212..9e4f92f 100644
+--- a/src/filelock/_rw_security.py
++++ b/src/filelock/_rw_security.py
+@@ -7,10 +7,13 @@ import stat
+ import sys
+ import threading
+ from collections import deque
+-from contextlib import suppress
++from contextlib import contextmanager, suppress
+ from dataclasses import dataclass, field
+ from itertools import count
+-from typing import NamedTuple
++from typing import TYPE_CHECKING, NamedTuple
++
++if TYPE_CHECKING:
++ from collections.abc import Generator
+
+ _DatabaseIdentity = tuple[int, int]
+ _DB_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
+@@ -154,14 +157,14 @@ def _open_lock_database(database: str) -> tuple[int, _DatabaseIdentity]:
+ def connect(database: str) -> sqlite3.Connection:
+ if sys.platform == "win32":
+ # Windows handle hardening is not part of the POSIX recipe backport.
+- return sqlite3.connect(database, check_same_thread=False)
++ return sqlite3.connect(database, check_same_thread=False, cached_statements=0)
+ claimed: list[_Probe] = []
+ try:
+ if (probe := _PROBE_DESCRIPTORS.adopt(database, claimed)) is None:
+ fd, identity = _open_lock_database(database)
+ probe = _PROBE_DESCRIPTORS.retain(fd, identity, claimed)
+ _PROBE_DESCRIPTORS.drain()
+- connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection)
++ connection = sqlite3.connect(f"{_FD_DIR}/{probe.fd}", check_same_thread=False, factory=_Connection, cached_statements=0)
+ connection._probe = probe
+ connection.database_identity = probe.identity
+ claimed.clear()
+@@ -169,3 +172,26 @@ def connect(database: str) -> sqlite3.Connection:
+ finally:
+ for probe in claimed:
+ _PROBE_DESCRIPTORS.release(probe)
++
++
++@contextmanager
++def bounded_schema(connection: sqlite3.Connection, database: str) -> Generator[None]:
++ # The first statement loads the file's complete schema, which no timeout bounds, and a lock database has none.
++ # Waiting for a lock runs no virtual machine steps, so a step budget bounds only that load.
++ calls = count()
++ max_calls = 10
++ connection.setlimit(sqlite3.SQLITE_LIMIT_LENGTH, 4096)
++ connection.set_progress_handler(lambda: next(calls) >= max_calls, 1000)
++ try:
++ yield
++ except sqlite3.DatabaseError as error:
++ # An interrupted schema load reports a malformed schema rather than the interrupt.
++ if isinstance(error, sqlite3.DataError) or next(calls) > max_calls:
++ msg = (
++ f"refusing a lock database whose schema is too large to load: {database!r}; "
++ "give ReadWriteLock a file of its own"
++ )
++ raise ValueError(msg) from error
++ raise
++ finally:
++ connection.set_progress_handler(None, 0)
+diff --git a/tests/test_read_write_security.py b/tests/test_read_write_security.py
+index e2b862a..a995c80 100644
+--- a/tests/test_read_write_security.py
++++ b/tests/test_read_write_security.py
+@@ -2,6 +2,8 @@ from __future__ import annotations
+
+ import errno
+ import os
++import struct
++from typing import Literal
+ import subprocess
+ import sys
+ from pathlib import Path
+@@ -65,6 +67,105 @@ def test_closing_a_peer_keeps_another_connections_lock(tmp_path: Path) -> None:
+ holder.close()
+
+
++_PAGE_SIZE = 4096
++
++
++_TABLE_LEAF_PAGE = 0x0D
++
++
++@pytest.mark.timeout(60)
++@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")])
++def test_read_write_lock_refuses_a_schema_that_never_finishes_loading(
++ tmp_path: Path, mode: Literal["read", "write"]
++) -> None:
++ # Every interior page leads to the next one through each of its 660 cells, so the schema has 661**4 rows to scan.
++ database = tmp_path / "lock.db"
++ pages = [
++ _btree_page(
++ 0x05, # table interior page
++ struct.pack(">IB", number + 1, 1),
++ 660,
++ right_child=struct.pack(">I", number + 1),
++ header_at=100 if number == 1 else 0,
++ )
++ for number in range(1, 5)
++ ]
++ pages.append(_btree_page(_TABLE_LEAF_PAGE, bytes([6, 1, 6, 0, 0, 0, 0, 0]), 1))
++ pages[0][:100] = _file_header(len(pages))
++ database.write_bytes(b"".join(pages))
++ lock = ReadWriteLock(database, is_singleton=False)
++
++ try:
++ with pytest.raises(ValueError, match="schema"):
++ (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1)
++ finally:
++ lock.close()
++
++
++@pytest.mark.parametrize("mode", [pytest.param("read", id="read"), pytest.param("write", id="write")])
++def test_read_write_lock_refuses_a_schema_row_longer_than_a_lock_needs(
++ tmp_path: Path, mode: Literal["read", "write"]
++) -> None:
++ # The row's text continues on an overflow page that names itself as the next one, so one page of disk reads as a
++ # megabyte, far more schema text than a lock database holds and below SQLite's one-billion-byte string limit.
++ long_sql_length = 1_000_000
++ database = tmp_path / "lock.db"
++ statement = b"CREATE TABLE application (value) --"
++ columns = [b"table", b"application", b"application", b"\x02"]
++ serial_types = [13 + 2 * len(column) for column in columns[:3]] + [1, 13 + 2 * long_sql_length]
++ record_header = b"".join(_varint(serial_type) for serial_type in serial_types)
++ record_start = _varint(len(record_header) + 1) + record_header + b"".join(columns) + statement
++ record_length = len(record_start) + long_sql_length - len(statement)
++ # How much of an overflowing record a table leaf keeps on its own page.
++ local = (minimum := (_PAGE_SIZE - 12) * 32 // 255 - 23) + (record_length - minimum) % (_PAGE_SIZE - 4)
++ cell = _varint(record_length) + _varint(1) + record_start.ljust(local, b"-") + struct.pack(">I", 2)
++ leaf = _btree_page(_TABLE_LEAF_PAGE, cell, 1, header_at=100)
++ page_count = long_sql_length // _PAGE_SIZE + 2
++ leaf[:100] = _file_header(page_count)
++ database.write_bytes(leaf + struct.pack(">I", 2).ljust(_PAGE_SIZE, b"-"))
++ # SQLite refuses a record longer than the file, which a hole stretches at no cost.
++ os.truncate(database, page_count * _PAGE_SIZE)
++ lock = ReadWriteLock(database, is_singleton=False)
++
++ try:
++ with pytest.raises(ValueError, match="schema"):
++ (lock.acquire_read if mode == "read" else lock.acquire_write)(timeout=1)
++ finally:
++ lock.close()
++
++
++def _btree_page(kind: int, cell: bytes, cell_count: int, *, right_child: bytes = b"", header_at: int = 0) -> bytearray:
++ page = bytearray(_PAGE_SIZE)
++ cell_at = _PAGE_SIZE - len(cell)
++ page[cell_at:] = cell
++ # Every slot of the cell pointer array names the one cell.
++ header = struct.pack(">BHHHB", kind, 0, cell_count, cell_at, 0) + right_child
++ pointers = struct.pack(">H", cell_at) * cell_count
++ page[header_at : header_at + len(header) + len(pointers)] = header + pointers
++ return page
++
++
++def _file_header(page_count: int) -> bytes:
++ # Rollback journal format, a matching change counter and version-valid-for, so SQLite trusts the page count.
++ return struct.pack(
++ ">16sH6B12I20s2I",
++ b"SQLite format 3\0",
++ _PAGE_SIZE,
++ *(1, 1, 0, 64, 32, 32),
++ *(1, page_count, 0, 0, 1, 4, 0, 0, 1, 0, 0, 0),
++ b"",
++ 1,
++ 3040001,
++ )
++
++
++def _varint(value: int) -> bytes:
++ groups = [value & 0x7F]
++ while value := value >> 7:
++ groups.insert(0, value & 0x7F | 0x80)
++ return bytes(groups)
++
++
+ def _fail_opened_database_fstat(database: Path, monkeypatch: pytest.MonkeyPatch) -> list[int]:
+ opened: list[int] = []
+ real_open = os.open
@@ -15,6 +15,7 @@ SRC_URI += " file://run-ptest \
file://CVE-2026-112909_p1.patch \
file://CVE-2026-112909_p2.patch \
file://CVE-2026-112936.patch \
+ file://CVE-2026-112915.patch \
"
SRC_URI[sha256sum] = "b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694"