security: harden sqlite ffi
ober
21150d96c1822dee2c11859cd9ef5c476ffd0bee
--- a/docs/ffi-audit.md +++ b/docs/ffi-audit.md @@ -40,10 +40,10 @@ Latest summary from `tools/ffi-audit-report.ss`: (rust-file-count 29) (c-file-count 5) (no-mangle-exports 196) - (unsafe-sites 409) - (unsafe-sites-with-nearby-safety-comment 221) - (unsafe-sites-without-nearby-safety-comment 188) - (unsafe-comment-review-sites 188) + (unsafe-sites 397) + (unsafe-sites-with-nearby-safety-comment 243) + (unsafe-sites-without-nearby-safety-comment 154) + (unsafe-comment-review-sites 154) (export-review-sites 196)) (vendor (jsqlite (path "vendor/jsqlite") (status accepted-risk-cve-gated))) @@ -139,9 +139,14 @@ null/nonzero input and output-slot failures. `wasm_sm.rs` now compiles under the crate-wide `unsafe_op_in_unsafe_fn` denial for the SpiderMonkey feature, checks host-memory offset arithmetic, bounds C ABI module/function/argument/log buffers, converts poisoned handle-store locks into normal FFI errors, and has -helper regressions for null/nonzero inputs and signed offset conversion. The -generated inventory now reports 221 annotated native unsafe sites and 188 -remaining unsafe review sites. +helper regressions for null/nonzero inputs and signed offset conversion. +`sqlite.rs` now funnels path, SQL, text, and blob inputs through checked C ABI +slice helpers, rejects oversized SQLite `int` lengths, centralizes +text/blob/name/error output-buffer writes, converts poisoned +database/statement-store locks into normal FFI errors, wraps integer and double +column getters in panic containment, and has regressions for null/nonzero +inputs and output-buffer validation. The generated inventory now reports 243 +annotated native unsafe sites and 154 remaining unsafe review sites. Remaining work before closing K3-P1-01: --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Jerboa Documentation -Updated 2026-07-27. +Updated 2026-07-28. These are the maintained docs for the current Jerboa toolchain. Public examples use the `jerboa` CLI: run programs with `jerboa run file.ss`, open the REPL with @@ -37,7 +37,9 @@ and handoff notes live under `docs/reviews/` or in the explicit backlog When a topic has both a reference and a review note, prefer the reference for current behavior and use the review note only for historical context or open -work. +work. Do not maintain secondary documentation inventories inside handoff or +audit files; link back here, to [status.md](status.md), or to the relevant +generated audit. Design notes such as [harden.md](harden.md), [hide.md](hide.md), [unification.md](unification.md), and [ai-threat.md](ai-threat.md) are not --- a/docs/kimi3-security-recommmendations.md +++ b/docs/kimi3-security-recommmendations.md @@ -107,9 +107,9 @@ Consequences: circumventing. [`ai-threat.md`](ai-threat.md) already flags the SQL injection heuristic this way. - **Known-unknowns become knowns.** Every "we should audit this someday" - corner of the tree (the 91 `foreign-procedure` files, the 225 exported - Rust FFI symbols, the raw `read` paths) will be systematically enumerated. - There is no security through obscurity left. + corner of the tree (the 91 `foreign-procedure` files, the 196 native + `#[no_mangle]` sites tracked by the FFI audit, the raw `read` paths) will + be systematically enumerated. There is no security through obscurity left. - **Supply-chain and tooling surfaces get attacked at machine speed.** Install scripts, code generators, MCP tools, LSP servers, and CI scripts are all "applications written in this language" too. @@ -145,8 +145,8 @@ never pattern-based: Win"). What the language *can* do is make the safe pattern the default and the unsafe pattern unrepresentable — that's most of P0/P1 below. - **The FFI boundary is our `unsafe`.** 91 files under `lib/std/` mention - `foreign-procedure`; `jerboa-native-rs/src` exports ~225 `#[no_mangle]` - C-ABI symbols and contains 432 matches for `unsafe`. That boundary is + `foreign-procedure`; the current [FFI audit](ffi-audit.md) tracks 196 + native `#[no_mangle]` sites and 397 Rust unsafe sites. That boundary is where a K3-class adversary will concentrate, because it is the only place memory-corruption patterns *exist* in a Jerboa application. [`Philosophy.md`](Philosophy.md) Principle 4 and open tension #5 both @@ -257,13 +257,18 @@ when" must be answerable from `dist/release-evidence/` in minutes. transparency log, `jpkg audit` (OSV), declared-capability gating ([jpkg-guide.md](jpkg-guide.md)). -### 4.3 Verified numbers +### 4.3 Verified Numbers -| Metric | Value (2026-07-27) | Command | +The current source of truth for release posture is [status.md](status.md); the +current source of truth for FFI/native counts is [ffi-audit.md](ffi-audit.md). +Keep this section to baseline facts only. + +| Metric | Value (2026-07-28) | Command | |---|---|---| | Files under `lib/std/` mentioning `foreign-procedure` | **91** | `grep -rl foreign-procedure lib/std \| wc -l` | -| `#[no_mangle]` C-ABI exports in `jerboa-native-rs/src` | **225** | `grep -c no_mangle jerboa-native-rs/src/*.rs` | -| `unsafe` matches in `jerboa-native-rs/src` | **432** | `grep -c unsafe jerboa-native-rs/src/*.rs` | +| Native `#[no_mangle]` sites in `jerboa-native-rs/src` | **196** | `make ffi-audit-report` | +| Actual native C ABI exports after cfg filtering | **190** | `make native-export-review-check` | +| Rust unsafe sites in `jerboa-native-rs/src` | **397** | `make ffi-audit-report` | | Fuzz harnesses | 13 | `ls tests/fuzz/harness` | | Checked-in fuzz seed corpora | **0** | `tests/fuzz/` contains only `harness/` | | Security test files | 21 | Appendix A | @@ -570,9 +575,10 @@ Harnesses without corpora find a bug once and forget it. ### K3-P1-01 — FFI boundary audit (parser-hardening phase 5) **Serves:** G1. **Effort:** 3–4 weeks (the big one). -The 91 `foreign-procedure` files and 225 native exports are where a -K3-class adversary will shop. `security-reference.md` §13: "FFI audit … is -not started." +The 91 `foreign-procedure` files, 196 native `#[no_mangle]` audit sites, and +190 actual native exports are where a K3-class adversary will shop. +`security-reference.md` §13 originally said "FFI audit … is not started"; +the current inventory and remediation trail now live in [ffi-audit.md](ffi-audit.md). - **Do:** (a) Inventory: generate the authoritative list of every `foreign-procedure` binding and every `#[no_mangle]` export, with arity, @@ -681,7 +687,11 @@ not started." `wasm_sm.rs` now compiles under `unsafe_op_in_unsafe_fn` for the SpiderMonkey feature, bounds C ABI buffers, validates host-memory offsets, and converts poisoned handle-store locks into normal FFI errors. The - generated report now shows 221 annotated native unsafe sites and 188 + `sqlite.rs` native wrapper now validates path, SQL, text, blob, result + output, and error output buffers through shared helpers, rejects oversized + SQLite lengths, converts poisoned handle-store locks into normal FFI errors, + and wraps integer and double column getters in panic containment. The + generated report now shows 243 annotated native unsafe sites and 154 remaining unsafe review sites. Remaining work: continue unsafe invariant comments across the rest of `jerboa-native-rs`. @@ -1172,7 +1182,7 @@ Track these in `docs/status.md` per release: | Confined worker exists | facade, audit lifecycle, output caps, deadline, process-group kill, memory rlimit pre-exec path, explicit sandbox-axis refusal, egress proxy env wiring, and Linux syscall/ptrace seccomp pre-exec setup landed; Landlock path/net and Seatbelt/Capsicum parity pending | yes, tested (P0-02) | | Unclassified raw `read`/FASL/`load` sites | scanner-driven manifest classification gate closed 2026-07-27; trusted build, compiler, REPL/dev, and test surfaces are centrally justified by exact source line | 0 | | FFI bindings audited | scanner inventory and provisional verdicts in [ffi-audit.md](ffi-audit.md); native exports gated and justified | 100% with final verdicts | -| Un-annotated Rust `unsafe` blocks | 246 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 | +| Un-annotated Rust `unsafe` blocks | 154 unsafe review sites without nearby `SAFETY:` comments in [ffi-audit.md](ffi-audit.md) | 0 | | Fuzz corpora / crash regressions | 0 / 0 | per-parser corpora + every crash a test | | Exploit-shaped regression tests | ~0 | ≥ 1 per historical finding | | Sandboxed-by-default app entry | no | yes (P0-06) | @@ -1187,8 +1197,8 @@ Track these in `docs/status.md` per release: ```bash # Posture facts used in this document (re-run to refresh): grep -rl 'foreign-procedure' lib/std | wc -l # 91 -grep -c 'no_mangle' jerboa-native-rs/src/*.rs | awk -F: '{s+=$2} END {print s}' # 225 -grep -c 'unsafe' jerboa-native-rs/src/*.rs | awk -F: '{s+=$2} END {print s}' # 432 +make ffi-audit-report # native no_mangle 196; unsafe 397 +make native-export-review-check # actual exports 190 ls tests/fuzz/harness # 13 harnesses ls vendor/ # ChezScheme, ChezScheme-patches, jsqlite @@ -1209,31 +1219,20 @@ make verify # local production gate make sbom reproducibility-report signing-evidence release-evidence ``` -## Appendix B: Documentation inventory - -Security-relevant docs as of 2026-07-27, with their role in this plan: - -| Doc | Role | -|---|---| -| [`Philosophy.md`](Philosophy.md) | The *why*: authority-centric doctrine; principles every item above cites | -| [`ai-threat.md`](ai-threat.md) | Existing AI-adversary threat model; update only when comparative posture changes | -| [`security-reference.md`](security-reference.md) | Canonical implemented-security reference + limitation ledger (§13) | -| [`safety-guide.md`](safety-guide.md) | User-facing secure-app guide; points users at the safe prelude and worker boundary | -| [`release-security.md`](release-security.md) | Release gates and threat model for the distribution | -| [`capability.md`](capability.md) | ocap implementation + documented limitations (TOCTOU fixed by P1-02) | -| [`chez-hardening.md`](chez-hardening.md) | CPU/runtime mitigations; P2-08 and P3-01..03 source | -| [`harden.md`](harden.md), [`harden-usage.md`](harden-usage.md) | Binary hardening; §7/§12 design-only → P3-08 | -| [`limits.md`](limits.md) | Fail-closed launcher policy the worker (P0-02) must satisfy | -| [`aproc.md`](aproc.md) | The exec primitive the worker builds on | -| [`slang.md`](slang.md) | Secure static subset; P3-05 | -| [`unification.md`](unification.md) | Security invariants for the whole stack; §"AI code is untrusted code" | -| [`ffi.md`](ffi.md) | FFI DSL with ownership/destructors; P1-01 prefers generated bindings | -| [`jpkg-guide.md`](jpkg-guide.md) | Supply-chain security model; P2-01/02 | -| [`uhoh.md`](uhoh.md) | Crypto discipline rule table; enforced by P1-05 | -| [`finalizer-safety-net.md`](finalizer-safety-net.md) | Resource-leak net proposal; verify guardian coverage when adopting | -| [`chez-limits.md`](chez-limits.md) | Target for P1-07 heap-cap findings | -| [`testing-and-infrastructure.md`](testing-and-infrastructure.md) | Update with corpus/regression layout (P0-08) | -| [`SECURITY.md`](../SECURITY.md) | Policy root; cross-link this file | +## Appendix B: Documentation Inventory + +This language repository keeps one maintained documentation map: +[docs/index.md](index.md). Use it instead of adding parallel catalogues to +review notes or handoff files. + +- Stable language and library references live at top-level `docs/`. +- Current production state lives in [status.md](status.md). +- Implemented security behavior lives in + [security-reference.md](security-reference.md). +- Current FFI/native audit counts live in [ffi-audit.md](ffi-audit.md). +- Dated review records live under [reviews/](reviews/). +- This file remains the prioritized security backlog and implementation + handoff, not a second documentation index. ## Appendix C: Honest limitation ledger --- a/docs/reviews/2026-07-27-native-export-review.sexp +++ b/docs/reviews/2026-07-27-native-export-review.sexp @@ -140,28 +140,28 @@ (export (symbol "jerboa_sm_instance_new_hosted") (file "jerboa-native-rs/src/wasm_sm.rs") (line 989) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 174)))) (export (symbol "jerboa_sm_module_free") (file "jerboa-native-rs/src/wasm_sm.rs") (line 934) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 150)))) (export (symbol "jerboa_sm_module_new") (file "jerboa-native-rs/src/wasm_sm.rs") (line 847) (scheme-callers 1 (caller (file "lib/std/wasm/sandbox.ss") (line 144)))) - (export (symbol "jerboa_sqlite_bind_blob") (file "jerboa-native-rs/src/sqlite.rs") (line 318) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 63)))) - (export (symbol "jerboa_sqlite_bind_double") (file "jerboa-native-rs/src/sqlite.rs") (line 247) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 59)))) - (export (symbol "jerboa_sqlite_bind_int") (file "jerboa-native-rs/src/sqlite.rs") (line 227) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 57)))) - (export (symbol "jerboa_sqlite_bind_null") (file "jerboa-native-rs/src/sqlite.rs") (line 362) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 65)))) - (export (symbol "jerboa_sqlite_bind_text") (file "jerboa-native-rs/src/sqlite.rs") (line 267) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 61)))) - (export (symbol "jerboa_sqlite_changes") (file "jerboa-native-rs/src/sqlite.rs") (line 611) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 91)))) - (export (symbol "jerboa_sqlite_close") (file "jerboa-native-rs/src/sqlite.rs") (line 86) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 46)))) - (export (symbol "jerboa_sqlite_column_blob") (file "jerboa-native-rs/src/sqlite.rs") (line 489) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 82)))) - (export (symbol "jerboa_sqlite_column_count") (file "jerboa-native-rs/src/sqlite.rs") (line 410) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 71)))) - (export (symbol "jerboa_sqlite_column_double") (file "jerboa-native-rs/src/sqlite.rs") (line 442) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 77)))) - (export (symbol "jerboa_sqlite_column_int") (file "jerboa-native-rs/src/sqlite.rs") (line 433) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 75)))) - (export (symbol "jerboa_sqlite_column_name") (file "jerboa-native-rs/src/sqlite.rs") (line 526) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 85)))) - (export (symbol "jerboa_sqlite_column_text") (file "jerboa-native-rs/src/sqlite.rs") (line 452) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 79)))) - (export (symbol "jerboa_sqlite_column_type") (file "jerboa-native-rs/src/sqlite.rs") (line 422) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 73)))) - (export (symbol "jerboa_sqlite_errmsg") (file "jerboa-native-rs/src/sqlite.rs") (line 620) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 93)))) - (export (symbol "jerboa_sqlite_exec") (file "jerboa-native-rs/src/sqlite.rs") (line 117) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 48)))) - (export (symbol "jerboa_sqlite_finalize") (file "jerboa-native-rs/src/sqlite.rs") (line 584) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 52)))) - (export (symbol "jerboa_sqlite_last_insert_rowid") (file "jerboa-native-rs/src/sqlite.rs") (line 602) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 89)))) - (export (symbol "jerboa_sqlite_open") (file "jerboa-native-rs/src/sqlite.rs") (line 46) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 44)))) - (export (symbol "jerboa_sqlite_prepare") (file "jerboa-native-rs/src/sqlite.rs") (line 152) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 50)))) - (export (symbol "jerboa_sqlite_reset") (file "jerboa-native-rs/src/sqlite.rs") (line 564) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 54)))) - (export (symbol "jerboa_sqlite_step") (file "jerboa-native-rs/src/sqlite.rs") (line 385) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 68)))) + (export (symbol "jerboa_sqlite_bind_blob") (file "jerboa-native-rs/src/sqlite.rs") (line 445) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 63)))) + (export (symbol "jerboa_sqlite_bind_double") (file "jerboa-native-rs/src/sqlite.rs") (line 374) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 59)))) + (export (symbol "jerboa_sqlite_bind_int") (file "jerboa-native-rs/src/sqlite.rs") (line 350) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 57)))) + (export (symbol "jerboa_sqlite_bind_null") (file "jerboa-native-rs/src/sqlite.rs") (line 491) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 65)))) + (export (symbol "jerboa_sqlite_bind_text") (file "jerboa-native-rs/src/sqlite.rs") (line 398) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 61)))) + (export (symbol "jerboa_sqlite_changes") (file "jerboa-native-rs/src/sqlite.rs") (line 781) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 91)))) + (export (symbol "jerboa_sqlite_close") (file "jerboa-native-rs/src/sqlite.rs") (line 194) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 46)))) + (export (symbol "jerboa_sqlite_column_blob") (file "jerboa-native-rs/src/sqlite.rs") (line 644) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 82)))) + (export (symbol "jerboa_sqlite_column_count") (file "jerboa-native-rs/src/sqlite.rs") (line 547) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 71)))) + (export (symbol "jerboa_sqlite_column_double") (file "jerboa-native-rs/src/sqlite.rs") (line 596) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 77)))) + (export (symbol "jerboa_sqlite_column_int") (file "jerboa-native-rs/src/sqlite.rs") (line 580) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 75)))) + (export (symbol "jerboa_sqlite_column_name") (file "jerboa-native-rs/src/sqlite.rs") (line 686) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 85)))) + (export (symbol "jerboa_sqlite_column_text") (file "jerboa-native-rs/src/sqlite.rs") (line 613) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 79)))) + (export (symbol "jerboa_sqlite_column_type") (file "jerboa-native-rs/src/sqlite.rs") (line 564) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 73)))) + (export (symbol "jerboa_sqlite_errmsg") (file "jerboa-native-rs/src/sqlite.rs") (line 797) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 93)))) + (export (symbol "jerboa_sqlite_exec") (file "jerboa-native-rs/src/sqlite.rs") (line 233) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 48)))) + (export (symbol "jerboa_sqlite_finalize") (file "jerboa-native-rs/src/sqlite.rs") (line 742) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 52)))) + (export (symbol "jerboa_sqlite_last_insert_rowid") (file "jerboa-native-rs/src/sqlite.rs") (line 768) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 89)))) + (export (symbol "jerboa_sqlite_open") (file "jerboa-native-rs/src/sqlite.rs") (line 153) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 44)))) + (export (symbol "jerboa_sqlite_prepare") (file "jerboa-native-rs/src/sqlite.rs") (line 262) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 50)))) + (export (symbol "jerboa_sqlite_reset") (file "jerboa-native-rs/src/sqlite.rs") (line 718) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 54)))) + (export (symbol "jerboa_sqlite_step") (file "jerboa-native-rs/src/sqlite.rs") (line 518) (scheme-callers 1 (caller (file "lib/std/db/sqlite-native.ss") (line 68)))) (export (symbol "jerboa_timing_safe_equal") (file "jerboa-native-rs/src/crypto.rs") (line 310) (scheme-callers 1 (caller (file "lib/std/crypto/native-rust.ss") (line 131)))) (export (symbol "jerboa_tls_accept") (file "jerboa-native-rs/src/tls.rs") (line 807) (scheme-callers 1 (caller (file "lib/std/net/tls-rustls.ss") (line 86)))) (export (symbol "jerboa_tls_close") (file "jerboa-native-rs/src/tls.rs") (line 1675) (scheme-callers 1 (caller (file "lib/std/net/tls-rustls.ss") (line 144)))) --- a/docs/status.md +++ b/docs/status.md @@ -25,7 +25,7 @@ release artifacts are built as Jerboa multicall binaries with `jerboa`, | Area | Current state | Remaining work | |---|---|---| | Kimi security handoff | [kimi3-security-recommmendations.md](kimi3-security-recommmendations.md) is the backlog. Dated evidence and review manifests live under [reviews/](reviews/). | Keep new security evidence in dated review records and summarize the current release state here. | -| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, and X.509 native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants, with the generated unannotated unsafe-site count at 188. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. | +| FFI audit phase 5 | [ffi-audit.md](ffi-audit.md) records the scanner output, provisional Scheme binding verdicts, and native Rust export inventory. `make native-export-review-check` gates native export decisions. The worker-launch native path in `aproc.rs`/`seccomp.rs`, crypto FFI buffer path in `crypto.rs`, secure-memory region lifecycle in `secure_mem.rs`, Ed25519/X25519 key-agreement buffers, compression buffers, HTTP parse/writev boundary, embed-crypto ABI, integrity ABI, regex-native ABI, process-control ABI, pcap ABI, inotify ABI, epoll/eventfd ABI, antidebug ABI, SOCKS5 server ABI, PostgreSQL native ABI, Landlock native ABI, wasmi sandbox ABI, SpiderMonkey WASM ABI, DuckDB native ABI, X.509 native ABI, and SQLite native ABI now have nearby `SAFETY:` comments or equivalent checked FFI invariants, with the generated unannotated unsafe-site count at 154. | Continue adding `SAFETY:` invariant comments near the remaining Rust unsafe sites. | | Native Rust exports | The native export review now has 190 exported functions: 183 tracked Scheme references and 7 retained standalone C/binary helpers. The previous 35 no-Scheme-reference removal candidates no longer have C ABI export markers. | Re-run `make native-export-review-check` whenever adding or removing native exports. | | Confined worker | `(std security worker)` provides the facade, audit lifecycle, output caps, deadlines, process-group kill, memory rlimit pre-exec setup, Linux syscall/ptrace seccomp pre-exec setup for requested axes, explicit sandbox-axis refusal, and egress proxy env wiring. | Install native Landlock path/net rules in the worker pre-exec path and keep Linux/macOS/FreeBSD parity tests current. | | Safe surface | Direct scripts default to the safe prelude; raw access requires `--unsafe-prelude` or `(jerboa prelude unsafe)`. | Continue moving risky APIs behind explicit unsafe imports as new modules land. | --- a/jerboa-native-rs/src/sqlite.rs +++ b/jerboa-native-rs/src/sqlite.rs @@ -1,10 +1,11 @@ -use crate::panic::{ffi_wrap, set_last_error}; +use crate::panic::{ffi_wrap, ffi_wrap_i64, set_last_error}; use rusqlite::Connection; use std::collections::HashMap; use std::ffi::{c_char, CStr}; +use std::panic; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::LazyLock; -use std::sync::Mutex; +use std::sync::{Mutex, MutexGuard}; // We use raw sqlite3 / sqlite3_stmt pointers to avoid lifetime issues. // rusqlite is only used for opening (which initializes SQLite properly). @@ -19,6 +20,7 @@ static DB_STORE: LazyLock<Mutex<HashMap<u64, DbEntry>>> = static STMT_STORE: LazyLock<Mutex<HashMap<u64, StmtEntry>>> = LazyLock::new(|| Mutex::new(HashMap::new())); static NEXT_ID: AtomicU64 = AtomicU64::new(1); +const MAX_C_ABI_SLICE_LEN: usize = isize::MAX as usize; struct DbEntry { // Keep the Connection alive so rusqlite manages the sqlite3* lifetime @@ -40,21 +42,123 @@ fn next_id() -> u64 { NEXT_ID.fetch_add(1, Ordering::SeqCst) } +fn lock_db_store() -> Option<MutexGuard<'static, HashMap<u64, DbEntry>>> { + match DB_STORE.lock() { + Ok(store) => Some(store), + Err(_) => { + set_last_error("sqlite db store lock poisoned".into()); + None + } + } +} + +fn lock_stmt_store() -> Option<MutexGuard<'static, HashMap<u64, StmtEntry>>> { + match STMT_STORE.lock() { + Ok(store) => Some(store), + Err(_) => { + set_last_error("sqlite statement store lock poisoned".into()); + None + } + } +} + +fn ffi_bytes<'a>(ptr: *const u8, len: usize, label: &str) -> Result<&'a [u8], ()> { + if ptr.is_null() { + if len == 0 { + return Ok(&[]); + } + set_last_error(format!("null {label} pointer with nonzero length")); + return Err(()); + } + if len > MAX_C_ABI_SLICE_LEN { + set_last_error(format!("{label} length exceeds Rust slice limit")); + return Err(()); + } + // SAFETY: The C ABI caller supplies `ptr`/`len`. We reject null nonempty + // buffers and lengths above `isize::MAX`; the caller remains responsible + // for providing a readable buffer for the duration of this call. + Ok(unsafe { std::slice::from_raw_parts(ptr, len) }) +} + +fn ffi_utf8<'a>(ptr: *const u8, len: usize, label: &str) -> Result<&'a str, ()> { + let bytes = ffi_bytes(ptr, len, label)?; + match std::str::from_utf8(bytes) { + Ok(value) => Ok(value), + Err(_) => { + set_last_error(format!("invalid UTF-8 {label}")); + Err(()) + } + } +} + +fn sqlite_len_i32(len: usize, label: &str) -> Option<i32> { + match i32::try_from(len) { + Ok(len) => Some(len), + Err(_) => { + set_last_error(format!("{label} exceeds SQLite's INT_MAX limit")); + None + } + } +} + +fn copy_to_output(bytes: &[u8], output: *mut u8, output_max: usize, output_len: *mut usize) -> i32 { + if output_len.is_null() { + set_last_error("null SQLite output length pointer".into()); + return -1; + } + if output.is_null() && output_max != 0 { + set_last_error("null SQLite output pointer with nonzero capacity".into()); + return -1; + } + if output_max > MAX_C_ABI_SLICE_LEN { + set_last_error("SQLite output capacity exceeds Rust slice limit".into()); + return -1; + } + let copy_len = bytes.len().min(output_max); + if copy_len != 0 { + // SAFETY: `output` is non-null when `output_max` is nonzero, and the + // caller promises it is writable for `output_max` bytes. We copy only + // `copy_len <= output_max` bytes from a Rust-owned source slice. + let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) }; + out[..copy_len].copy_from_slice(&bytes[..copy_len]); + } + // SAFETY: `output_len` was checked non-null and points to caller-owned + // storage for one `usize`. + unsafe { + *output_len = bytes.len(); + } + 0 +} + +fn ffi_wrap_f64<F: FnOnce() -> f64 + panic::UnwindSafe>(f: F) -> f64 { + match panic::catch_unwind(f) { + Ok(value) => value, + Err(e) => { + let msg = if let Some(s) = e.downcast_ref::<&str>() { + s.to_string() + } else if let Some(s) = e.downcast_ref::<String>() { + s.clone() + } else { + "unknown panic".to_string() + }; + set_last_error(msg); + 0.0 + } + } +} + // --- Database open/close --- #[no_mangle] pub extern "C" fn jerboa_sqlite_open(path: *const u8, path_len: usize, handle: *mut u64) -> i32 { ffi_wrap(|| { - if path.is_null() || handle.is_null() { + if handle.is_null() { + set_last_error("null SQLite handle output pointer".into()); return -1; } - let path_bytes = unsafe { std::slice::from_raw_parts(path, path_len) }; - let path_str = match std::str::from_utf8(path_bytes) { - Ok(s) => s, - Err(_) => { - set_last_error("invalid UTF-8 path".into()); - return -1; - } + let path_str = match ffi_utf8(path, path_len, "SQLite path") { + Ok(path) => path, + Err(()) => return -1, }; let conn = if path_str == ":memory:" { Connection::open_in_memory() @@ -63,12 +167,16 @@ pub extern "C" fn jerboa_sqlite_open(path: *const u8, path_len: usize, handle: * }; match conn { Ok(c) => { + // SAFETY: `c` owns a live SQLite connection. The raw handle is + // stored only while `c` remains alive in `DbEntry`. let raw = unsafe { c.handle() }; let id = next_id(); - DB_STORE - .lock() - .unwrap() - .insert(id, DbEntry { _conn: c, raw }); + let Some(mut store) = lock_db_store() else { + return -1; + }; + store.insert(id, DbEntry { _conn: c, raw }); + // SAFETY: `handle` was checked non-null and points to + // caller-owned storage for one `u64`. unsafe { *handle = id; } @@ -86,7 +194,9 @@ pub extern "C" fn jerboa_sqlite_open(path: *const u8, path_len: usize, handle: * pub extern "C" fn jerboa_sqlite_close(handle: u64) -> i32 { ffi_wrap(|| { // First remove any statements for this db - let mut stmts = STMT_STORE.lock().unwrap(); + let Some(mut stmts) = lock_stmt_store() else { + return -1; + }; let to_remove: Vec<u64> = stmts .iter() .filter(|(_, v)| v.db_handle == handle) @@ -94,6 +204,9 @@ pub extern "C" fn jerboa_sqlite_close(handle: u64) -> i32 { .collect(); for k in to_remove { if let Some(entry) = stmts.remove(&k) { + // SAFETY: statement handles are inserted only after successful + // sqlite3_prepare_v2 and removed at most once while the store + // mutex serializes access. unsafe { rusqlite::ffi::sqlite3_finalize(entry.raw); } @@ -101,7 +214,10 @@ pub extern "C" fn jerboa_sqlite_close(handle: u64) -> i32 { } drop(stmts); - match DB_STORE.lock().unwrap().remove(&handle) { + let Some(mut dbs) = lock_db_store() else { + return -1; + }; + match dbs.remove(&handle) { Some(_) => 0, // Connection dropped, sqlite3_close called by rusqlite None => { set_last_error("invalid db handle".into()); @@ -116,19 +232,13 @@ pub extern "C" fn jerboa_sqlite_close(handle: u64) -> i32 { #[no_mangle] pub extern "C" fn jerboa_sqlite_exec(handle: u64, sql: *const u8, sql_len: usize) -> i32 { ffi_wrap(|| { - if sql.is_null() { - set_last_error("null SQL pointer".into()); - return -1; - } - let sql_str = match std::str::from_utf8(unsafe { std::slice::from_raw_parts(sql, sql_len) }) - { + let sql_str = match ffi_utf8(sql, sql_len, "SQLite SQL") { Ok(sql) => sql, - Err(_) => { - set_last_error("invalid UTF-8 SQL".into()); - return -1; - } + Err(()) => return -1, + }; + let Some(store) = lock_db_store() else { + return -1; }; - let store = DB_STORE.lock().unwrap(); let entry = match store.get(&handle) { Some(e) => e, None => { @@ -160,20 +270,17 @@ pub extern "C" fn jerboa_sqlite_prepare( set_last_error("null SQLite prepare argument".into()); return -1; } - let sql_len = match i32::try_from(sql_len) { - Ok(len) => len, - Err(_) => { - set_last_error("SQL exceeds SQLite's INT_MAX limit".into()); - return -1; - } + let sql_len_i32 = match sqlite_len_i32(sql_len, "SQL") { + Some(len) => len, + None => return -1, + }; + let sql_str = match ffi_utf8(sql, sql_len, "SQLite SQL") { + Ok(sql) => sql, + Err(()) => return -1, }; - if std::str::from_utf8(unsafe { std::slice::from_raw_parts(sql, sql_len as usize) }) - .is_err() - { - set_last_error("invalid UTF-8 SQL".into()); + let Some(store) = lock_db_store() else { return -1; - } - let store = DB_STORE.lock().unwrap(); + }; let entry = match store.get(&db_handle) { Some(e) => e, None => { @@ -183,16 +290,22 @@ pub extern "C" fn jerboa_sqlite_prepare( }; let mut raw_stmt: RawStmt = std::ptr::null_mut(); + // SAFETY: `entry.raw` is a live sqlite3 pointer kept alive by + // `DbEntry` while the store lock is held. `sql_str.as_ptr()` is valid + // for `sql_len_i32` bytes until this call returns; SQLite prepares the + // statement synchronously and stores its own compiled representation. let rc = unsafe { rusqlite::ffi::sqlite3_prepare_v2( entry.raw, - sql as *const c_char, - sql_len, + sql_str.as_ptr() as *const c_char, + sql_len_i32, &mut raw_stmt, std::ptr::null_mut(), ) }; if rc != rusqlite::ffi::SQLITE_OK { + // SAFETY: sqlite3_errmsg returns a NUL-terminated string owned by + // the live connection for the duration of this call. let errmsg = unsafe { let p = rusqlite::ffi::sqlite3_errmsg(entry.raw); if p.is_null() { @@ -207,13 +320,23 @@ pub extern "C" fn jerboa_sqlite_prepare( let id = next_id(); drop(store); - STMT_STORE.lock().unwrap().insert( + let Some(mut stmts) = lock_stmt_store() else { + // SAFETY: `raw_stmt` came from a successful prepare and has not + // been transferred into the handle store. + unsafe { + rusqlite::ffi::sqlite3_finalize(raw_stmt); + } + return -1; + }; + stmts.insert( id, StmtEntry { raw: raw_stmt, db_handle, }, ); + // SAFETY: `stmt_handle` was checked non-null and points to + // caller-owned storage for one `u64`. unsafe { *stmt_handle = id; } @@ -226,7 +349,9 @@ pub extern "C" fn jerboa_sqlite_prepare( #[no_mangle] pub extern "C" fn jerboa_sqlite_bind_int(stmt_handle: u64, index: i32, value: i64) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -234,6 +359,8 @@ pub extern "C" fn jerboa_sqlite_bind_int(stmt_handle: u64, index: i32, value: i6 return -1; } }; + // SAFETY: statement handles are created by sqlite3_prepare_v2 and kept + // live in the store while this lock is held. let rc = unsafe { rusqlite::ffi::sqlite3_bind_int64(entry.raw, index, value) }; if rc != rusqlite::ffi::SQLITE_OK { -1 @@ -246,7 +373,9 @@ pub extern "C" fn jerboa_sqlite_bind_int(stmt_handle: u64, index: i32, value: i6 #[no_mangle] pub extern "C" fn jerboa_sqlite_bind_double(stmt_handle: u64, index: i32, value: f64) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -254,6 +383,8 @@ pub extern "C" fn jerboa_sqlite_bind_double(stmt_handle: u64, index: i32, value: return -1; } }; + // SAFETY: statement handles are created by sqlite3_prepare_v2 and kept + // live in the store while this lock is held. let rc = unsafe { rusqlite::ffi::sqlite3_bind_double(entry.raw, index, value) }; if rc != rusqlite::ffi::SQLITE_OK { -1 @@ -271,24 +402,17 @@ pub extern "C" fn jerboa_sqlite_bind_text( text_len: usize, ) -> i32 { ffi_wrap(|| { - if text.is_null() { - set_last_error("null SQLite text pointer".into()); - return -1; - } - let text_len = match i32::try_from(text_len) { - Ok(len) => len as u64, - Err(_) => { - set_last_error("SQLite text exceeds INT_MAX".into()); - return -1; - } + let text_len_i32 = match sqlite_len_i32(text_len, "SQLite text") { + Some(len) => len, + None => return -1, }; - if std::str::from_utf8(unsafe { std::slice::from_raw_parts(text, text_len as usize) }) - .is_err() - { - set_last_error("invalid UTF-8 SQLite text".into()); + let text_str = match ffi_utf8(text, text_len, "SQLite text") { + Ok(text) => text, + Err(()) => return -1, + }; + let Some(store) = lock_stmt_store() else { return -1; - } - let store = STMT_STORE.lock().unwrap(); + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -296,12 +420,15 @@ pub extern "C" fn jerboa_sqlite_bind_text( return -1; } }; + // SAFETY: `entry.raw` is live while the statement-store lock is held. + // `text_str.as_ptr()` is readable for `text_len_i32` bytes, and + // SQLITE_TRANSIENT tells SQLite to copy the bytes before returning. let rc = unsafe { rusqlite::ffi::sqlite3_bind_text64( entry.raw, index, - text as *const c_char, - text_len, + text_str.as_ptr() as *const c_char, + text_len_i32 as u64, rusqlite::ffi::SQLITE_TRANSIENT(), rusqlite::ffi::SQLITE_UTF8 as u8, ) @@ -322,18 +449,17 @@ pub extern "C" fn jerboa_sqlite_bind_blob( data_len: usize, ) -> i32 { ffi_wrap(|| { - if data.is_null() { - set_last_error("null SQLite blob pointer".into()); + let data_len_i32 = match sqlite_len_i32(data_len, "SQLite blob") { + Some(len) => len, + None => return -1, + }; + let data = match ffi_bytes(data, data_len, "SQLite blob") { + Ok(data) => data, + Err(()) => return -1, + }; + let Some(store) = lock_stmt_store() else { return -1; - } - let data_len = match i32::try_from(data_len) { - Ok(len) => len as u64, - Err(_) => { - set_last_error("SQLite blob exceeds INT_MAX".into()); - return -1; - } }; - let store = STMT_STORE.lock().unwrap(); let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -341,12 +467,15 @@ pub extern "C" fn jerboa_sqlite_bind_blob( return -1; } }; + // SAFETY: `entry.raw` is live while the statement-store lock is held. + // `data.as_ptr()` is readable for `data_len_i32` bytes, and + // SQLITE_TRANSIENT tells SQLite to copy the bytes before returning. let rc = unsafe { rusqlite::ffi::sqlite3_bind_blob64( entry.raw, index, - data as *const _, - data_len, + data.as_ptr() as *const _, + data_len_i32 as u64, rusqlite::ffi::SQLITE_TRANSIENT(), ) }; @@ -361,7 +490,9 @@ pub extern "C" fn jerboa_sqlite_bind_blob( #[no_mangle] pub extern "C" fn jerboa_sqlite_bind_null(stmt_handle: u64, index: i32) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -369,6 +500,8 @@ pub extern "C" fn jerboa_sqlite_bind_null(stmt_handle: u64, index: i32) -> i32 { return -1; } }; + // SAFETY: statement handles are created by sqlite3_prepare_v2 and kept + // live in the store while this lock is held. let rc = unsafe { rusqlite::ffi::sqlite3_bind_null(entry.raw, index) }; if rc != rusqlite::ffi::SQLITE_OK { -1 @@ -384,7 +517,9 @@ pub extern "C" fn jerboa_sqlite_bind_null(stmt_handle: u64, index: i32) -> i32 { #[no_mangle] pub extern "C" fn jerboa_sqlite_step(stmt_handle: u64) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -392,6 +527,8 @@ pub extern "C" fn jerboa_sqlite_step(stmt_handle: u64) -> i32 { return -1; } }; + // SAFETY: statement handles are created by sqlite3_prepare_v2 and kept + // live in the store while this lock is held. let rc = unsafe { rusqlite::ffi::sqlite3_step(entry.raw) }; match rc { rusqlite::ffi::SQLITE_ROW => 100, @@ -409,9 +546,14 @@ pub extern "C" fn jerboa_sqlite_step(stmt_handle: u64) -> i32 { #[no_mangle] pub extern "C" fn jerboa_sqlite_column_count(stmt_handle: u64) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; match store.get(&stmt_handle) { - Some(e) => unsafe { rusqlite::ffi::sqlite3_column_count(e.raw) }, + Some(e) => { + // SAFETY: statement handles are live while the store lock is held. + unsafe { rusqlite::ffi::sqlite3_column_count(e.raw) } + } None => -1, } }) @@ -421,9 +563,14 @@ pub extern "C" fn jerboa_sqlite_column_count(stmt_handle: u64) -> i32 { #[no_mangle] pub extern "C" fn jerboa_sqlite_column_type(stmt_handle: u64, col: i32) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; match store.get(&stmt_handle) { - Some(e) => unsafe { rusqlite::ffi::sqlite3_column_type(e.raw, col) }, + Some(e) => { + // SAFETY: statement handles are live while the store lock is held. + unsafe { rusqlite::ffi::sqlite3_column_type(e.raw, col) } + } None => -1, } }) @@ -431,20 +578,34 @@ pub extern "C" fn jerboa_sqlite_column_type(stmt_handle: u64, col: i32) -> i32 { #[no_mangle] pub extern "C" fn jerboa_sqlite_column_int(stmt_handle: u64, col: i32) -> i64 { - let store = STMT_STORE.lock().unwrap(); - match store.get(&stmt_handle) { - Some(e) => unsafe { rusqlite::ffi::sqlite3_column_int64(e.raw, col) }, - None => 0, - } + ffi_wrap_i64(|| { + let Some(store) = lock_stmt_store() else { + return 0; + }; + match store.get(&stmt_handle) { + Some(e) => { + // SAFETY: statement handles are live while the store lock is held. + unsafe { rusqlite::ffi::sqlite3_column_int64(e.raw, col) } + } + None => 0, + } + }) } #[no_mangle] pub extern "C" fn jerboa_sqlite_column_double(stmt_handle: u64, col: i32) -> f64 { - let store = STMT_STORE.lock().unwrap(); - match store.get(&stmt_handle) { - Some(e) => unsafe { rusqlite::ffi::sqlite3_column_double(e.raw, col) }, - None => 0.0, - } + ffi_wrap_f64(|| { + let Some(store) = lock_stmt_store() else { + return 0.0; + }; + match store.get(&stmt_handle) { + Some(e) => { + // SAFETY: statement handles are live while the store lock is held. + unsafe { rusqlite::ffi::sqlite3_column_double(e.raw, col) } + } + None => 0.0, + } + }) } /// Get text column value. Copies to output buffer. @@ -457,30 +618,24 @@ pub extern "C" fn jerboa_sqlite_column_text( output_len: *mut usize, ) -> i32 { ffi_wrap(|| { - if output.is_null() || output_len.is_null() { + let Some(store) = lock_stmt_store() else { return -1; - } - let store = STMT_STORE.lock().unwrap(); + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => return -1, }; + // SAFETY: statement handles are live while the store lock is held. The + // returned SQLite text pointer is valid until the next statement step, + // reset, finalize, or type conversion; we copy it before returning. let ptr = unsafe { rusqlite::ffi::sqlite3_column_text(entry.raw, col) }; if ptr.is_null() { - unsafe { - *output_len = 0; - } - return 0; + return copy_to_output(&[], output, output_max, output_len); } + // SAFETY: SQLite returns a NUL-terminated UTF-8/UTF-16 converted byte + // string for sqlite3_column_text. We immediately copy the bytes. let cstr = unsafe { CStr::from_ptr(ptr as *const _) }; - let bytes = cstr.to_bytes(); - let copy_len = bytes.len().min(output_max); - let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) }; - out[..copy_len].copy_from_slice(&bytes[..copy_len]); - unsafe { - *output_len = bytes.len(); - } - 0 + copy_to_output(cstr.to_bytes(), output, output_max, output_len) }) } @@ -494,30 +649,35 @@ pub extern "C" fn jerboa_sqlite_column_blob( output_len: *mut usize, ) -> i32 { ffi_wrap(|| { - if output.is_null() || output_len.is_null() { + let Some(store) = lock_stmt_store() else { return -1; - } - let store = STMT_STORE.lock().unwrap(); + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => return -1, }; + // SAFETY: statement handles are live while the store lock is held. The + // returned blob pointer is valid until the next statement step/reset or + // finalize; we copy it before returning. let blob_ptr = unsafe { rusqlite::ffi::sqlite3_column_blob(entry.raw, col) }; - let blob_len = unsafe { rusqlite::ffi::sqlite3_column_bytes(entry.raw, col) } as usize; + // SAFETY: same live statement invariant as above; SQLite reports the + // byte count for the current column value. + let blob_len = unsafe { rusqlite::ffi::sqlite3_column_bytes(entry.raw, col) }; + let Ok(blob_len) = usize::try_from(blob_len) else { + set_last_error("SQLite returned negative blob length".into()); + return -1; + }; if blob_ptr.is_null() || blob_len == 0 { - unsafe { - *output_len = 0; - } - return 0; + return copy_to_output(&[], output, output_max, output_len); } - let copy_len = blob_len.min(output_max); - let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) }; - let src = unsafe { std::slice::from_raw_parts(blob_ptr as *const u8, blob_len) }; - out[..copy_len].copy_from_slice(&src[..copy_len]); - unsafe { - *output_len = blob_len; + if blob_len > MAX_C_ABI_SLICE_LEN { + set_last_error("SQLite blob length exceeds Rust slice limit".into()); + return -1; } - 0 + // SAFETY: SQLite reported `blob_len` bytes for non-null `blob_ptr`, + // valid under the statement lifetime described above. + let src = unsafe { std::slice::from_raw_parts(blob_ptr as *const u8, blob_len) }; + copy_to_output(src, output, output_max, output_len) }) } @@ -531,30 +691,24 @@ pub extern "C" fn jerboa_sqlite_column_name( output_len: *mut usize, ) -> i32 { ffi_wrap(|| { - if output.is_null() || output_len.is_null() { + let Some(store) = lock_stmt_store() else { return -1; - } - let store = STMT_STORE.lock().unwrap(); + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => return -1, }; + // SAFETY: statement handles are live while the store lock is held. The + // returned static/statement-owned column-name pointer is copied before + // returning. let ptr = unsafe { rusqlite::ffi::sqlite3_column_name(entry.raw, col) }; if ptr.is_null() { - unsafe { - *output_len = 0; - } - return 0; + return copy_to_output(&[], output, output_max, output_len); } + // SAFETY: SQLite returns a NUL-terminated column-name string for a live + // prepared statement. We immediately copy its bytes. let cstr = unsafe { CStr::from_ptr(ptr) }; - let bytes = cstr.to_bytes(); - let copy_len = bytes.len().min(output_max); - let out = unsafe { std::slice::from_raw_parts_mut(output, output_max) }; - out[..copy_len].copy_from_slice(&bytes[..copy_len]); - unsafe { - *output_len = bytes.len(); - } - 0 + copy_to_output(cstr.to_bytes(), output, output_max, output_len) }) } @@ -563,7 +717,9 @@ pub extern "C" fn jerboa_sqlite_column_name( #[no_mangle] pub extern "C" fn jerboa_sqlite_reset(stmt_handle: u64) -> i32 { ffi_wrap(|| { - let store = STMT_STORE.lock().unwrap(); + let Some(store) = lock_stmt_store() else { + return -1; + }; let entry = match store.get(&stmt_handle) { Some(e) => e, None => { @@ -571,6 +727,8 @@ pub extern "C" fn jerboa_sqlite_reset(stmt_handle: u64) -> i32 { return -1; } }; + // SAFETY: statement handles are created by sqlite3_prepare_v2 and kept + // live in the store while this lock is held. let rc = unsafe { rusqlite::ffi::sqlite3_reset(entry.raw) }; if rc != rusqlite::ffi::SQLITE_OK { -1 @@ -582,16 +740,24 @@ pub extern "C" fn jerboa_sqlite_reset(stmt_handle: u64) -> i32 { #[no_mangle] pub extern "C" fn jerboa_sqlite_finalize(stmt_handle: u64) -> i32 { - ffi_wrap(|| match STMT_STORE.lock().unwrap().remove(&stmt_handle) { - Some(entry) => { - unsafe { - rusqlite::ffi::sqlite3_finalize(entry.raw); + ffi_wrap(|| { + let Some(mut store) = lock_stmt_store() else { + return -1; + }; + match store.remove(&stmt_handle) { + Some(entry) => { + // SAFETY: statement handles are inserted only after successful + // sqlite3_prepare_v2 and removed at most once while the store + // mutex serializes access. + unsafe { + rusqlite::ffi::sqlite3_finalize(entry.raw); + }