Merge pull request #1838 from MemPalace/fix/sqlite-ro-uri-encoding

fix: percent-encode sqlite read-only URIs for spaced/special-char paths
This commit is contained in:
Igor Lins e Silva 2026-06-20 19:24:16 -03:00 committed by GitHub
commit fe460c4b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 56 additions and 10 deletions

View File

@ -17,6 +17,7 @@ from typing import Any, Optional
import chromadb
from chromadb.errors import NotFoundError as _ChromaNotFoundError
from ..config import sqlite_read_uri
from ._sidecar import EMBEDDER_SIDECAR_FILENAME, read_embedder_sidecar, write_embedder_sidecar
from .base import (
BaseBackend,
@ -457,7 +458,7 @@ def _vector_segment_id(palace_path: str, collection_name: str) -> Optional[str]:
if not os.path.isfile(db_path):
return None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
row = conn.execute(
"""
@ -626,7 +627,7 @@ def _read_sync_threshold(palace_path: str, collection_name: str) -> int:
if not os.path.isfile(db_path):
return 1000
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
cur = conn.cursor()
cur.execute(
@ -746,7 +747,7 @@ def _sqlite_embedding_count(palace_path: str, collection_name: str) -> Optional[
if not os.path.isfile(db_path):
return None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
row = conn.execute(
"""
@ -807,7 +808,7 @@ def _sqlite_wing_room_counts(
if not os.path.isfile(db_path):
return None
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
# Wait out a transient writer/checkpoint lock rather than falling
# straight back to the expensive vector-index path (#1681).
@ -1570,7 +1571,7 @@ class ChromaCollection(BaseCollection):
# rowid, embedding_id is the user-facing drawer id.
public_ids: dict[int, str] = {}
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
conn.row_factory = sqlite3.Row
except sqlite3.Error:
logger.debug("Chroma lexical sqlite open failed", exc_info=True)

View File

@ -205,6 +205,21 @@ DEFAULT_BACKEND = "chroma"
DEFAULT_MAX_BACKUPS = 10
def sqlite_read_uri(db_path: str) -> str:
"""Return a read-only ``file:`` URI for ``sqlite3.connect(..., uri=True)``.
A bare ``f"file:{db_path}?mode=ro"`` mis-parses paths containing spaces or
other URI-reserved characters common in real home directories (a Windows
user folder like ``First Last``, many macOS paths). ``pathname2url``
percent-encodes the path and normalizes separators so the database opens on
every platform.
"""
from urllib.request import pathname2url
db_path = os.fspath(db_path)
return f"file:{pathname2url(db_path)}?mode=ro"
@lru_cache(maxsize=1)
def get_configured_collection_name() -> str:
"""Return the configured drawer collection name without repeated config-file reads."""

View File

@ -60,6 +60,7 @@ from .config import ( # noqa: E402
sanitize_name,
sanitize_content,
sanitize_iso_temporal,
sqlite_read_uri,
strip_lone_surrogates,
)
from .version import __version__ # noqa: E402
@ -920,7 +921,7 @@ def _tool_status_via_sqlite() -> dict:
rooms: dict = {}
total = 0
try:
conn = _sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = _sqlite3.connect(sqlite_read_uri(db_path), uri=True)
try:
row = conn.execute(
"""

View File

@ -43,6 +43,7 @@ from typing import Callable, Iterator, Optional
from chromadb.errors import NotFoundError as ChromaNotFoundError
from .backends.chroma import ChromaBackend, hnsw_capacity_status
from .config import sqlite_read_uri
COLLECTION_NAME = "mempalace_drawers"
@ -476,7 +477,7 @@ def sqlite_drawer_count(palace_path: str, collection_name: Optional[str] = None)
try:
import sqlite3
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True)
try:
row = conn.execute(
"""
@ -516,7 +517,7 @@ def sqlite_integrity_errors(palace_path: str) -> list[str]:
return []
try:
with sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True) as conn:
with sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True) as conn:
rows = conn.execute("PRAGMA quick_check").fetchall()
except sqlite3.Error as e:
return [f"PRAGMA quick_check failed: {e}"]
@ -1013,7 +1014,7 @@ def extract_via_sqlite(palace_path: str, collection_name: str) -> Iterator[tuple
if not os.path.isfile(sqlite_path):
return
conn = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(sqlite_path), uri=True)
try:
seg_row = conn.execute(
"""

View File

@ -23,6 +23,7 @@ from .backends import (
PalaceNotFoundError,
UnsupportedCapabilityError,
)
from .config import sqlite_read_uri
from .palace import (
_open_collection_or_explain,
get_closets_collection,
@ -533,7 +534,7 @@ def _bm25_only_via_sqlite(
return "".join(clauses), params
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
conn = sqlite3.connect(sqlite_read_uri(db_path), uri=True)
except sqlite3.Error as e:
return {"error": f"sqlite open failed: {e}"}

View File

@ -1,5 +1,6 @@
import os
import json
import sqlite3
import tempfile
import pytest
@ -10,6 +11,7 @@ from mempalace.config import (
sanitize_iso_temporal,
sanitize_kg_value,
sanitize_name,
sqlite_read_uri,
)
@ -148,6 +150,31 @@ def test_embedding_threads_invalid_falls_back_to_auto(tmp_path, monkeypatch):
assert cfg.embedding_threads == 2
def test_sqlite_read_uri_opens_path_with_spaces(tmp_path):
"""sqlite_read_uri must open a read-only DB whose path contains spaces,
which a bare f"file:{path}?mode=ro" mis-parses (especially on Windows)."""
db_dir = tmp_path / "palace with spaces"
db_dir.mkdir()
db_path = db_dir / "chroma.sqlite3"
setup = sqlite3.connect(str(db_path))
setup.execute("CREATE TABLE t (x INTEGER)")
setup.execute("INSERT INTO t VALUES (42)")
setup.commit()
setup.close()
uri = sqlite_read_uri(str(db_path))
assert "%20" in uri # the space is percent-encoded, not left raw
conn = sqlite3.connect(uri, uri=True)
try:
assert conn.execute("SELECT x FROM t").fetchone()[0] == 42
# mode=ro is still honored through the encoded URI
with pytest.raises(sqlite3.OperationalError):
conn.execute("INSERT INTO t VALUES (1)")
finally:
conn.close()
def test_env_override():
raw = "/env/palace"
os.environ["MEMPALACE_PALACE_PATH"] = raw