Skip to main content

boatramp_core/
sql.rs

1//! A small, engine-agnostic SQL backend contract for the handler `sql` binding.
2//!
3//! The handler engine exposes a `sql` capability to guests, but *which* database
4//! serves it is a deployment detail — the same seam as the blob ([`Storage`])
5//! and KV ([`kv::KvStore`]) backends. [`SqlBackend`] is that seam, so the guest
6//! interface and the server UX stay identical across single-node and cluster
7//! deployments. The one implementation is **libsql** (SQLite-compatible): an
8//! embedded file per site (single-node) or a sqld namespace per site (cluster,
9//! read-replicable) — one engine, the split being config, not a backend choice.
10//!
11//! Each backend instance is **scoped to one site**; the engine/transport and the
12//! per-site database mapping live behind the trait, so a handler can never
13//! address another site's data ([`crate::deploy`]-style isolation).
14//!
15//! The contract is deliberately tiny — `begin` a transaction, `query`/`execute`
16//! within it, then `commit`/`rollback` — and the trait keeps the engine
17//! decoupled from libsql's specifics (and lets tests substitute a fake). The
18//! handler engine wraps each invocation in one transaction (commit on success,
19//! roll back on trap/error).
20//!
21//! [`Storage`]: crate::Storage
22//! [`kv::KvStore`]: crate::kv::KvStore
23
24use std::sync::Arc;
25
26use async_trait::async_trait;
27
28/// A single SQL value. `Boolean` is carried as a distinct class (so a guest can
29/// express one and a strictly-typed engine could bind a native `BOOL`); libsql,
30/// being SQLite-family, maps it to `0`/`1`.
31#[derive(Debug, Clone, PartialEq)]
32pub enum SqlValue {
33    /// SQL `NULL`.
34    Null,
35    /// A boolean (a native `BOOL` where the engine has one, else `0`/`1`).
36    Boolean(bool),
37    /// A 64-bit signed integer.
38    Integer(i64),
39    /// A 64-bit float.
40    Real(f64),
41    /// UTF-8 text.
42    Text(String),
43    /// A byte string.
44    Blob(Vec<u8>),
45    /// A JSON document (its JSON text) — the portable "JSON document" value, bound to
46    /// each engine's canonical document type: `jsonb` on Postgres (validated,
47    /// canonical, operator- and index-capable), the binary `JSON` type on MySQL, text
48    /// (json1) on SQLite. So a guest writes a `jsonb`/`JSON` column with no `::` cast,
49    /// AND the value **type-unifies** with such a column in `COALESCE`/comparison/`||`,
50    /// not only on INSERT. Note Postgres `jsonb` validates on write (malformed JSON is
51    /// rejected). Postgres's raw-text `json` type is out of the portable model — use
52    /// raw SQL with an explicit `::json` cast for it. Read back as
53    /// [`Text`](Self::Text) (the engines stringify JSON on the way out).
54    Json(String),
55}
56
57/// The rows a [`SqlTransaction::query`] returned: column names plus row-major
58/// cells (each row's length equals `columns.len()`).
59#[derive(Debug, Clone, Default, PartialEq)]
60pub struct SqlRows {
61    /// Column names, in result order.
62    pub columns: Vec<String>,
63    /// Rows, each a vector of cells aligned to `columns`.
64    pub rows: Vec<Vec<SqlValue>>,
65}
66
67/// Why a SQL operation failed.
68#[derive(Debug, Clone, thiserror::Error)]
69pub enum SqlError {
70    /// The statement could not be parsed or planned.
71    #[error("sql syntax error: {0}")]
72    Syntax(String),
73    /// A constraint (unique, type, foreign key, ...) was violated.
74    #[error("sql constraint error: {0}")]
75    Constraint(String),
76    /// Any other backend/transport error (I/O, connection, ...).
77    #[error("sql error: {0}")]
78    Other(String),
79    /// The backend is **not ready yet** — a host-MANAGED database that is still starting,
80    /// recovering, or has no healthy replica (distinct from a permanent config/transport error).
81    /// This is **transient**: the caller should retry, or return a retryable `503` — never treat it
82    /// as a permanent "not configured"/"not granted" failure. Emitted only by the managed-compute
83    /// resolver; an external/local backend's outages stay [`Other`](SqlError::Other) (per-DB
84    /// resilience, no readiness gate).
85    #[error("sql backend not ready: {0}")]
86    Unavailable(String),
87}
88
89impl SqlError {
90    /// Wrap any displayable error as [`SqlError::Other`].
91    pub fn other<E: std::fmt::Display>(err: E) -> Self {
92        Self::Other(err.to_string())
93    }
94
95    /// Wrap a displayable error as [`SqlError::Unavailable`] — a transient "managed backend not
96    /// ready yet" condition (still starting / recovering / no healthy replica).
97    pub fn unavailable<E: std::fmt::Display>(err: E) -> Self {
98        Self::Unavailable(err.to_string())
99    }
100
101    /// Whether this is the transient [`Unavailable`](SqlError::Unavailable) not-ready condition
102    /// (the caller may retry or gate with a retryable `503`).
103    pub fn is_unavailable(&self) -> bool {
104        matches!(self, Self::Unavailable(_))
105    }
106}
107
108/// Operator-configured SQL session-context GUC names carrying the host-resolved tenant (and the
109/// anonymous session) to an app's **Postgres RLS**, so its policies (`current_setting(name, true)`)
110/// mirror boatramp's injected tenancy predicate as a defense-in-depth backstop. Set on a managed /
111/// external SQL binding (the `rls_session` flag + these names, e.g. `app.tenant_id`). **Postgres
112/// only** — the backstop is the `current_setting` RLS pattern; a libsql/MySQL backend leaves these
113/// unset. The guest can NEVER set them itself (the reserved-write guard blocks the configured names,
114/// [`reject_reserved_session_writes`]); the host derives the value from the SAME resolution the
115/// injected predicate uses (own / target / session) or, for a posture-vetted `all` write, from the
116/// row/statement being written — the DB's `WITH CHECK` / `USING` is the final arbiter of a mismatch.
117#[derive(Debug, Clone)]
118pub struct RlsGuc {
119    /// The GUC carrying the resolved TENANT (e.g. `app.tenant_id`).
120    pub tenant: String,
121    /// The GUC carrying the anonymous SESSION id (e.g. `app.session_id`), if the operator uses one.
122    pub session: Option<String>,
123    /// A reserved, host-controlled sentinel written to [`Self::tenant`] on an **`all`-scoped READ**
124    /// (v0.4.21) — a value the operator guarantees can never be a real tenant id, so a table that
125    /// opts in with `USING (tenant_id = current_setting(name, true) OR current_setting(name, true) =
126    /// '<marker>')` opens cross-tenant for the audited `all` twins while every other table (and every
127    /// write) stays strict. `None` ⇒ `all` reads leave the GUC untouched (v0.4.20 behavior:
128    /// fail-closed). The guest can never set it — the whole [`Self::tenant`] namespace is reserved.
129    pub all_marker: Option<String>,
130}
131
132impl RlsGuc {
133    /// The configured GUC names, lowercased — the EXTRA reserved keys a guest may not set (on top of
134    /// the always-reserved `boatramp.*` / `@boatramp_*`), so a guest can't forge the RLS backstop.
135    pub fn reserved_names(&self) -> Vec<String> {
136        let mut v = vec![self.tenant.to_ascii_lowercase()];
137        if let Some(s) = &self.session {
138            v.push(s.to_ascii_lowercase());
139        }
140        v
141    }
142}
143
144/// Render a **transaction-local Postgres** GUC set (`SELECT set_config($1, $2, true)`). Both the
145/// setting NAME and the VALUE are BOUND parameters (never interpolated), so a dotted operator name
146/// like `app.tenant_id` and any value are injection-safe; the `true` scopes the setting to the
147/// current transaction (auto-cleared at COMMIT/ROLLBACK, like the `boatramp.project`/`site` context).
148/// The value is bound as TEXT (`set_config`'s argument type); the operator's RLS policy casts if its
149/// key column isn't text. Postgres only — callers gate on [`Dialect::Postgres`].
150pub fn render_set_local_guc(name: &str, value: &SqlValue) -> (String, Vec<SqlValue>) {
151    let text = match value {
152        SqlValue::Text(s) => s.clone(),
153        SqlValue::Integer(i) => i.to_string(),
154        SqlValue::Boolean(b) => b.to_string(),
155        SqlValue::Real(r) => r.to_string(),
156        // A tenant/session key is realistically text or an integer; anything else (blob/json/null)
157        // has no meaningful GUC text — bind empty so RLS `= current_setting(...)` denies (fail-safe).
158        SqlValue::Blob(_) | SqlValue::Null | SqlValue::Json(_) => String::new(),
159    };
160    (
161        "SELECT set_config(?1, ?2, true)".to_string(),
162        vec![SqlValue::Text(name.to_string()), SqlValue::Text(text)],
163    )
164}
165
166/// The SQL dialect a backend speaks. The `orm` compiler is `?N`-portable for almost
167/// everything (the backend rewrites the placeholders), and only consults this for the
168/// handful of constructs whose *syntax* genuinely differs across engines — currently JSON
169/// extraction (`json_extract(...)` on SQLite/MySQL vs `#>>` on Postgres).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum Dialect {
172    /// SQLite family (libsql). The default.
173    #[default]
174    Sqlite,
175    Postgres,
176    Mysql,
177}
178
179/// A per-site SQL backend (libsql — a local file or a remote sqld namespace).
180///
181/// One instance serves one site. The handler engine calls [`begin`] once per
182/// invocation that uses SQL and drives the resulting [`SqlTransaction`] to a
183/// commit (on a successful response) or rollback (on trap/error).
184///
185/// [`begin`]: SqlBackend::begin
186#[async_trait]
187pub trait SqlBackend: Send + Sync {
188    /// The SQL dialect this backend speaks — used by the `orm` compiler for the few
189    /// dialect-divergent constructs (e.g. JSON extraction). Defaults to SQLite-family
190    /// (libsql); the Postgres/MySQL backends override it.
191    fn dialect(&self) -> Dialect {
192        Dialect::Sqlite
193    }
194
195    /// The operator-configured RLS session-GUC names ([`RlsGuc`]) this backend carries, or `None`
196    /// (the common case). When `Some` **and** [`dialect`](Self::dialect) is
197    /// [`Postgres`](Dialect::Postgres), the handler `sql`/`orm` binding sets the host-resolved tenant
198    /// (own/target/session) per transaction, and the row's tenant per `all` write, via
199    /// [`render_set_local_guc`], so an app's RLS mirrors the injected predicate. The guest can never
200    /// set these itself ([`reject_reserved_session_writes`] blocks the configured names).
201    fn rls_guc(&self) -> Option<&RlsGuc> {
202        None
203    }
204
205    /// Open a new read-write transaction. Backends are free to draw the
206    /// underlying connection from a pool, a fresh embedded connection, or a
207    /// remote session. Writes always land on the primary.
208    async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError>;
209
210    /// Open a transaction for a **read-only** invocation, which a backend
211    /// configured with a read replica may route to that replica (separate read
212    /// vs write endpoint: reads → replicas, writes →
213    /// primary). A replica may lag the primary, so such reads are
214    /// **eventually consistent**; issuing a write on this transaction is a
215    /// caller error (it hits the read endpoint, which a replica rejects).
216    ///
217    /// The default has no replica and simply opens a normal transaction, so
218    /// single-node and replica-less deployments behave identically.
219    async fn begin_read_only(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
220        self.begin().await
221    }
222
223    /// Run a multi-statement SQL **script** as one unit (the simple-query protocol),
224    /// for operator migrations: `CREATE EXTENSION` and long chains of DDL/DML that the
225    /// parameterized per-statement path can't express. Only the external
226    /// Postgres/MySQL backends implement it (the per-site libsql backend rejects it);
227    /// it is an operator tool, not a guest capability.
228    async fn run_script(&self, _sql: &str) -> Result<(), SqlError> {
229        Err(SqlError::Other(
230            "this database does not support running a raw SQL script".into(),
231        ))
232    }
233
234    /// Run one row-returning statement directly, in its own short-lived read-only
235    /// transaction — backs the operator `sql query`. The default composes the existing
236    /// transaction methods, so every backend supports it.
237    async fn run_query(&self, sql: &str) -> Result<SqlRows, SqlError> {
238        let mut tx = self.begin_read_only().await?;
239        let result = tx.query(sql, &[]).await;
240        // Read-only: always roll back so nothing lingers and no write can slip through.
241        let _ = tx.rollback().await;
242        result
243    }
244
245    /// Whether this backend injects a **reserved** boatramp session context
246    /// (`rls_session` — the `boatramp.project` / `boatramp.site` GUC on Postgres, or
247    /// the `@boatramp_project` / `@boatramp_site` MySQL session var) that an app's
248    /// row-level-security policy keys on. Default `false`.
249    ///
250    /// When `true`, the guest `sql` binding must **refuse** any guest statement that
251    /// would set/reset those reserved keys (see [`reject_reserved_session_writes`]):
252    /// otherwise a hostile guest could spoof its injected tenant and defeat the app's
253    /// RLS. This is a security signal, not a routing one — see the `rls_session` doc for
254    /// the trust model (the real isolation boundary is the per-tenant database + role).
255    fn injects_session_context(&self) -> bool {
256        false
257    }
258}
259
260/// Reject a guest SQL statement that would set or reset a **boatramp-reserved**
261/// session key — the `boatramp.*` GUC (Postgres) or an `@boatramp_*` user variable
262/// (MySQL). Used by the guest `sql` binding when the backend
263/// [`injects_session_context`](SqlBackend::injects_session_context): with `rls_session`
264/// on, boatramp injects the request's tenant into those keys for the app's RLS, so a
265/// guest that could overwrite them would spoof its tenant and defeat that RLS.
266///
267/// The statement is **tokenized with `sqlparser`** (the [`GenericDialect`], which lexes
268/// Postgres `"idents"`, MySQL backticks, `@vars`, and comments), not string-matched, so
269/// the earlier naive filter's bypasses are closed: comments and whitespace are normalized
270/// away (`SET/*x*/ boatramp.project`, `/*c*/SET …`), casing is folded, and a
271/// concatenated / non-literal `set_config` argument can no longer smuggle the reserved
272/// name past the check. The match stays **narrow** — ordinary app SQL
273/// (`SET statement_timeout = …`, `SET search_path TO …`, `set_config('search_path', …)`,
274/// a `SELECT` merely mentioning "set" or "boatramp.project") is untouched.
275///
276/// Recognised hostile forms (all rejected):
277///
278/// - a **deferred-execution or persistent-default** construct, whose body the tokenizer
279///   cannot see into and where a reserved-key write could hide: any **dollar-quoted**
280///   token (`$$…$$` / `$tag$…$tag$` — a `DO` block, a routine body, or a string literal),
281///   a leading `DO` / `CALL`, a `CREATE`/`ALTER … FUNCTION|PROCEDURE`, or an
282///   `ALTER ROLE|DATABASE|USER|SYSTEM … boatramp.*`. A guest on the RLS path has no
283///   legitimate need for procedural code, so these whole classes are refused (the
284///   operator keeps them via trusted operator SQL); `$1`/`$2` bind params are unaffected
285///   (they lex as placeholders, not dollar-quoted strings);
286/// - a statement whose leading keyword is `SET` / `SET SESSION` / `SET LOCAL` /
287///   `RESET` / `DISCARD` whose target is a `boatramp.*` GUC or an `@boatramp_*` var
288///   (`RESET ALL` / `DISCARD ALL` reset custom GUCs too, so they are refused);
289/// - **any** `@boatramp_*` MySQL user-var token appearing *anywhere* in the statement
290///   — MySQL writes it not only as the leading `SET` target but after a comma
291///   (`SET @x=1, @boatramp_project=…`, incl. `:=`) or via `SELECT … INTO @boatramp_*`
292///   (no `SET` at all); the reserved namespace is refused position-independently
293///   (Postgres has no such token and a MySQL app never names the reserved var);
294/// - any `set_config(<arg1>, …)` call — anywhere, incl. inside a `SELECT` — whose first
295///   argument is a single-quoted string literal naming `boatramp` / `boatramp.*`, **or**
296///   whose first argument is not a single simple string literal at all (a concatenation
297///   or other expression could construct `boatramp.*` at runtime; a legitimate caller
298///   always passes a plain literal such as `'search_path'`).
299///
300/// **Fail-closed:** if the tokenizer cannot lex the statement at all, it is rejected — a
301/// guest statement the guard cannot understand must not slip through while a session
302/// context is injected.
303///
304/// `extra_namespaces` are additional reserved GUC namespaces (lowercased leading segments, e.g.
305/// `app` for a configured `app.tenant_id`/`app.session_id` RLS GUC) — a guest must not set the
306/// operator's RLS session keys either, or it could forge the defense-in-depth backstop. Reserving
307/// the whole namespace (like `boatramp`) is the safe, simple superset.
308///
309/// Returns [`SqlError::Other`] with a clear message on a match, else `Ok(())`.
310pub fn reject_reserved_session_writes(
311    sql: &str,
312    extra_namespaces: &[String],
313) -> Result<(), SqlError> {
314    use sqlparser::dialect::GenericDialect;
315    use sqlparser::tokenizer::{Token, Tokenizer, Word};
316
317    /// The reserved GUC namespace (Postgres) — the first dotted segment, lowercased.
318    const GUC_NAMESPACE: &str = "boatramp";
319    /// The reserved MySQL user-var prefix, lowercased (an `@`-prefixed identifier).
320    const MYSQL_VAR_PREFIX: &str = "@boatramp_";
321
322    // A reserved GUC namespace (leading dotted segment): boatramp's own, or an operator RLS one.
323    let is_reserved_ns = |w: &str| w == GUC_NAMESPACE || extra_namespaces.iter().any(|n| n == w);
324
325    let refused = || {
326        Err(SqlError::Other(
327            "setting a reserved session key (boatramp.* / @boatramp_*, or the operator's \
328             rls_session tenant GUC) is not permitted from a handler: it is managed by \
329             rls_session and reserved for per-request tenant isolation"
330                .to_string(),
331        ))
332    };
333
334    // Tokenize with the generic dialect: it lexes Postgres `"idents"`, MySQL backticks,
335    // `@vars`, and both comment styles, folding comments/whitespace into `Whitespace`
336    // tokens we then drop. A statement the tokenizer rejects fails closed (below).
337    let dialect = GenericDialect {};
338    let Ok(raw) = Tokenizer::new(&dialect, sql).tokenize() else {
339        // Fail closed: an unlexable guest statement (e.g. an unbalanced backtick like
340        // `SET @`boatramp_project`=1`) must not pass while a context is injected.
341        return refused();
342    };
343
344    // Drop whitespace/comment tokens so a comment cannot split a keyword or hide inside
345    // a `set_config(` call. What remains are the statement's significant tokens.
346    let toks: Vec<&Token> = raw
347        .iter()
348        .filter(|t| !matches!(t, Token::Whitespace(_)))
349        .collect();
350
351    // The unquoted, case-folded text of a `Word` token, or `None` for any other token.
352    // Quoted identifiers keep their inner text (so a backtick-/double-quoted reserved
353    // name is still recognized), just without the quotes.
354    fn word_lc(tok: &Token) -> Option<String> {
355        match tok {
356            Token::Word(Word { value, .. }) => Some(value.to_ascii_lowercase()),
357            _ => None,
358        }
359    }
360
361    // Whether a case-folded identifier names a reserved key: the MySQL `@boatramp_*`
362    // user var, or (as the leading segment of a GUC) the `boatramp` namespace.
363    let is_reserved_var = |w: &str| w.starts_with(MYSQL_VAR_PREFIX);
364
365    // ---- (0) Deferred-execution / persistent-default constructs the token scan below
366    // cannot see into. `sqlparser` lexes a **dollar-quoted body** (`$$…$$`, `$tag$…$tag$`)
367    // — a `DO` block or a `CREATE FUNCTION` body — as ONE opaque `DollarQuotedString`
368    // token, and a **single-quoted** `DO`/function body as a `SingleQuotedString`, so a
369    // reserved-key write hidden inside either (`DO $$ … set_config('boatramp.project', …,
370    // false) … $$`) is invisible to (a)/(b). A guest on the RLS path has no legitimate
371    // need for procedural code, so under an injected context these whole classes are
372    // refused outright — the operator keeps them via operator SQL, which is trusted and
373    // unguarded. Refused:
374    //   - any dollar-quoted token (a `$$…$$` / `$tag$…$tag$` body or string literal);
375    //   - a leading `DO` (anonymous block) or `CALL` (invoke a procedure that could set it);
376    //   - `CREATE`/`ALTER … FUNCTION|PROCEDURE` (defines a body the tokenizer can't inspect);
377    //   - `ALTER ROLE|DATABASE|USER|SYSTEM … boatramp.*` (sets a *persistent* default GUC).
378    // `$1`/`$2` bind params lex as `Placeholder`, not `DollarQuotedString`, so ordinary
379    // parameterized guest queries are unaffected.
380    if toks
381        .iter()
382        .any(|t| matches!(t, Token::DollarQuotedString(_)))
383    {
384        return refused();
385    }
386
387    // ---- (0b) A reserved MySQL user var (`@boatramp_*`) appearing ANYWHERE. MySQL
388    // writes it not only via a leading `SET` but also mid-`SET` after a comma
389    // (`SET @x=1, @boatramp_project='v'` — a `:=` variant too) and via
390    // `SELECT … INTO @boatramp_project` (no `SET` keyword at all), none of which the
391    // leading-token check (a) sees. Postgres has no legitimate `@boatramp_*` token and
392    // a MySQL app never needs to name the reserved var, so a **position-independent**
393    // refusal (like the `set_config` scan) closes the whole family — comma-assign,
394    // `SELECT … INTO`, `:=`, and case/quote variants. ----
395    if toks
396        .iter()
397        .any(|t| word_lc(t).is_some_and(|w| is_reserved_var(&w)))
398    {
399        return refused();
400    }
401    {
402        let leading = toks.first().and_then(|t| word_lc(t));
403        let has_word = |w: &str| toks.iter().any(|t| word_lc(t).as_deref() == Some(w));
404        let names_reserved = || {
405            toks.iter()
406                .any(|t| word_lc(t).is_some_and(|w| is_reserved_ns(&w) || is_reserved_var(&w)))
407        };
408        match leading.as_deref() {
409            // Anonymous code block / procedure call / prepared-statement indirection:
410            // deferred execution the token scan can't see through. `DO`/`CALL` run a
411            // body; `PREPARE s FROM '<text>'` + `EXECUTE s` (MySQL, same pooled
412            // connection within one invocation) hides the reserved write inside a
413            // *string literal* — which we must NOT scan (a literal naming the key is
414            // legitimate data), so refuse the deferral construct instead. The guest
415            // `sql` binding parameterizes via bind params (`$1`/`?`), never SQL-level
416            // PREPARE/EXECUTE, so refusing these on the RLS path costs nothing.
417            Some("do") | Some("call") | Some("prepare") | Some("execute") => return refused(),
418            // Defining a routine (single- or dollar-quoted body) on the guest path.
419            Some("create") | Some("alter") if has_word("function") || has_word("procedure") => {
420                return refused()
421            }
422            // A persistent GUC default: `ALTER ROLE/DATABASE/USER/SYSTEM … SET boatramp.*`
423            // (scoped to those targets so an `ALTER TABLE`/`INDEX` isn't caught).
424            Some("alter")
425                if matches!(
426                    toks.get(1).and_then(|t| word_lc(t)).as_deref(),
427                    Some("role") | Some("database") | Some("user") | Some("system")
428                ) && names_reserved() =>
429            {
430                return refused()
431            }
432            _ => {}
433        }
434    }
435
436    // ---- (a) A leading SET / RESET / DISCARD targeting a reserved key. ----
437    if let Some(first) = toks.first().and_then(|t| word_lc(t)) {
438        match first.as_str() {
439            // DISCARD [ALL|…]: DISCARD ALL resets every session GUC (incl. ours); any
440            // DISCARD is a broad session reset, so refuse it outright under a context.
441            "discard" => return refused(),
442            "reset" => {
443                // `RESET boatramp.project` (target segment == namespace) or `RESET ALL`
444                // (clears custom GUCs too).
445                if let Some(target) = toks.get(1).and_then(|t| word_lc(t)) {
446                    if target == "all" || is_reserved_ns(&target) || is_reserved_var(&target) {
447                        return refused();
448                    }
449                }
450            }
451            "set" => {
452                // Skip an optional SESSION / LOCAL qualifier, then inspect the target.
453                let mut idx = 1;
454                if matches!(
455                    toks.get(idx).and_then(|t| word_lc(t)).as_deref(),
456                    Some("session") | Some("local")
457                ) {
458                    idx += 1;
459                }
460                if let Some(target) = toks.get(idx).and_then(|t| word_lc(t)) {
461                    // A GUC is `boatramp` `.` `project` (dotted); the MySQL var is the
462                    // single `@boatramp_*` word. Either way the first identifier decides.
463                    if is_reserved_ns(&target) || is_reserved_var(&target) {
464                        return refused();
465                    }
466                }
467            }
468            _ => {}
469        }
470    }
471
472    // ---- (b) A `set_config(<arg1>, …)` call anywhere (it can hide inside a SELECT, and
473    // more than one can appear). For each `set_config` word immediately followed by `(`,
474    // inspect the first argument: reject unless it is a single simple string literal that
475    // does NOT start with `boatramp.`. A concatenation/expression first arg is refused
476    // (it could build `boatramp.*` at runtime). ----
477    for (i, tok) in toks.iter().enumerate() {
478        if word_lc(tok).as_deref() != Some("set_config") {
479            continue;
480        }
481        // Must be a call: the next significant token is `(`.
482        if !matches!(toks.get(i + 1), Some(Token::LParen)) {
483            continue;
484        }
485        // The first argument token and the token following it.
486        let arg0 = toks.get(i + 2);
487        let after = toks.get(i + 3);
488        match (arg0, after) {
489            // A single simple **string literal** delimited by `,` or `)` — the only form
490            // a legitimate caller uses for the setting name (`set_config('search_path', …)`).
491            // Allow it iff it does not name the reserved GUC namespace. Note the generic
492            // dialect lexes a double-quoted `"…"` as a *delimited identifier* (a quoted
493            // `Word`), not a string literal, so it falls through to the catch-all below —
494            // a non-idiomatic double-quoted first arg is refused, which is fine.
495            (Some(Token::SingleQuotedString(s)), Some(Token::Comma | Token::RParen)) => {
496                let name = s.to_ascii_lowercase();
497                // A reserved namespace itself, or `<ns>.<anything>` (`.` as the boundary) — for
498                // boatramp's own namespace AND any operator RLS namespace (e.g. `app.tenant_id`).
499                let ns_of = name.split('.').next().unwrap_or(&name);
500                if is_reserved_ns(ns_of) {
501                    return refused();
502                }
503            }
504            // Anything else as the first argument (a concatenation, a function call, a
505            // quoted identifier, a bind param, an empty `()`, …) cannot be proven safe →
506            // refuse: a non-literal could construct `boatramp.*` at runtime.
507            _ => return refused(),
508        }
509    }
510
511    Ok(())
512}
513
514/// How a **preview** deployment's SQL database relates to the site's live one
515/// (operator policy; see the per-site/server config). The default is the safe,
516/// isolated choice.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
518pub enum PreviewSqlMode {
519    /// A fresh, empty database isolated from live (optionally seeded by an init
520    /// script). Can never read or clobber live data.
521    #[default]
522    Empty,
523    /// A consistent **copy** of the live database at branch time — realistic
524    /// data, but writes stay in the preview's copy.
525    Branch,
526    /// The site's **live** database, shared with production traffic. The preview
527    /// reads and writes real data — use only when that's intended.
528    Shared,
529}
530
531/// Resolves a site's named SQL databases to [`SqlBackend`]s — the seam the
532/// server's handler runtime uses to obtain a per-site database on demand
533/// (opening/caching it lazily). The concrete mapping (a libsql file per site,
534/// or a sqld namespace per site) lives behind this, so the server stays
535/// storage-agnostic.
536#[async_trait]
537pub trait SqlBackends: Send + Sync {
538    /// Open (or reuse) the database called `name` for `site` within tenant
539    /// `project` (the empty name is the site's default database). Per-tenant +
540    /// per-site isolation is the implementation's responsibility — a handler can
541    /// only ever reach its own project's site's data.
542    ///
543    /// `project` and `site` are **separately** validated by the implementation
544    /// and composed internally via
545    /// [`ProjectRef::qualified`](crate::project::ProjectRef::qualified) (the
546    /// reserved `default` project keeps the byte-identical, pre-project identity
547    /// for back-compat; any other project prefixes `"<project>/"`). Passing a
548    /// single already-composed `"<project>/<site>"` string as `site` would be
549    /// rejected — the two names are kept apart so each is validated on its own.
550    async fn database(
551        &self,
552        project: &str,
553        site: &str,
554        name: &str,
555    ) -> Result<Arc<dyn SqlBackend>, SqlError>;
556
557    /// Open (or reuse) the database for a **preview** deployment `preview` of
558    /// `site` within tenant `project`. The implementation applies its configured
559    /// [`PreviewSqlMode`]. The default is [`PreviewSqlMode::Empty`] — an isolated
560    /// database keyed by project+site+preview, so a preview can never touch live
561    /// state. The default composition qualifies `site` by `project` first, then
562    /// appends the trusted `_preview/{preview}` suffix (both from validated
563    /// parts), and delegates to [`database`](Self::database) under the reserved
564    /// `default` project so the already-qualified identity is not re-qualified.
565    async fn preview_database(
566        &self,
567        project: &str,
568        site: &str,
569        name: &str,
570        preview: &str,
571    ) -> Result<Arc<dyn SqlBackend>, SqlError> {
572        let qualified = crate::project::ProjectRef::new(project).qualified(site);
573        self.database(
574            crate::project::DEFAULT_PROJECT,
575            &format!("{qualified}/_preview/{preview}"),
576            name,
577        )
578        .await
579    }
580}
581
582/// The operator-facing SQL capability for a **managed** database: run a migration
583/// script or a single query against a compute-backed database boatramp runs, using
584/// its sealed managed credential (resolved server-side — the credential never leaves
585/// the node). Backs `POST /api/sql/{db}/{exec,query}` and the `boatramp sql` CLI.
586/// Distinct from [`SqlBackends`] (the per-site guest binding): this is a
587/// project-scoped **operator** tool, admin-gated at the API.
588#[async_trait]
589pub trait OperatorSql: Send + Sync {
590    /// Run a multi-statement migration `script` against managed database `db` in
591    /// `project` (the simple-query protocol — `CREATE EXTENSION` + chained DDL).
592    async fn exec_script(&self, project: &str, db: &str, script: &str) -> Result<(), SqlError>;
593
594    /// Run one row-returning `sql` statement against managed database `db`.
595    async fn query(&self, project: &str, db: &str, sql: &str) -> Result<SqlRows, SqlError>;
596
597    /// Probe every replica of managed database `db`'s compute workload — an **active**
598    /// TCP reachability check, independent of the stored health flag. Lets an operator
599    /// tell "the DB is actually down" (`tcp_reachable: false`) from "the DB is up but
600    /// the endpoint resolver won't serve it" (`tcp_reachable: true, healthy: false` —
601    /// the reachable-but-not-served signature). Never runs a query or presents a
602    /// credential; it only opens (and immediately drops) a TCP connection.
603    async fn ping(&self, project: &str, db: &str) -> Result<Vec<SqlPingReplica>, SqlError>;
604}
605
606/// One replica's reachability, returned by [`OperatorSql::ping`].
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub struct SqlPingReplica {
609    /// The replica's endpoint (`host:port`).
610    pub endpoint: String,
611    /// The stored health flag (what the endpoint resolver gates serving on).
612    pub healthy: bool,
613    /// The replica's lifecycle phase (`running` / `zero`).
614    pub phase: String,
615    /// Whether a TCP connection to the endpoint succeeded just now.
616    pub tcp_reachable: bool,
617}
618
619/// Tear down a deleted tenant's **managed** databases — the delete-time counterpart
620/// to the create-time provisioning of a per-tenant managed `sql` binding. When a
621/// project (or site) is deleted through the control plane, boatramp drops *that
622/// tenant's* databases + login roles + sealed credentials — exactly that tenant's,
623/// nothing else — so a deleted tenant leaves no orphaned data plane behind.
624///
625/// **Best-effort by contract.** Both methods return `()`: a deprovision failure is
626/// the implementation's to log, and must never block or fail the delete it hangs off
627/// (an orphaned database is a lesser evil than a delete that can't complete). The
628/// reserved `default` project is never touched — its "tenant" is the whole
629/// single-tenant install. Wired by the node when a compute-backed managed database
630/// exists; the delete handlers call it after the store delete succeeds.
631#[async_trait]
632pub trait TenantDeprovisioner: Send + Sync {
633    /// Deprovision every `Project`-scoped managed binding for the deleted `project`.
634    async fn deprovision_project(&self, project: &str);
635
636    /// Deprovision every `Site`-scoped managed binding for the deleted `site` of
637    /// `project`.
638    async fn deprovision_site(&self, project: &str, site: &str);
639}
640
641/// One transaction's worth of work. Dropping it without [`commit`] must leave
642/// the database unchanged (the engine rolls back).
643///
644/// [`commit`]: SqlTransaction::commit
645#[async_trait]
646pub trait SqlTransaction: Send {
647    /// Run a row-returning statement (e.g. `SELECT`), binding `params` to the
648    /// statement's positional placeholders.
649    async fn query(&mut self, sql: &str, params: &[SqlValue]) -> Result<SqlRows, SqlError>;
650
651    /// Run a non-row statement (`INSERT`/`UPDATE`/`DELETE`/DDL), binding
652    /// `params`. Returns the number of affected rows (0 for DDL).
653    async fn execute(&mut self, sql: &str, params: &[SqlValue]) -> Result<u64, SqlError>;
654
655    /// Commit the transaction.
656    async fn commit(self: Box<Self>) -> Result<(), SqlError>;
657
658    /// Roll the transaction back.
659    async fn rollback(self: Box<Self>) -> Result<(), SqlError>;
660}
661
662#[cfg(test)]
663mod reserved_session_writes_tests {
664    use super::reject_reserved_session_writes as check;
665
666    fn rejected(sql: &str) -> bool {
667        check(sql, &[]).is_err()
668    }
669
670    /// Rejected when the operator's RLS GUC namespace (`app`) is reserved.
671    fn rejected_with_app(sql: &str) -> bool {
672        check(sql, &["app".to_string()]).is_err()
673    }
674
675    #[test]
676    fn operator_rls_guc_is_rejected_only_when_its_namespace_is_reserved() {
677        // With `app` reserved (a configured `app.tenant_id` RLS GUC), a guest cannot forge it
678        // via any form — direct SET, set_config, or a RESET of the namespace.
679        assert!(rejected_with_app("SET app.tenant_id = 'victim'"));
680        assert!(rejected_with_app("set local app.tenant_id = 'victim'"));
681        assert!(rejected_with_app(
682            "SELECT set_config('app.tenant_id','victim',false)"
683        ));
684        assert!(rejected_with_app("RESET app.tenant_id"));
685        // Without the reservation, an ordinary `app.*` set is NOT the guard's business (only
686        // boatramp's own namespace is always reserved).
687        assert!(!rejected("SET app.tenant_id = 'x'"));
688        assert!(!rejected("SELECT set_config('app.tenant_id','x',true)"));
689        // The always-reserved boatramp namespace is still blocked regardless of extras.
690        assert!(rejected_with_app("SET boatramp.project = 'x'"));
691    }
692
693    // ---- hostile statements that spoof the injected tenant MUST be rejected ----
694
695    #[test]
696    fn set_config_on_reserved_guc_is_rejected() {
697        assert!(rejected(
698            "SELECT set_config('boatramp.project','victim',false)"
699        ));
700        assert!(rejected("select set_config('boatramp.site', 'x', true)"));
701        // Tolerant of whitespace around the call and the quote.
702        assert!(rejected(
703            "SELECT set_config ( 'boatramp.project' , 'v', false )"
704        ));
705        // Double-quoted first arg (unusual but a literal in some dialects).
706        assert!(rejected(
707            "SELECT set_config(\"boatramp.project\", 'v', false)"
708        ));
709        // A reserved set_config hiding AFTER a benign one in the same statement is
710        // still caught (every occurrence is checked, not just the first).
711        assert!(rejected(
712            "SELECT set_config('search_path','app',false), \
713             set_config('boatramp.project','v',false)"
714        ));
715    }
716
717    #[test]
718    fn set_reserved_guc_is_rejected() {
719        assert!(rejected("SET boatramp.project = 'victim'"));
720        assert!(rejected("set boatramp.project='victim'")); // no spaces
721        assert!(rejected("SET SESSION boatramp.site = 'x'"));
722        assert!(rejected("SET LOCAL boatramp.project TO 'x'"));
723    }
724
725    #[test]
726    fn set_reserved_mysql_var_is_rejected() {
727        assert!(rejected("SET @boatramp_project = 'victim'"));
728        assert!(rejected("set @boatramp_site='x'"));
729        assert!(rejected("SET @boatramp_project := 'x'")); // MySQL := assignment
730        assert!(rejected("SET SESSION @boatramp_project = 'x'"));
731    }
732
733    #[test]
734    fn reset_and_discard_of_reserved_state_is_rejected() {
735        assert!(rejected("RESET boatramp.project"));
736        assert!(rejected("RESET ALL")); // clears our GUC too
737        assert!(rejected("DISCARD ALL"));
738        assert!(rejected("discard all"));
739    }
740
741    // ---- legitimate app SQL MUST be allowed (narrow match) ----
742
743    #[test]
744    fn unrelated_set_statements_are_allowed() {
745        assert!(!rejected("SET statement_timeout = 5000"));
746        assert!(!rejected("SET search_path TO app, public"));
747        assert!(!rejected("SET SESSION time_zone = '+00:00'"));
748        assert!(!rejected("SET @my_var = 1")); // a non-reserved MySQL user var
749        assert!(!rejected("RESET statement_timeout"));
750    }
751
752    #[test]
753    fn a_select_mentioning_set_in_an_identifier_is_allowed() {
754        // "set" appears only as an identifier / column word, not a SET statement.
755        assert!(!rejected("SELECT settings FROM boatramp_projects"));
756        assert!(!rejected(
757            "SELECT * FROM offset_table WHERE reset_at > now()"
758        ));
759        // A normal SELECT that happens to filter on a column literally named similarly.
760        assert!(!rejected("SELECT * FROM t WHERE name = 'boatramp.project'"));
761    }
762
763    #[test]
764    fn set_config_on_a_non_reserved_guc_is_allowed() {
765        assert!(!rejected("SELECT set_config('search_path','app',false)"));
766        assert!(!rejected(
767            "SELECT set_config('statement_timeout', '5000', true)"
768        ));
769    }
770
771    // ---- bypasses of the earlier naive string filter, now closed by the tokenizer ----
772
773    /// A comment spliced into the keyword or between the function name and `(` used to
774    /// defeat the substring match; the tokenizer folds comments into whitespace we drop.
775    #[test]
776    fn inline_comment_splitting_the_keyword_is_rejected() {
777        assert!(rejected("SET/*x*/ boatramp.project='x'"));
778        assert!(rejected("set_config/*c*/('boatramp.project','x')"));
779    }
780
781    /// A leading comment used to push the real keyword out of the string's head.
782    #[test]
783    fn leading_comment_before_set_is_rejected() {
784        assert!(rejected("/*c*/SET boatramp.project='x'"));
785        assert!(rejected("/* hi */ set_config('boatramp.site','x')"));
786    }
787
788    /// String-concatenating the setting name hid `boatramp.` from a literal-prefix check;
789    /// a non-simple-literal first argument is now refused wholesale.
790    #[test]
791    fn set_config_with_concatenated_name_is_rejected() {
792        assert!(rejected(
793            "SELECT set_config('boat'||'ramp.project','x',false)"
794        ));
795        assert!(rejected(
796            "SELECT set_config('boatramp.'||'project','x',false)"
797        ));
798    }
799
800    /// MySQL quoting variants around the reserved user var.
801    #[test]
802    fn mysql_quoted_reserved_var_is_rejected() {
803        // Backtick-quoted whole var: `@boatramp_project` (one delimited identifier).
804        assert!(rejected("SET `@boatramp_project`=1"));
805        // `@` then a backtick-quoted name — an unbalanced/oddly-lexing form fails closed.
806        assert!(rejected("SET @`boatramp_project`=1"));
807    }
808
809    /// Casing of the keyword and of the `set_config` function name is folded.
810    #[test]
811    fn case_variants_are_rejected() {
812        assert!(rejected("sEt boatramp.project=1"));
813        assert!(rejected("SeT_config('boatramp.project','x')"));
814    }
815
816    /// The `set_config` guard tolerates whitespace/comments around the call and catches a
817    /// reserved call hiding after a benign one in the same statement.
818    #[test]
819    fn set_config_edge_forms_are_rejected() {
820        assert!(rejected(
821            "SELECT set_config ( 'boatramp.project' , 'v', false )"
822        ));
823        assert!(rejected(
824            "SELECT set_config('search_path','app',false), \
825             set_config('boatramp.project','v',false)"
826        ));
827    }
828
829    // ---- deferred-execution bypasses (the tokenizer can't see into a body) ----
830
831    /// The proven Round-1 High: a reserved write hidden in a **dollar-quoted** `DO`
832    /// block. `$$…$$` / `$tag$…$tag$` lex as one opaque token, so the inner
833    /// `set_config`/`SET` was invisible to the token scan — now the whole
834    /// dollar-quoted class is refused under an injected context.
835    #[test]
836    fn dollar_quoted_do_block_reserved_write_is_rejected() {
837        assert!(rejected(
838            "DO $$ BEGIN PERFORM set_config('boatramp.project','victim',false); END $$;"
839        ));
840        assert!(rejected(
841            "DO $$ BEGIN SET boatramp.project = 'victim'; END $$;"
842        ));
843        assert!(rejected(
844            "DO $tag$ PERFORM set_config('boatramp.project','v',false); $tag$;"
845        ));
846        // A dollar-quoted string literal anywhere is refused too (a guest has no need
847        // for one on the RLS path; it could carry a hidden body).
848        assert!(rejected(
849            "SELECT set_config($$boatramp.project$$, 'v', false)"
850        ));
851    }
852
853    /// The rest of the deferred-execution / persistent-default class: a single-quoted
854    /// `DO` body, `CALL`, defining a routine (single- or dollar-quoted body), and a
855    /// persistent GUC default via `ALTER ROLE/DATABASE`.
856    #[test]
857    fn procedural_and_persistent_constructs_are_rejected() {
858        assert!(rejected(
859            "DO 'BEGIN PERFORM set_config(''boatramp.project'',''v'',false); END'"
860        ));
861        assert!(rejected("CALL do_evil()"));
862        assert!(rejected(
863            "CREATE FUNCTION e() RETURNS void AS $$ SELECT set_config('boatramp.project','v',false) $$ LANGUAGE sql"
864        ));
865        assert!(rejected(
866            "CREATE FUNCTION e() RETURNS void AS 'BEGIN PERFORM set_config(''boatramp.project'',''v'',false); END' LANGUAGE plpgsql"
867        ));
868        assert!(rejected(
869            "CREATE OR REPLACE PROCEDURE p() LANGUAGE sql AS $$ SELECT 1 $$"
870        ));
871        assert!(rejected(
872            "ALTER ROLE tenant_role SET boatramp.project = 'victim'"
873        ));
874        assert!(rejected("ALTER DATABASE app SET boatramp.site = 'victim'"));
875    }
876
877    /// Round-2 High: MySQL writes the reserved `@boatramp_*` user var without it being
878    /// the first `SET` target — via a comma-list, a `:=` variant, or
879    /// `SELECT … INTO @var` (no `SET` at all) — evading the leading-target check. A
880    /// position-independent reserved-var refusal closes the whole family.
881    #[test]
882    fn mysql_reserved_var_anywhere_is_rejected() {
883        assert!(rejected("SET @x=1, @boatramp_project='victim'"));
884        assert!(rejected("SET @a=1, @b=2, @boatramp_project='victim'"));
885        assert!(rejected("SET @x:=1, @boatramp_project:='victim'"));
886        assert!(rejected("SELECT 'victim' INTO @boatramp_project"));
887        assert!(rejected("SELECT 'victim' AS v INTO @boatramp_project"));
888        assert!(rejected("SELECT 1,'victim' INTO @junk, @boatramp_project"));
889        assert!(rejected("select 'victim' into @boatramp_project"));
890        assert!(rejected("SELECT 'v' INTO @boatramp_site"));
891    }
892
893    /// Prepared-statement indirection hides the reserved write inside a string literal
894    /// (which is legitimate data elsewhere, so must not be scanned): refuse the
895    /// deferral construct itself. The guest binding never issues SQL-level
896    /// PREPARE/EXECUTE (it parameterizes via bind params), so this costs nothing.
897    #[test]
898    fn prepared_statement_indirection_is_rejected() {
899        assert!(rejected(
900            "PREPARE s FROM 'SET @boatramp_project=''victim'''"
901        ));
902        assert!(rejected("EXECUTE s"));
903        assert!(rejected(
904            "prepare s from 'SELECT ''v'' INTO @boatramp_site'"
905        ));
906    }
907
908    // ---- legit forms must still parse-and-pass (no regression) ----
909
910    #[test]
911    fn legit_set_and_set_config_forms_still_pass() {
912        assert!(!rejected("SET statement_timeout = '5s'"));
913        assert!(!rejected("SET search_path TO myschema"));
914        assert!(!rejected("SET SESSION time_zone = '+00:00'"));
915        assert!(!rejected("SET @my_var = 1"));
916        assert!(!rejected("RESET statement_timeout"));
917        assert!(!rejected("set_config('search_path','x',false)"));
918        assert!(!rejected("set_config('statement_timeout','5s',true)"));
919        // "set" / "boatramp.project" appearing only in identifiers or string literals.
920        assert!(!rejected(
921            "SELECT settings FROM t WHERE k = 'boatramp.project'"
922        ));
923        // Ordinary app SQL on the RLS path is untouched: parameterized queries ($1 is a
924        // Placeholder, not a dollar-quoted body), plain DML, and non-routine DDL.
925        assert!(!rejected("SELECT * FROM orders WHERE id = $1"));
926        assert!(!rejected("INSERT INTO orders (id, total) VALUES ($1, $2)"));
927        assert!(!rejected("UPDATE orders SET total = $1 WHERE id = $2"));
928        assert!(!rejected(
929            "CREATE TABLE orders (id bigint primary key, total numeric)"
930        ));
931        assert!(!rejected("ALTER TABLE orders ADD COLUMN note text"));
932        // Non-reserved MySQL user vars (comma-list and `SELECT … INTO`) are untouched —
933        // only the `@boatramp_*` namespace is refused.
934        assert!(!rejected("SET @x = 1, @y = 2"));
935        assert!(!rejected("SELECT 42 INTO @myvar"));
936        assert!(!rejected("SELECT total INTO @t FROM orders WHERE id = $1"));
937    }
938}