Merge pull request 'R2 W2.0: SQLite-backed zero-dep Rust read boundary' (#2457) from r2-wave2-memory-storage into master
This commit is contained in:
commit
de5445357e
14 files changed
+6955
-272
No files matched your search
@@ -79,6 +79,7 @@ jobs:
|
||||
CS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libcapability_scoring.so
|
||||
TC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libtoken_counting.so
|
||||
SC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libscaffold_core.so
|
||||
MEMS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_storage.so
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -131,6 +132,7 @@ jobs:
|
||||
cargo build --release --manifest-path src/rust/capability_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/token_counting/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/scaffold_core/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_storage/Cargo.toml
|
||||
- name: Test with coverage (global floor + changed-lines ratchet)
|
||||
run: |
|
||||
# #149 coverage ratchet — both floors live in .coveragerc; raise,
|
||||
@@ -179,6 +181,7 @@ jobs:
|
||||
CS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libcapability_scoring.so
|
||||
TC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libtoken_counting.so
|
||||
SC_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libscaffold_core.so
|
||||
MEMS_RUST_LIB: /tmp/rune-ci-target-${{ github.run_id }}/release/libmemory_storage.so
|
||||
steps:
|
||||
# actions/checkout v4.2.2, pinned to the audited upstream tag commit.
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
@@ -216,6 +219,7 @@ jobs:
|
||||
cargo build --release --manifest-path src/rust/capability_scoring/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/token_counting/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/scaffold_core/Cargo.toml
|
||||
cargo build --release --manifest-path src/rust/memory_storage/Cargo.toml
|
||||
- name: Run canary suite
|
||||
run: |
|
||||
FAILED=0
|
||||
@@ -252,6 +256,7 @@ jobs:
|
||||
src/rust/capability_scoring/canary_capability_scoring.py \
|
||||
src/rust/token_counting/canary_token_counting.py \
|
||||
src/rust/scaffold_core/canary_scaffold_core.py \
|
||||
src/rust/memory_storage/canary_memory_storage.py \
|
||||
src/rune/canary_unbounded.py \
|
||||
src/rune/canary_full_system.py \
|
||||
src/rune/canary_native.py; do
|
||||
|
||||
@@ -165,6 +165,7 @@
|
||||
"memory_env",
|
||||
"memory_selector",
|
||||
"memory_store",
|
||||
"mems_loader",
|
||||
"ms_loader",
|
||||
"query_memory",
|
||||
"recall_router",
|
||||
@@ -184,6 +185,7 @@
|
||||
],
|
||||
"rust_crates": [
|
||||
"memory_scoring",
|
||||
"memory_storage",
|
||||
"query_memory"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1907,6 +1907,71 @@ def stats(db):
|
||||
return {"active_entries": e, "working": w, "audit": a, "top_topics": by_topic}
|
||||
|
||||
|
||||
# ── storage backend seam (R2 W2.0) ───────────────────────────────────────────
|
||||
# SQLite is RETAINED (Nick's ruling) — the seam separates query logic from
|
||||
# storage mechanics so the Rust core can serve proven-identical READS via
|
||||
# system libsqlite3 FFI. Connection-based callers (stats above) keep their
|
||||
# transactional semantics untouched; path-based consumers opt into the
|
||||
# backend. NO WRITES route through the Rust backend this slice.
|
||||
|
||||
|
||||
class _SQLiteBackend:
|
||||
"""The default backend: the stdlib sqlite3 mechanics, unchanged."""
|
||||
|
||||
name = "sqlite"
|
||||
|
||||
def read_query(self, db_path: str, sql: str, params=()) -> list:
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
return [tuple(r) for r in conn.execute(sql, params).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class _RustReadBackend:
|
||||
"""The Rust read path through the memory_storage crate (read-only by
|
||||
construction — the crate exports no write-capable symbols)."""
|
||||
|
||||
name = "rust-memory-storage"
|
||||
|
||||
def read_query(self, db_path: str, sql: str, params=()) -> list:
|
||||
from rune import mems_loader
|
||||
|
||||
return [tuple(r) for r in mems_loader.read_query(db_path, sql, list(params))]
|
||||
|
||||
|
||||
def storage_backend():
|
||||
"""The active read backend. Default sqlite; RUNE_MEMORY_STORAGE=rust
|
||||
opts into the crate when built (falls back loudly-free to sqlite)."""
|
||||
if os.environ.get("RUNE_MEMORY_STORAGE", "auto") == "rust":
|
||||
try:
|
||||
from rune import mems_loader
|
||||
|
||||
if mems_loader.available():
|
||||
return _RustReadBackend()
|
||||
except Exception: # noqa: BLE001, S110 — backend probe failure means the default serves (fallback, not silence)
|
||||
pass
|
||||
return _SQLiteBackend()
|
||||
|
||||
|
||||
def stats_for_path(db_path: str) -> dict:
|
||||
"""stats() through the storage backend seam — the opt-in consumer the
|
||||
parity battery and future Rust callers use. Same result dict shape."""
|
||||
backend = storage_backend()
|
||||
e = backend.read_query(db_path, "SELECT COUNT(*) FROM entries WHERE status='active'")[0][0]
|
||||
w = backend.read_query(db_path, "SELECT COUNT(*) FROM working")[0][0]
|
||||
a = backend.read_query(db_path, "SELECT COUNT(*) FROM audit_log")[0][0]
|
||||
by_topic = {
|
||||
r[0]: r[1]
|
||||
for r in backend.read_query(
|
||||
db_path,
|
||||
"SELECT topic, COUNT(*) AS n FROM entries WHERE status='active' GROUP BY topic ORDER BY n DESC LIMIT 6",
|
||||
)
|
||||
if r[0]
|
||||
}
|
||||
return {"active_entries": e, "working": w, "audit": a, "top_topics": by_topic}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ associations ----
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust memory_storage core (R2 W2.0).
|
||||
|
||||
Contract BUILDER-2026-10-04T15:54:17Z-cli. READ-ONLY by construction:
|
||||
the only operation is read_query — the crate exports no write-capable
|
||||
symbols (Nick's ruling: reads proven byte-identical before any writes
|
||||
exist). SQLite is retained; the crate reaches it via system libsqlite3.
|
||||
|
||||
FFI contract (doc-envelope law): mems_read_query({db_path, sql, params})
|
||||
-> [[cell, ...], ...]; mems_free_string(ptr). FFI segfault law: argtypes +
|
||||
restype pinned per symbol. Override the .so with MEMS_RUST_LIB (FILE path).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "memory_storage"
|
||||
_LIB_PATH = Path(
|
||||
os.environ.get(
|
||||
"MEMS_RUST_LIB", _CRATE_DIR / "target" / "release" / "libmemory_storage.so"
|
||||
)
|
||||
)
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class MemoryStorageUnavailable(FileNotFoundError):
|
||||
"""The Rust core is not built (or MEMS_RUST_LIB points nowhere)."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.exists():
|
||||
raise MemoryStorageUnavailable(
|
||||
f"Rust memory_storage core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {_CRATE_DIR}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
lib.mems_free_string.argtypes = [ctypes.c_void_p]
|
||||
lib.mems_free_string.restype = None
|
||||
lib.mems_read_query.argtypes = [ctypes.c_char_p]
|
||||
lib.mems_read_query.restype = ctypes.c_void_p # char* — never c_int
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _call(payload: dict) -> dict:
|
||||
"""Raw envelope: {"ok", "value"|"err"} — the error-honesty section of
|
||||
the canary needs the envelope, not an exception."""
|
||||
lib = _load()
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
ptr = lib.mems_read_query(raw)
|
||||
if not ptr:
|
||||
raise RuntimeError("mems_read_query returned a null pointer")
|
||||
try:
|
||||
text = ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8")
|
||||
finally:
|
||||
lib.mems_free_string(ptr)
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def read_query(db_path: str, sql: str, params: list) -> list:
|
||||
"""Read-only query through the Rust core; raises on the error envelope."""
|
||||
env = _call({"db_path": db_path, "sql": sql, "params": params})
|
||||
if not env.get("ok"):
|
||||
raise RuntimeError(f"mems_read_query core error: {env.get('err')}")
|
||||
return env["value"]
|
||||
|
||||
|
||||
def read_query_result(db_path: str, sql: str, params: list) -> dict:
|
||||
"""The raw envelope (error-honesty checks consume this)."""
|
||||
return _call({"db_path": db_path, "sql": sql, "params": params})
|
||||
Generated
+7
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "memory_storage"
|
||||
version = "0.1.0"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "memory_storage"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "R2 W2.0 — SQLite read path via system libsqlite3 FFI (contract BUILDER-2026-10-04T15:54:17Z-cli); zero cargo deps; NO WRITES (Nick's ruling: reads proven identical first)"
|
||||
|
||||
[lib]
|
||||
name = "memory_storage"
|
||||
crate-type = ["rlib", "cdylib"]
|
||||
|
||||
[[bin]]
|
||||
name = "memory_storage"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
"""canary_memory_storage.py — read-path parity battery for the
|
||||
memory_storage crate (R2 W2.0, contract BUILDER-2026-10-04T15:54:17Z-cli).
|
||||
|
||||
Nick's super-careful ruling is the law of this battery: SQLite is RETAINED;
|
||||
the Rust core reads the SAME database through system libsqlite3 FFI;
|
||||
byte-identical rows or the wave stops. NO WRITES through the Rust path —
|
||||
the FFI surface carries no write symbols, and section 5 proves it.
|
||||
|
||||
Sections:
|
||||
1. crate builds (rustup PATH-first per the SYSTEM RUSTC BROKEN law)
|
||||
2. fixture store built by PYTHON (the real schema, production-shaped rows)
|
||||
3. read-path parity: byte-identical row sets across typed values
|
||||
(ints, floats, text, NULLs, unicode)
|
||||
4. error honesty: missing table/file produce the same error class
|
||||
5. no-write gate: the FFI surface exports no write-capable symbols
|
||||
|
||||
Run: python3 src/rust/memory_storage/canary_memory_storage.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
CRATE_DIR = Path(__file__).resolve().parent
|
||||
REPO = CRATE_DIR.parents[2]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
PASS = 0
|
||||
FAIL = 0
|
||||
|
||||
|
||||
def test(name: str, ok: bool, detail: str = "") -> None:
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
print(f" ✓ {name}")
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f" ✗ {name} — {detail}")
|
||||
|
||||
|
||||
def build_crate() -> subprocess.CompletedProcess:
|
||||
env = dict(os.environ)
|
||||
_absolute = Path("/home/nick/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin")
|
||||
rustup_bin = _absolute if _absolute.is_dir() else (
|
||||
Path.home() / ".rustup" / "toolchains" / "stable-x86_64-unknown-linux-gnu" / "bin")
|
||||
env["PATH"] = f"{rustup_bin}:{env.get('PATH', '')}"
|
||||
env["CARGO_TARGET_DIR"] = env.get("CARGO_TARGET_DIR", str(CRATE_DIR / "target"))
|
||||
return subprocess.run(
|
||||
["cargo", "build", "--release"],
|
||||
cwd=CRATE_DIR, env=env, capture_output=True, text=True, timeout=600)
|
||||
|
||||
|
||||
def build_fixture(db_path: str) -> None:
|
||||
"""The production-shaped fixture: the real entries schema + rows that
|
||||
exercise every column type (int, float, text, NULL, unicode)."""
|
||||
db = sqlite3.connect(db_path)
|
||||
db.execute("""
|
||||
CREATE TABLE entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
text TEXT NOT NULL,
|
||||
importance REAL DEFAULT 0.5,
|
||||
created_at TEXT NOT NULL,
|
||||
last_accessed TEXT,
|
||||
topic TEXT DEFAULT '',
|
||||
entities TEXT DEFAULT '[]',
|
||||
source TEXT DEFAULT ''
|
||||
)""")
|
||||
rows = [
|
||||
("plain text entry", 0.5, "2026-10-01T00:00:00", None, "general", "[]", "test"),
|
||||
("unicode — héllo wörld 🎸", 0.9, "2026-10-02T00:00:00", "2026-10-03T00:00:00", "identity", '["rune"]', "test"),
|
||||
("empty topic", 0.0, "2026-10-03T12:00:00", None, "", "[]", ""),
|
||||
("big number", 1.0, "2026-10-04T00:00:00", None, "numbers", "[]", "test"),
|
||||
]
|
||||
db.executemany(
|
||||
"INSERT INTO entries (text, importance, created_at, last_accessed, topic, entities, source) VALUES (?,?,?,?,?,?,?)",
|
||||
rows)
|
||||
# stats() consumer tables (the seam's consumer surface)
|
||||
db.execute("ALTER TABLE entries ADD COLUMN status TEXT DEFAULT 'active'")
|
||||
db.execute("CREATE TABLE working (id INTEGER PRIMARY KEY, note TEXT)")
|
||||
db.execute("CREATE TABLE audit_log (id INTEGER PRIMARY KEY, action TEXT)")
|
||||
db.execute("INSERT INTO working (note) VALUES ('held')")
|
||||
db.execute("INSERT INTO working (note) VALUES ('held-2')")
|
||||
db.execute("INSERT INTO audit_log (action) VALUES ('boot')")
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 47)
|
||||
print("canary_memory_storage — SQLite read-path parity battery")
|
||||
print("=" * 47)
|
||||
|
||||
print("\n--- 1. Crate builds ---")
|
||||
r = build_crate()
|
||||
test("cargo build --release exits 0", r.returncode == 0,
|
||||
f"rc={r.returncode} err={r.stderr[-300:]!r}")
|
||||
if r.returncode != 0:
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1
|
||||
default_so = Path(os.environ.get(
|
||||
"CARGO_TARGET_DIR", str(CRATE_DIR / "target"))) / "release" / "libmemory_storage.so"
|
||||
so = Path(os.environ.get("MEMS_RUST_LIB", default_so))
|
||||
test("libmemory_storage.so exists", so.is_file(), str(so))
|
||||
if not so.is_file():
|
||||
print(f"\nRESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1
|
||||
|
||||
# ── 2. fixture store (Python-built, real schema) ──────────────────────
|
||||
print("\n--- 2. Fixture store ---")
|
||||
tmp = Path(tempfile.mkdtemp(prefix="mems-parity-"))
|
||||
db_path = str(tmp / "store.db")
|
||||
build_fixture(db_path)
|
||||
import hashlib
|
||||
digest_before = hashlib.sha256(Path(db_path).read_bytes()).hexdigest()
|
||||
test("fixture store created with the entries schema",
|
||||
Path(db_path).is_file())
|
||||
|
||||
from rune import mems_loader # noqa: E402
|
||||
|
||||
# ── 3. read-path parity ───────────────────────────────────────────────
|
||||
print("\n--- 3. Read-path parity (byte-identical rows) ---")
|
||||
queries = [
|
||||
("full table", "SELECT id, text, importance, created_at, last_accessed, topic, entities, source FROM entries ORDER BY id", []),
|
||||
("filtered by topic", "SELECT id, text, topic FROM entries WHERE topic = ? ORDER BY id", ["identity"]),
|
||||
("null projection", "SELECT id, last_accessed FROM entries ORDER BY id", []),
|
||||
("float filter", "SELECT id, importance FROM entries WHERE importance > ? ORDER BY id", [0.6]),
|
||||
("count aggregate", "SELECT COUNT(*) FROM entries", []),
|
||||
("empty result", "SELECT id FROM entries WHERE topic = ?", ["nonexistent-topic"]),
|
||||
("null parameter", "SELECT id FROM entries WHERE last_accessed IS ? ORDER BY id", [None]),
|
||||
("blob value", "SELECT CAST(X'00FF41' AS BLOB)", []),
|
||||
]
|
||||
for name, sql, params in queries:
|
||||
db = sqlite3.connect(db_path)
|
||||
py_rows = db.execute(sql, params).fetchall()
|
||||
db.close()
|
||||
rs_rows = mems_loader.read_query(db_path, sql, params)
|
||||
# Python tuples -> lists for JSON comparison; bytes have no JSON
|
||||
# scalar form, so the transport contract represents them as byte
|
||||
# arrays on both sides. Floats compare exact.
|
||||
py_norm = [
|
||||
[list(v) if isinstance(v, bytes) else v for v in r]
|
||||
for r in py_rows
|
||||
]
|
||||
test(f"parity: {name}", py_norm == rs_rows,
|
||||
f"py={py_norm[:2]!r} rs={rs_rows[:2]!r}")
|
||||
|
||||
# ── 4. error honesty ──────────────────────────────────────────────────
|
||||
print("\n--- 4. Error honesty ---")
|
||||
py_err = None
|
||||
try:
|
||||
db = sqlite3.connect(db_path)
|
||||
db.execute("SELECT id FROM missing_table").fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
py_err = str(e)
|
||||
finally:
|
||||
db.close()
|
||||
rs = mems_loader.read_query_result(db_path, "SELECT id FROM missing_table", [])
|
||||
test("missing table: same error class both sides",
|
||||
py_err is not None and not rs.get("ok", True)
|
||||
and "no such table" in rs.get("err", ""),
|
||||
f"py={py_err!r} rs={rs!r}")
|
||||
|
||||
rs = mems_loader.read_query_result(str(tmp / "nonexistent" / "ghost.db"),
|
||||
"SELECT 1", [])
|
||||
py_err = None
|
||||
try:
|
||||
db = sqlite3.connect(str(tmp / "nonexistent" / "ghost.db"))
|
||||
db.execute("SELECT 1").fetchall()
|
||||
except sqlite3.OperationalError as e:
|
||||
py_err = str(e)
|
||||
test("missing file: same error class both sides",
|
||||
py_err is not None and not rs.get("ok", True),
|
||||
f"py={py_err!r} rs={rs!r}")
|
||||
|
||||
# ── 4b. the seam consumer: stats through both backends ────────────────
|
||||
print("\n--- 4b. Seam consumer parity (stats_for_path) ---")
|
||||
from rune import memory_store # noqa: E402
|
||||
|
||||
py_stats = memory_store._SQLiteBackend() # direct, for row-level checks
|
||||
py_rows = py_stats.read_query(db_path, "SELECT COUNT(*) FROM entries WHERE status='active'")
|
||||
rs_rows = mems_loader.read_query(db_path,
|
||||
"SELECT COUNT(*) FROM entries WHERE status='active'", [])
|
||||
test("backend row parity on the stats read",
|
||||
[list(r) for r in py_rows] == rs_rows, f"py={py_rows!r} rs={rs_rows!r}")
|
||||
|
||||
os.environ["RUNE_MEMORY_STORAGE"] = "auto"
|
||||
stats_py = memory_store.stats_for_path(db_path)
|
||||
test("stats_for_path via the default backend", stats_py["active_entries"] == 4
|
||||
and stats_py["working"] == 2 and stats_py["audit"] == 1,
|
||||
f"{stats_py!r}")
|
||||
os.environ["RUNE_MEMORY_STORAGE"] = "rust"
|
||||
stats_rs = memory_store.stats_for_path(db_path)
|
||||
test("stats_for_path via the Rust backend — identical",
|
||||
stats_rs == stats_py and stats_rs["top_topics"].get("general") == 1,
|
||||
f"py={stats_py!r} rs={stats_rs!r}")
|
||||
os.environ.pop("RUNE_MEMORY_STORAGE", None)
|
||||
|
||||
# ── 5. no-write gate ──────────────────────────────────────────────────
|
||||
print("\n--- 5. No-write gate (Nick's super-careful ruling) ---")
|
||||
nm = subprocess.run(["nm", "-D", "--defined-only", str(so)],
|
||||
capture_output=True, text=True)
|
||||
exported = [l.split()[-1] for l in nm.stdout.splitlines() if l.strip()]
|
||||
write_syms = [s for s in exported if any(
|
||||
k in s.lower() for k in ("write", "insert", "update", "delete", "exec"))]
|
||||
write_syms = [s for s in write_syms if s.startswith("mems_")]
|
||||
test("the FFI surface exports no write-capable mems_* symbols",
|
||||
write_syms == [], f"found: {write_syms}")
|
||||
# Connection-level enforcement: even explicit mutation SQL cannot write.
|
||||
mutation = mems_loader.read_query_result(
|
||||
db_path, "UPDATE entries SET text = 'mutated' WHERE id = 1 RETURNING id", [])
|
||||
test("SQLite READONLY rejects explicit mutation SQL",
|
||||
not mutation.get("ok", True) and "readonly" in mutation.get("err", "").lower(),
|
||||
f"{mutation!r}")
|
||||
# And the fixture is byte-identical after every read + rejected mutation.
|
||||
digest_after = hashlib.sha256(Path(db_path).read_bytes()).hexdigest()
|
||||
test("the fixture store is byte-identical after all reads",
|
||||
digest_before == digest_after,
|
||||
f"before={digest_before} after={digest_after}")
|
||||
|
||||
print(f"\n{'=' * 47}")
|
||||
print(f"RESULTS: {PASS} pass, {FAIL} fail")
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,85 @@
|
||||
//! lib.rs — FFI surface for the memory_storage crate (R2 W2.0).
|
||||
//!
|
||||
//! READ-ONLY BY CONSTRUCTION (Nick's ruling): the only exported operation
|
||||
//! is mems_read_query. There is no write symbol to call.
|
||||
//!
|
||||
//! Envelope law: {"ok": true, "value": [[row], ...]} or
|
||||
//! {"ok": false, "err": "..."}. The caller frees with mems_free_string.
|
||||
//! FFI segfault law: mems_loader.py pins argtypes + restype.
|
||||
|
||||
pub mod pyjson;
|
||||
pub mod sqlite;
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::c_char;
|
||||
|
||||
fn read_arg(ptr: *const c_char) -> Result<String, String> {
|
||||
if ptr.is_null() {
|
||||
return Err("null argument pointer".to_string());
|
||||
}
|
||||
unsafe { CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.map(|s| s.to_string())
|
||||
.map_err(|e| format!("argument is not valid UTF-8: {}", e))
|
||||
}
|
||||
|
||||
fn envelope(result: Result<pyjson::Json, String>) -> *mut c_char {
|
||||
let body = match result {
|
||||
Ok(value) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(true)),
|
||||
("value".to_string(), value),
|
||||
]),
|
||||
Err(err) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(false)),
|
||||
("err".to_string(), pyjson::Json::Str(err)),
|
||||
]),
|
||||
};
|
||||
let out = pyjson::dumps(&body);
|
||||
CString::new(out)
|
||||
.map(|c| c.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn get<'a>(v: &'a pyjson::Json, key: &str) -> Option<&'a pyjson::Json> {
|
||||
match v {
|
||||
pyjson::Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// mems_read_query({"db_path": str, "sql": str, "params": [...]})
|
||||
/// -> envelope([[cell, ...], ...])
|
||||
#[no_mangle]
|
||||
pub extern "C" fn mems_read_query(arg: *const c_char) -> *mut c_char {
|
||||
let result = read_arg(arg)
|
||||
.and_then(|text| pyjson::parse(&text).map_err(|e| format!("arg parse: {}", e)))
|
||||
.and_then(|a| {
|
||||
let db_path = match get(&a, "db_path") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => return Err("arg.db_path required".to_string()),
|
||||
};
|
||||
let sql = match get(&a, "sql") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => return Err("arg.sql required".to_string()),
|
||||
};
|
||||
let params = match get(&a, "params") {
|
||||
Some(pyjson::Json::Arr(items)) => items.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let rows = sqlite::read_query(&db_path, &sql, ¶ms)?;
|
||||
Ok(pyjson::Json::Arr(
|
||||
rows.into_iter().map(pyjson::Json::Arr).collect(),
|
||||
))
|
||||
});
|
||||
envelope(result)
|
||||
}
|
||||
|
||||
/// Free a string returned by any mems_* function.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn mems_free_string(ptr: *mut c_char) {
|
||||
if !ptr.is_null() {
|
||||
unsafe {
|
||||
let _ = CString::from_raw(ptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! main.rs — CLI front for the memory_storage core (runner/debug use).
|
||||
//! READ-ONLY: the only op is read_query.
|
||||
//!
|
||||
//! stdin: {"op": "read_query", "arg": {"db_path": str, "sql": str, "params": [...]}}
|
||||
//! stdout: the same {ok, value|err} envelope the FFI returns.
|
||||
|
||||
use memory_storage::{pyjson, sqlite};
|
||||
use std::io::Read;
|
||||
|
||||
fn get<'a>(v: &'a pyjson::Json, key: &str) -> Option<&'a pyjson::Json> {
|
||||
match v {
|
||||
pyjson::Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut input = String::new();
|
||||
if std::io::stdin().read_to_string(&mut input).is_err() {
|
||||
emit(Err("failed to read stdin".to_string()));
|
||||
return;
|
||||
}
|
||||
let parsed = match pyjson::parse(&input) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
emit(Err(format!("stdin JSON parse failed: {}", e)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let op = match get(&parsed, "op") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => {
|
||||
emit(Err("op required".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let arg = get(&parsed, "arg").cloned().unwrap_or(pyjson::Json::Null);
|
||||
let result = match op.as_str() {
|
||||
"read_query" => {
|
||||
let db_path = match get(&arg, "db_path") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => {
|
||||
emit(Err("arg.db_path required".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let sql = match get(&arg, "sql") {
|
||||
Some(pyjson::Json::Str(s)) => s.clone(),
|
||||
_ => {
|
||||
emit(Err("arg.sql required".to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let params = match get(&arg, "params") {
|
||||
Some(pyjson::Json::Arr(items)) => items.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
sqlite::read_query(&db_path, &sql, ¶ms).map(|rows| {
|
||||
pyjson::Json::Arr(rows.into_iter().map(pyjson::Json::Arr).collect())
|
||||
})
|
||||
}
|
||||
other => Err(format!("unknown op: {:?} (read-only build)", other)),
|
||||
};
|
||||
emit(result);
|
||||
}
|
||||
|
||||
fn emit(result: Result<pyjson::Json, String>) {
|
||||
let ok = result.is_ok();
|
||||
let body = match result {
|
||||
Ok(value) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(true)),
|
||||
("value".to_string(), value),
|
||||
]),
|
||||
Err(err) => pyjson::Json::Obj(vec![
|
||||
("ok".to_string(), pyjson::Json::Bool(false)),
|
||||
("err".to_string(), pyjson::Json::Str(err)),
|
||||
]),
|
||||
};
|
||||
println!("{}", pyjson::dumps(&body));
|
||||
std::process::exit(if ok { 0 } else { 1 });
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
//! json_min.py — minimal JSON parse/serialize with Python-parity output.
|
||||
//!
|
||||
//! Exists because contract C1 demands byte-comparable state files with the
|
||||
//! Python implementation (`json.dumps` / `json.dump(indent=2)`):
|
||||
//! - compact separators: ", " between items, ": " between key/value
|
||||
//! (Python json.dumps DEFAULTS — no separators arg means the space-y form)
|
||||
//! - ensure_ascii: non-ASCII escaped as \uXXXX (with surrogate pairs)
|
||||
//! - floats: shortest round-trip repr (Rust `{}` == Python `repr` for f64
|
||||
//! in every value this module produces, including arithmetic artifacts
|
||||
//! like 0.30000000000000004)
|
||||
//! - indent=2 dump: newline+indent after every item separator, "{}" when
|
||||
//! empty, NO trailing newline (json.dump never writes one)
|
||||
//! Object key order is preserved (Vec of pairs) — matches Python 3.7+ dict
|
||||
//! insertion order, which the classification tie-break law depends on.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// Preserved-order JSON value.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Json {
|
||||
Null,
|
||||
Bool(bool),
|
||||
Num(f64),
|
||||
Str(String),
|
||||
Arr(Vec<Json>),
|
||||
Obj(Vec<(String, Json)>),
|
||||
}
|
||||
|
||||
impl Json {
|
||||
pub fn get(&self, key: &str) -> Option<&Json> {
|
||||
match self {
|
||||
Json::Obj(pairs) => pairs.iter().find(|(k, _)| k == key).map(|(_, v)| v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
pub fn parse(input: &str) -> Result<Json, String> {
|
||||
let bytes = input.as_bytes();
|
||||
let mut p = Parser { b: bytes, i: 0 };
|
||||
p.skip_ws();
|
||||
let v = p.value()?;
|
||||
p.skip_ws();
|
||||
if p.i != bytes.len() {
|
||||
return Err(format!("trailing data at byte {}", p.i));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
b: &'a [u8],
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl<'a> Parser<'a> {
|
||||
fn skip_ws(&mut self) {
|
||||
while self.i < self.b.len() && matches!(self.b[self.i], b' ' | b'\t' | b'\n' | b'\r') {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.b.get(self.i).copied()
|
||||
}
|
||||
|
||||
fn expect(&mut self, c: u8) -> Result<(), String> {
|
||||
if self.peek() == Some(c) {
|
||||
self.i += 1;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("expected '{}' at byte {}", c as char, self.i))
|
||||
}
|
||||
}
|
||||
|
||||
fn value(&mut self) -> Result<Json, String> {
|
||||
match self.peek() {
|
||||
Some(b'{') => self.object(),
|
||||
Some(b'[') => self.array(),
|
||||
Some(b'"') => Ok(Json::Str(self.string()?)),
|
||||
Some(b't') => self.literal("true", Json::Bool(true)),
|
||||
Some(b'f') => self.literal("false", Json::Bool(false)),
|
||||
Some(b'n') => self.literal("null", Json::Null),
|
||||
Some(c) if c == b'-' || c.is_ascii_digit() => self.number(),
|
||||
_ => Err(format!("unexpected byte at {}", self.i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn literal(&mut self, lit: &str, v: Json) -> Result<Json, String> {
|
||||
if self.b[self.i..].starts_with(lit.as_bytes()) {
|
||||
self.i += lit.len();
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(format!("bad literal at byte {}", self.i))
|
||||
}
|
||||
}
|
||||
|
||||
fn object(&mut self) -> Result<Json, String> {
|
||||
self.expect(b'{')?;
|
||||
let mut pairs = Vec::new();
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b'}') {
|
||||
self.i += 1;
|
||||
return Ok(Json::Obj(pairs));
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
let key = self.string()?;
|
||||
self.skip_ws();
|
||||
self.expect(b':')?;
|
||||
self.skip_ws();
|
||||
let val = self.value()?;
|
||||
pairs.push((key, val));
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b'}') => {
|
||||
self.i += 1;
|
||||
return Ok(Json::Obj(pairs));
|
||||
}
|
||||
_ => return Err(format!("expected ',' or '}}' at byte {}", self.i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn array(&mut self) -> Result<Json, String> {
|
||||
self.expect(b'[')?;
|
||||
let mut items = Vec::new();
|
||||
self.skip_ws();
|
||||
if self.peek() == Some(b']') {
|
||||
self.i += 1;
|
||||
return Ok(Json::Arr(items));
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
items.push(self.value()?);
|
||||
self.skip_ws();
|
||||
match self.peek() {
|
||||
Some(b',') => self.i += 1,
|
||||
Some(b']') => {
|
||||
self.i += 1;
|
||||
return Ok(Json::Arr(items));
|
||||
}
|
||||
_ => return Err(format!("expected ',' or ']' at byte {}", self.i)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn string(&mut self) -> Result<String, String> {
|
||||
self.expect(b'"')?;
|
||||
let mut out = String::new();
|
||||
loop {
|
||||
match self.peek() {
|
||||
None => return Err("unterminated string".into()),
|
||||
Some(b'"') => {
|
||||
self.i += 1;
|
||||
return Ok(out);
|
||||
}
|
||||
Some(b'\\') => {
|
||||
self.i += 1;
|
||||
match self.peek() {
|
||||
Some(b'"') => out.push('"'),
|
||||
Some(b'\\') => out.push('\\'),
|
||||
Some(b'/') => out.push('/'),
|
||||
Some(b'b') => out.push('\u{0008}'),
|
||||
Some(b'f') => out.push('\u{000C}'),
|
||||
Some(b'n') => out.push('\n'),
|
||||
Some(b'r') => out.push('\r'),
|
||||
Some(b't') => out.push('\t'),
|
||||
Some(b'u') => {
|
||||
let cp = self.hex4()?;
|
||||
// surrogate pair handling (Python accepts these)
|
||||
if (0xD800..0xDC00).contains(&cp) {
|
||||
if self.b[self.i + 1..].starts_with(b"\\u") {
|
||||
self.i += 2;
|
||||
let lo = self.hex4()?;
|
||||
if (0xDC00..0xE000).contains(&lo) {
|
||||
let c = 0x10000
|
||||
+ ((cp - 0xD800) << 10)
|
||||
+ (lo - 0xDC00);
|
||||
out.push(
|
||||
char::from_u32(c)
|
||||
.ok_or("bad surrogate pair")?,
|
||||
);
|
||||
} else {
|
||||
// lone high surrogate — Python keeps it as
|
||||
// an escaped lone surrogate; we degrade to
|
||||
// U+FFFD (not produced by this writer)
|
||||
out.push('\u{FFFD}');
|
||||
}
|
||||
} else {
|
||||
out.push('\u{FFFD}');
|
||||
}
|
||||
} else {
|
||||
out.push(char::from_u32(cp).unwrap_or('\u{FFFD}'));
|
||||
}
|
||||
continue; // hex4 already advanced i
|
||||
}
|
||||
_ => return Err(format!("bad escape at byte {}", self.i)),
|
||||
}
|
||||
self.i += 1;
|
||||
}
|
||||
Some(_) => {
|
||||
// consume one UTF-8 scalar
|
||||
let s = &self.b[self.i..];
|
||||
let ch_len = utf8_len(s[0]);
|
||||
let chunk = std::str::from_utf8(&s[..ch_len.min(s.len())])
|
||||
.map_err(|_| "bad utf8")?;
|
||||
out.push_str(chunk);
|
||||
self.i += ch_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hex4(&mut self) -> Result<u32, String> {
|
||||
if self.i + 4 > self.b.len() {
|
||||
return Err("short \\u escape".into());
|
||||
}
|
||||
let s = std::str::from_utf8(&self.b[self.i + 1..self.i + 5])
|
||||
.map_err(|_| "bad \\u")?;
|
||||
let v = u32::from_str_radix(s, 16).map_err(|_| "bad \\u digits")?;
|
||||
self.i += 5; // 'u' + 4 hex digits (caller consumed the backslash)
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn number(&mut self) -> Result<Json, String> {
|
||||
let start = self.i;
|
||||
if self.peek() == Some(b'-') {
|
||||
self.i += 1;
|
||||
}
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
if self.peek() == Some(b'.') {
|
||||
self.i += 1;
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
if matches!(self.peek(), Some(b'e') | Some(b'E')) {
|
||||
self.i += 1;
|
||||
if matches!(self.peek(), Some(b'+') | Some(b'-')) {
|
||||
self.i += 1;
|
||||
}
|
||||
while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
let s = std::str::from_utf8(&self.b[start..self.i]).map_err(|_| "bad number")?;
|
||||
s.parse::<f64>()
|
||||
.map(Json::Num)
|
||||
.map_err(|_| format!("bad number '{}'", s))
|
||||
}
|
||||
}
|
||||
|
||||
fn utf8_len(b: u8) -> usize {
|
||||
if b < 0x80 {
|
||||
1
|
||||
} else if b >> 5 == 0b110 {
|
||||
2
|
||||
} else if b >> 4 == 0b1110 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
}
|
||||
}
|
||||
|
||||
// ── serialization (Python json.dumps / json.dump(indent=2) parity) ─────────
|
||||
|
||||
/// Python `json.dumps(value, sort_keys=True)` — compact separators, object
|
||||
/// keys recursively sorted. Python sorts str keys by code point; Rust's
|
||||
/// byte-wise UTF-8 `String` ordering is identical for valid UTF-8, so a
|
||||
/// plain `sort_by` on the key is faithful.
|
||||
pub fn dumps_sorted(v: &Json) -> String {
|
||||
let mut out = String::new();
|
||||
write_compact_sorted(&mut out, v);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_compact_sorted(out: &mut String, v: &Json) {
|
||||
match v {
|
||||
Json::Obj(pairs) => {
|
||||
let mut sorted: Vec<&(String, Json)> = pairs.iter().collect();
|
||||
sorted.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
out.push('{');
|
||||
for (i, (k, val)) in sorted.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_escaped(out, k);
|
||||
out.push_str(": ");
|
||||
write_compact_sorted(out, val);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Json::Arr(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_compact_sorted(out, item);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => write_compact(out, other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Python `json.dumps(value)` — compact separators `", "` / `": "`,
|
||||
/// ensure_ascii escaping.
|
||||
pub fn dumps(v: &Json) -> String {
|
||||
let mut out = String::new();
|
||||
write_compact(&mut out, v);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_compact(out: &mut String, v: &Json) {
|
||||
match v {
|
||||
Json::Null => out.push_str("null"),
|
||||
Json::Bool(true) => out.push_str("true"),
|
||||
Json::Bool(false) => out.push_str("false"),
|
||||
Json::Num(n) => out.push_str(&fmt_num(*n)),
|
||||
Json::Str(s) => write_escaped(out, s),
|
||||
Json::Arr(items) => {
|
||||
out.push('[');
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_compact(out, item);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
Json::Obj(pairs) => {
|
||||
out.push('{');
|
||||
for (i, (k, val)) in pairs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(", ");
|
||||
}
|
||||
write_escaped(out, k);
|
||||
out.push_str(": ");
|
||||
write_compact(out, val);
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Python `json.dump(value, indent=2)` — item separator becomes
|
||||
/// `",\n<indent>"`, key separator `": "`, empty container → `{}` / `[]`,
|
||||
/// NO trailing newline.
|
||||
pub fn dump_indent2(v: &Json) -> String {
|
||||
let mut out = String::new();
|
||||
write_indent(&mut out, v, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn write_indent(out: &mut String, v: &Json, depth: usize) {
|
||||
match v {
|
||||
Json::Arr(items) if !items.is_empty() => {
|
||||
out.push_str("[\n");
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(",\n");
|
||||
}
|
||||
push_indent(out, depth + 1);
|
||||
write_indent(out, item, depth + 1);
|
||||
}
|
||||
out.push('\n');
|
||||
push_indent(out, depth);
|
||||
out.push(']');
|
||||
}
|
||||
Json::Arr(_) => out.push_str("[]"),
|
||||
Json::Obj(pairs) if !pairs.is_empty() => {
|
||||
out.push_str("{\n");
|
||||
for (i, (k, val)) in pairs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str(",\n");
|
||||
}
|
||||
push_indent(out, depth + 1);
|
||||
write_escaped(out, k);
|
||||
out.push_str(": ");
|
||||
write_indent(out, val, depth + 1);
|
||||
}
|
||||
out.push('\n');
|
||||
push_indent(out, depth);
|
||||
out.push('}');
|
||||
}
|
||||
Json::Obj(_) => out.push_str("{}"),
|
||||
other => write_compact(out, other),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_indent(out: &mut String, depth: usize) {
|
||||
for _ in 0..depth {
|
||||
out.push_str(" ");
|
||||
}
|
||||
}
|
||||
|
||||
/// Integer-shaped f64 prints without a decimal point (matching how this
|
||||
/// module's writers emit counts); all other values use shortest round-trip.
|
||||
pub fn fmt_num(n: f64) -> String {
|
||||
if n.is_finite() && n.fract() == 0.0 && n.abs() < 9.007_199_254_740_992e15 {
|
||||
return format!("{}", n as i64);
|
||||
}
|
||||
format!("{}", n)
|
||||
}
|
||||
|
||||
/// Python `json.dumps` string escaping with ensure_ascii=True.
|
||||
pub fn write_escaped(out: &mut String, s: &str) {
|
||||
out.push('"');
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
'\u{0008}' => out.push_str("\\b"),
|
||||
'\u{000C}' => out.push_str("\\f"),
|
||||
c if (c as u32) < 0x20 => {
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c if (c as u32) <= 0x7E => out.push(c),
|
||||
// ensure_ascii: escape everything non-ASCII, surrogate pairs
|
||||
// for astral planes (Python behaviour)
|
||||
c => {
|
||||
let cp = c as u32;
|
||||
if cp > 0xFFFF {
|
||||
let v = cp - 0x10000;
|
||||
let hi = 0xD800 + (v >> 10);
|
||||
let lo = 0xDC00 + (v & 0x3FF);
|
||||
let _ = write!(out, "\\u{:04x}\\u{:04x}", hi, lo);
|
||||
} else {
|
||||
let _ = write!(out, "\\u{:04x}", cp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! sqlite.rs — zero-dep read-only bindings to the SYSTEM libsqlite3
|
||||
//! (R2 W2.0). Nick's ruling: SQLite is retained and wired in; a system
|
||||
//! library is not a crate dependency, so the zero-dep law holds.
|
||||
//!
|
||||
//! READ-ONLY BY CONSTRUCTION: this module binds only open/prepare/bind/
|
||||
//! step/column/finalize/close/errmsg — there is NO exec/prepare-for-write
|
||||
//! path, and the crate exports no write-capable symbols (canary section 5
|
||||
//! enforces it).
|
||||
|
||||
use crate::pyjson::Json;
|
||||
use std::ffi::{CStr, CString};
|
||||
use std::os::raw::{c_char, c_int, c_void};
|
||||
|
||||
const SQLITE_OK: c_int = 0;
|
||||
const SQLITE_ROW: c_int = 100;
|
||||
const SQLITE_DONE: c_int = 101;
|
||||
const SQLITE_OPEN_READONLY: c_int = 0x00000001;
|
||||
|
||||
const SQLITE_INTEGER: c_int = 1;
|
||||
const SQLITE_FLOAT: c_int = 2;
|
||||
const SQLITE_TEXT: c_int = 3;
|
||||
const SQLITE_BLOB: c_int = 4;
|
||||
const SQLITE_NULL: c_int = 5;
|
||||
|
||||
#[link(name = "sqlite3")]
|
||||
extern "C" {
|
||||
fn sqlite3_open_v2(
|
||||
filename: *const c_char,
|
||||
db: *mut *mut c_void,
|
||||
flags: c_int,
|
||||
vfs: *const c_char,
|
||||
) -> c_int;
|
||||
fn sqlite3_prepare_v2(
|
||||
db: *mut c_void,
|
||||
sql: *const c_char,
|
||||
nbytes: c_int,
|
||||
stmt: *mut *mut c_void,
|
||||
tail: *mut *const c_char,
|
||||
) -> c_int;
|
||||
fn sqlite3_bind_int64(stmt: *mut c_void, idx: c_int, val: i64) -> c_int;
|
||||
fn sqlite3_bind_double(stmt: *mut c_void, idx: c_int, val: f64) -> c_int;
|
||||
fn sqlite3_bind_text(
|
||||
stmt: *mut c_void,
|
||||
idx: c_int,
|
||||
val: *const c_char,
|
||||
nbytes: c_int,
|
||||
destructor: *mut c_void,
|
||||
) -> c_int;
|
||||
fn sqlite3_bind_null(stmt: *mut c_void, idx: c_int) -> c_int;
|
||||
fn sqlite3_step(stmt: *mut c_void) -> c_int;
|
||||
fn sqlite3_column_count(stmt: *mut c_void) -> c_int;
|
||||
fn sqlite3_column_type(stmt: *mut c_void, i: c_int) -> c_int;
|
||||
fn sqlite3_column_int64(stmt: *mut c_void, i: c_int) -> i64;
|
||||
fn sqlite3_column_double(stmt: *mut c_void, i: c_int) -> f64;
|
||||
fn sqlite3_column_text(stmt: *mut c_void, i: c_int) -> *const u8;
|
||||
fn sqlite3_column_blob(stmt: *mut c_void, i: c_int) -> *const c_void;
|
||||
fn sqlite3_column_bytes(stmt: *mut c_void, i: c_int) -> c_int;
|
||||
fn sqlite3_finalize(stmt: *mut c_void) -> c_int;
|
||||
fn sqlite3_close(db: *mut c_void) -> c_int;
|
||||
fn sqlite3_errmsg(db: *mut c_void) -> *const c_char;
|
||||
}
|
||||
|
||||
// SQLITE_TRANSIENT (-1 as a function pointer) — SQLite copies the bound text.
|
||||
const SQLITE_TRANSIENT: *mut c_void = !0usize as *mut c_void;
|
||||
|
||||
struct Db {
|
||||
handle: *mut c_void,
|
||||
}
|
||||
|
||||
impl Drop for Db {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
unsafe { sqlite3_close(self.handle) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn errmsg(db: *mut c_void) -> String {
|
||||
unsafe {
|
||||
let ptr = sqlite3_errmsg(db);
|
||||
if ptr.is_null() {
|
||||
return "unknown sqlite error".to_string();
|
||||
}
|
||||
CStr::from_ptr(ptr).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// read_query(db_path, sql, params) -> rows as JSON arrays.
|
||||
/// Params: JSON array; numbers bind int64/double by integrality, strings
|
||||
/// bind text, bools bind 0/1, and null binds SQL NULL.
|
||||
pub fn read_query(db_path: &str, sql: &str, params: &[Json]) -> Result<Vec<Vec<Json>>, String> {
|
||||
let c_path = CString::new(db_path).map_err(|_| "db path contains NUL")?;
|
||||
let c_sql = CString::new(sql).map_err(|_| "sql contains NUL")?;
|
||||
|
||||
let mut handle: *mut c_void = std::ptr::null_mut();
|
||||
// READ-ONLY at the SQLite connection level. A read-sounding exported
|
||||
// symbol is not enough: sqlite3_open() permits writes and creates a
|
||||
// missing database. open_v2(READONLY) makes mutation impossible even
|
||||
// if a caller supplies INSERT/UPDATE SQL.
|
||||
let rc = unsafe {
|
||||
sqlite3_open_v2(
|
||||
c_path.as_ptr(),
|
||||
&mut handle,
|
||||
SQLITE_OPEN_READONLY,
|
||||
std::ptr::null(),
|
||||
)
|
||||
};
|
||||
if rc != SQLITE_OK {
|
||||
let msg = if handle.is_null() {
|
||||
"cannot open database file".to_string()
|
||||
} else {
|
||||
errmsg(handle)
|
||||
};
|
||||
if !handle.is_null() {
|
||||
unsafe { sqlite3_close(handle) };
|
||||
}
|
||||
return Err(msg);
|
||||
}
|
||||
let db = Db { handle };
|
||||
|
||||
let mut stmt: *mut c_void = std::ptr::null_mut();
|
||||
let rc = unsafe {
|
||||
sqlite3_prepare_v2(
|
||||
db.handle,
|
||||
c_sql.as_ptr(),
|
||||
-1,
|
||||
&mut stmt,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if rc != SQLITE_OK {
|
||||
return Err(errmsg(db.handle));
|
||||
}
|
||||
struct Stmt(*mut c_void);
|
||||
impl Drop for Stmt {
|
||||
fn drop(&mut self) {
|
||||
if !self.0.is_null() {
|
||||
unsafe { sqlite3_finalize(self.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
let stmt = Stmt(stmt);
|
||||
|
||||
for (i, p) in params.iter().enumerate() {
|
||||
let idx = (i + 1) as c_int;
|
||||
let rc = match p {
|
||||
Json::Num(n) => {
|
||||
if n.fract() == 0.0 && n.abs() < 9.0e15 {
|
||||
unsafe { sqlite3_bind_int64(stmt.0, idx, *n as i64) }
|
||||
} else {
|
||||
unsafe { sqlite3_bind_double(stmt.0, idx, *n) }
|
||||
}
|
||||
}
|
||||
Json::Str(s) => {
|
||||
let c = CString::new(s.as_str()).map_err(|_| "param contains NUL")?;
|
||||
unsafe {
|
||||
sqlite3_bind_text(stmt.0, idx, c.as_ptr(), s.len() as c_int, SQLITE_TRANSIENT)
|
||||
}
|
||||
}
|
||||
Json::Bool(b) => unsafe { sqlite3_bind_int64(stmt.0, idx, if *b { 1 } else { 0 }) },
|
||||
Json::Null => unsafe { sqlite3_bind_null(stmt.0, idx) },
|
||||
_ => return Err("unsupported param type".to_string()),
|
||||
};
|
||||
if rc != SQLITE_OK {
|
||||
return Err(errmsg(db.handle));
|
||||
}
|
||||
}
|
||||
|
||||
let col_count = unsafe { sqlite3_column_count(stmt.0) };
|
||||
let mut rows: Vec<Vec<Json>> = vec![];
|
||||
loop {
|
||||
let rc = unsafe { sqlite3_step(stmt.0) };
|
||||
if rc == SQLITE_DONE {
|
||||
break;
|
||||
}
|
||||
if rc != SQLITE_ROW {
|
||||
return Err(errmsg(db.handle));
|
||||
}
|
||||
let mut row: Vec<Json> = vec![];
|
||||
for i in 0..col_count {
|
||||
let v = match unsafe { sqlite3_column_type(stmt.0, i) } {
|
||||
SQLITE_INTEGER => Json::Num(unsafe { sqlite3_column_int64(stmt.0, i) } as f64),
|
||||
SQLITE_FLOAT => Json::Num(unsafe { sqlite3_column_double(stmt.0, i) }),
|
||||
SQLITE_TEXT => {
|
||||
let ptr = unsafe { sqlite3_column_text(stmt.0, i) };
|
||||
let n = unsafe { sqlite3_column_bytes(stmt.0, i) };
|
||||
if ptr.is_null() || n == 0 {
|
||||
Json::Str(String::new())
|
||||
} else {
|
||||
let slice = unsafe { std::slice::from_raw_parts(ptr, n as usize) };
|
||||
Json::Str(String::from_utf8_lossy(slice).into_owned())
|
||||
}
|
||||
}
|
||||
SQLITE_BLOB => {
|
||||
let ptr = unsafe { sqlite3_column_blob(stmt.0, i) };
|
||||
let n = unsafe { sqlite3_column_bytes(stmt.0, i) };
|
||||
let slice = if ptr.is_null() {
|
||||
&[]
|
||||
} else {
|
||||
unsafe { std::slice::from_raw_parts(ptr as *const u8, n as usize) }
|
||||
};
|
||||
// blobs render as byte arrays (the JSON transport has no
|
||||
// bytes type; Python's bytes render via list(x) here)
|
||||
Json::Arr(slice.iter().map(|b| Json::Num(*b as f64)).collect())
|
||||
}
|
||||
SQLITE_NULL => Json::Null,
|
||||
_ => Json::Null,
|
||||
};
|
||||
row.push(v);
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+401
-272
File diff suppressed because it is too large.
Load diff
@@ -389,3 +389,41 @@ def test_construction_decision_placement_delegation():
|
||||
assert py["placement"] == "rust"
|
||||
pub = construction_decision.language_placement("rune", ["storage"])
|
||||
assert pub == py or pub["placement"] == "rust"
|
||||
|
||||
|
||||
# ── mems_loader / SQLite read path (R2 W2.0) ────────────────────────────────
|
||||
|
||||
|
||||
def test_mems_loader_read_path_through_cdylib(tmp_path):
|
||||
import sqlite3 as _sq
|
||||
from rune import mems_loader
|
||||
|
||||
if not mems_loader.available():
|
||||
pytest.skip("memory_storage crate not built")
|
||||
db_path = str(tmp_path / "t.db")
|
||||
conn = _sq.connect(db_path)
|
||||
conn.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
conn.execute("INSERT INTO t (name) VALUES ('alpha')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
rows = mems_loader.read_query(db_path, "SELECT id, name FROM t", [])
|
||||
assert rows == [[1, "alpha"]]
|
||||
env = mems_loader.read_query_result(db_path, "SELECT x FROM missing", [])
|
||||
assert env.get("ok") is False and "no such table" in env.get("err", "")
|
||||
|
||||
|
||||
def test_memory_store_storage_seam(tmp_path):
|
||||
import sqlite3 as _sq
|
||||
from rune import memory_store
|
||||
|
||||
db_path = str(tmp_path / "s.db")
|
||||
conn = _sq.connect(db_path)
|
||||
conn.execute("CREATE TABLE entries (id INTEGER PRIMARY KEY, status TEXT DEFAULT 'active', topic TEXT DEFAULT '')")
|
||||
conn.execute("CREATE TABLE working (id INTEGER PRIMARY KEY)")
|
||||
conn.execute("CREATE TABLE audit_log (id INTEGER PRIMARY KEY)")
|
||||
conn.execute("INSERT INTO entries (topic) VALUES ('general')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
out = memory_store.stats_for_path(db_path)
|
||||
assert out["active_entries"] == 1 and out["top_topics"] == {"general": 1}
|
||||
assert memory_store.storage_backend().name == "sqlite"
|
||||
Reference in new issue
Block a user