An app's OWN store had no ceiling of any kind — only the group-SHARED
collections did, which is the inversion of what you'd expect. And
`authz::script_gate` returns `Ok(())` when there is no principal, so a
PUBLIC UNAUTHENTICATED route could reach `files::collection(..).create(..)`
and write blobs in a loop until the disk filled. `PICLOUD_FILES_MAX_FILE_SIZE_BYTES`
caps ONE file at 100 MB; nothing capped the count. Same for KV keys and docs
against Postgres.
Ceilings: `PICLOUD_APP_KV_MAX_ROWS` / `PICLOUD_APP_DOCS_MAX_ROWS` (100k) and
`PICLOUD_APP_FILES_MAX_TOTAL_BYTES` (10 GiB).
They are enforced DIFFERENTLY from the group ones, on purpose — porting the
group design as-is would have been a serious regression:
* KV/docs check a ROW count, on INSERT only, with NO lock. `kv::set` is the
hottest write path in the system; taking a per-app advisory lock on every
set would serialize an app's entire data plane, and a `SUM(...)` scan would
make write cost grow with the app's stored size. What the lock buys is
small — unlocked overshoot is bounded by write concurrency (32) against a
ceiling of 100k, ~0.03%. These are anti-DoS rails, not billing. An update
adds no row and is bounded by the per-value cap, so it pays nothing at all.
* No per-app BYTE ceiling for KV/docs: every value is already capped at
`PICLOUD_KV_MAX_VALUE_BYTES`, so `max_rows x max_value_bytes` is ALREADY a
finite bound. A second scan would buy nothing.
* FILES are the exception and DO lock + sum: one blob may be 100 MB, so 32
racing uploads could overshoot by gigabytes of real disk, and a file write
is heavy enough that the lock and the SUM are lost in the noise. Checked on
create AND update — an update that skipped the ceiling would be a free
bypass (the same bug just fixed for group files: grow a 1-byte file to
100 MB, repeat).
`group_quota` is renamed `quota`: it now serves both owners, and the module
doc is where the two enforcement strategies are contrasted.
Pinned by tests/atomic_write.rs: the key ceiling refuses a new key while
still allowing an update at the ceiling (rejecting updates would brick an app
the moment it filled up — worse than the DoS the rail exists to stop), and 10
concurrent uploads cannot exceed the disk ceiling, nor can an update grow past it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
624 lines
20 KiB
Rust
624 lines
20 KiB
Rust
//! Low-level Postgres CRUD + filter-query builder over the `docs`
|
||
//! table (migration 0013). Stays storage-only; authorization, event
|
||
//! emission, and empty-collection validation live one layer up in
|
||
//! `DocsServiceImpl`.
|
||
//!
|
||
//! The `find` SQL builder is the security-critical surface. **Every
|
||
//! field-path segment and every comparison value is bound as a
|
||
//! `$N` parameter — never interpolated into the SQL string.** The base
|
||
//! `WHERE app_id = $1 AND collection = $2` clause is fixed and
|
||
//! prepended to every query so cross-app isolation can't be widened by
|
||
//! any operator. See `sql_starts_with_app_collection_predicate`
|
||
//! assertion in tests for the load-bearing guarantee.
|
||
|
||
use async_trait::async_trait;
|
||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||
use base64::Engine as _;
|
||
use chrono::{DateTime, Utc};
|
||
use picloud_shared::{AppId, DocId, DocRow, DocsListPage};
|
||
use serde_json::Value;
|
||
use sqlx::postgres::PgRow;
|
||
use sqlx::{PgPool, Postgres, QueryBuilder, Row};
|
||
use uuid::Uuid;
|
||
|
||
use crate::docs_filter::{ComparisonOp, DocsFilter, SortDir};
|
||
|
||
#[derive(Debug, thiserror::Error)]
|
||
pub enum DocsRepoError {
|
||
#[error("database error: {0}")]
|
||
Db(#[from] sqlx::Error),
|
||
|
||
#[error("invalid pagination cursor")]
|
||
InvalidCursor,
|
||
}
|
||
|
||
/// Repo surface. The trait is exposed so the service unit tests can
|
||
/// substitute an in-memory backing without spinning up Postgres.
|
||
#[async_trait]
|
||
pub trait DocsRepo: Send + Sync {
|
||
/// Create a new doc with a server-generated UUID. Returns the
|
||
/// fully-materialised `DocRow` so the caller has timestamps too
|
||
/// (no separate select-back round-trip).
|
||
async fn create(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
data: Value,
|
||
) -> Result<DocRow, DocsRepoError>;
|
||
|
||
async fn get(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
) -> Result<Option<DocRow>, DocsRepoError>;
|
||
|
||
/// Filter-based query. The parsed `DocsFilter` ensures every
|
||
/// field-path segment and operator value is bound as a parameter.
|
||
async fn find(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
filter: &DocsFilter,
|
||
) -> Result<Vec<DocRow>, DocsRepoError>;
|
||
|
||
/// Full document replace. Returns `Some(previous_data)` on
|
||
/// success, `None` if no doc matched (the service maps that to
|
||
/// `DocsError::NotFound`). The prev value is the input to the
|
||
/// emitted update event's `old_payload`.
|
||
async fn update(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
data: Value,
|
||
) -> Result<Option<Value>, DocsRepoError>;
|
||
|
||
/// Returns the deleted doc's data if it existed, `None` if no
|
||
/// such doc. The caller converts `Some` → `Ok(true)` for the SDK's
|
||
/// was-present return; the `Value` feeds the delete event's
|
||
/// `old_payload`.
|
||
async fn delete(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
) -> Result<Option<Value>, DocsRepoError>;
|
||
|
||
async fn list(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
cursor: Option<&str>,
|
||
limit: u32,
|
||
) -> Result<DocsListPage, DocsRepoError>;
|
||
}
|
||
|
||
pub struct PostgresDocsRepo {
|
||
pool: PgPool,
|
||
}
|
||
|
||
impl PostgresDocsRepo {
|
||
#[must_use]
|
||
pub fn new(pool: PgPool) -> Self {
|
||
Self { pool }
|
||
}
|
||
}
|
||
|
||
/// Hard ceiling on `list` page size — mirrors KV's `KV_LIST_MAX_LIMIT`.
|
||
/// Scripts that pass anything larger get silently clamped.
|
||
const DOCS_LIST_MAX_LIMIT: u32 = 1_000;
|
||
const DOCS_LIST_DEFAULT_LIMIT: u32 = 100;
|
||
|
||
#[async_trait]
|
||
impl DocsRepo for PostgresDocsRepo {
|
||
async fn create(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
data: Value,
|
||
) -> Result<DocRow, DocsRepoError> {
|
||
create_on(&self.pool, app_id, collection, data).await
|
||
}
|
||
|
||
async fn get(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
) -> Result<Option<DocRow>, DocsRepoError> {
|
||
let row: Option<(Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||
"SELECT data, created_at, updated_at FROM docs \
|
||
WHERE app_id = $1 AND collection = $2 AND id = $3",
|
||
)
|
||
.bind(app_id.into_inner())
|
||
.bind(collection)
|
||
.bind(id)
|
||
.fetch_optional(&self.pool)
|
||
.await?;
|
||
Ok(row.map(|(data, created_at, updated_at)| DocRow {
|
||
id,
|
||
data,
|
||
created_at,
|
||
updated_at,
|
||
}))
|
||
}
|
||
|
||
async fn find(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
filter: &DocsFilter,
|
||
) -> Result<Vec<DocRow>, DocsRepoError> {
|
||
let mut qb = build_find_query("docs", "app_id", app_id.into_inner(), collection, filter);
|
||
let rows = qb.build().fetch_all(&self.pool).await?;
|
||
rows.into_iter().map(row_to_doc).collect()
|
||
}
|
||
|
||
async fn update(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
data: Value,
|
||
) -> Result<Option<Value>, DocsRepoError> {
|
||
update_on(&self.pool, app_id, collection, id, data).await
|
||
}
|
||
|
||
async fn delete(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
) -> Result<Option<Value>, DocsRepoError> {
|
||
delete_on(&self.pool, app_id, collection, id).await
|
||
}
|
||
|
||
async fn list(
|
||
&self,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
cursor: Option<&str>,
|
||
limit: u32,
|
||
) -> Result<DocsListPage, DocsRepoError> {
|
||
let limit = if limit == 0 {
|
||
DOCS_LIST_DEFAULT_LIMIT
|
||
} else {
|
||
limit.min(DOCS_LIST_MAX_LIMIT)
|
||
};
|
||
|
||
let last_id = match cursor {
|
||
Some(c) => Some(decode_cursor(c)?),
|
||
None => None,
|
||
};
|
||
|
||
let take = i64::from(limit) + 1;
|
||
let rows: Vec<(Uuid, Value, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
|
||
"SELECT id, data, created_at, updated_at FROM docs \
|
||
WHERE app_id = $1 AND collection = $2 \
|
||
AND ($3::uuid IS NULL OR id > $3) \
|
||
ORDER BY id ASC \
|
||
LIMIT $4",
|
||
)
|
||
.bind(app_id.into_inner())
|
||
.bind(collection)
|
||
.bind(last_id)
|
||
.bind(take)
|
||
.fetch_all(&self.pool)
|
||
.await?;
|
||
|
||
let mut docs: Vec<DocRow> = rows
|
||
.into_iter()
|
||
.map(|(id, data, created_at, updated_at)| DocRow {
|
||
id,
|
||
data,
|
||
created_at,
|
||
updated_at,
|
||
})
|
||
.collect();
|
||
let next_cursor = if docs.len() > limit as usize {
|
||
docs.truncate(limit as usize);
|
||
docs.last().map(|d| encode_cursor(&d.id))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
Ok(DocsListPage { docs, next_cursor })
|
||
}
|
||
}
|
||
|
||
pub(crate) fn row_to_doc(row: PgRow) -> Result<DocRow, DocsRepoError> {
|
||
Ok(DocRow {
|
||
id: row.try_get("id")?,
|
||
data: row.try_get("data")?,
|
||
created_at: row.try_get("created_at")?,
|
||
updated_at: row.try_get("updated_at")?,
|
||
})
|
||
}
|
||
|
||
fn encode_cursor(last_id: &Uuid) -> String {
|
||
URL_SAFE_NO_PAD.encode(last_id.as_bytes())
|
||
}
|
||
|
||
fn decode_cursor(cursor: &str) -> Result<Uuid, DocsRepoError> {
|
||
let bytes = URL_SAFE_NO_PAD
|
||
.decode(cursor)
|
||
.map_err(|_| DocsRepoError::InvalidCursor)?;
|
||
let arr: [u8; 16] = bytes
|
||
.as_slice()
|
||
.try_into()
|
||
.map_err(|_| DocsRepoError::InvalidCursor)?;
|
||
Ok(Uuid::from_bytes(arr))
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// SQL builder — the load-bearing security surface.
|
||
//
|
||
// Every field-path segment + every comparison value goes through
|
||
// `QueryBuilder::push_bind`, which appends `$N` to the SQL string and
|
||
// binds the value as a parameter. The only literal strings appended to
|
||
// the SQL are: hardcoded SQL fragments (SELECT/WHERE/AND/etc.) and
|
||
// hardcoded operator strings ("=", "IS DISTINCT FROM", ">", "ASC", …).
|
||
// **No user input ever lands in the SQL text unparameterized.**
|
||
// ----------------------------------------------------------------------------
|
||
|
||
/// Build the `find` query for a docs store. `table` and `owner_col` are
|
||
/// compile-time literals (`"docs"`/`"app_id"` for app docs, `"group_docs"`/
|
||
/// `"group_id"` for §11.6 group-shared docs) — never user input, so
|
||
/// interpolating them is injection-safe. `pub(crate)` so the group-docs repo
|
||
/// reuses this single source for the (security-sensitive) query SQL.
|
||
pub(crate) fn build_find_query<'a>(
|
||
table: &'static str,
|
||
owner_col: &'static str,
|
||
owner_id: uuid::Uuid,
|
||
collection: &'a str,
|
||
filter: &'a DocsFilter,
|
||
) -> QueryBuilder<'a, Postgres> {
|
||
let mut qb = QueryBuilder::new(format!(
|
||
"SELECT id, data, created_at, updated_at FROM {table} WHERE {owner_col} = "
|
||
));
|
||
qb.push_bind(owner_id);
|
||
qb.push(" AND collection = ");
|
||
qb.push_bind(collection);
|
||
|
||
for cond in &filter.conditions {
|
||
qb.push(" AND ");
|
||
emit_condition(&mut qb, cond);
|
||
}
|
||
|
||
qb.push(" ORDER BY ");
|
||
if let Some(sort) = &filter.sort {
|
||
push_jsonb_path(&mut qb, sort.path.segments());
|
||
qb.push(match sort.direction {
|
||
SortDir::Asc => " ASC",
|
||
SortDir::Desc => " DESC",
|
||
});
|
||
qb.push(", id ASC");
|
||
} else {
|
||
qb.push("id ASC");
|
||
}
|
||
|
||
let limit = filter
|
||
.limit
|
||
.map_or(DOCS_LIST_MAX_LIMIT, |l| l.min(DOCS_LIST_MAX_LIMIT));
|
||
qb.push(" LIMIT ");
|
||
qb.push_bind(i64::from(limit));
|
||
|
||
qb
|
||
}
|
||
|
||
fn emit_condition<'a>(
|
||
qb: &mut QueryBuilder<'a, Postgres>,
|
||
cond: &'a crate::docs_filter::FieldCondition,
|
||
) {
|
||
push_jsonb_path(qb, cond.path.segments());
|
||
match cond.op {
|
||
ComparisonOp::Eq => {
|
||
if cond.value.is_null() {
|
||
qb.push(" IS NULL");
|
||
} else {
|
||
qb.push(" = ");
|
||
qb.push_bind(value_to_text(&cond.value));
|
||
}
|
||
}
|
||
ComparisonOp::Ne => {
|
||
// IS DISTINCT FROM correctly handles NULL on either side
|
||
// (would otherwise silently exclude rows with missing
|
||
// paths). Holds for the literal-NULL case too.
|
||
if cond.value.is_null() {
|
||
qb.push(" IS NOT NULL");
|
||
} else {
|
||
qb.push(" IS DISTINCT FROM ");
|
||
qb.push_bind(value_to_text(&cond.value));
|
||
}
|
||
}
|
||
ComparisonOp::Gt => {
|
||
qb.push(" > ");
|
||
qb.push_bind(value_to_text(&cond.value));
|
||
}
|
||
ComparisonOp::Gte => {
|
||
qb.push(" >= ");
|
||
qb.push_bind(value_to_text(&cond.value));
|
||
}
|
||
ComparisonOp::Lt => {
|
||
qb.push(" < ");
|
||
qb.push_bind(value_to_text(&cond.value));
|
||
}
|
||
ComparisonOp::Lte => {
|
||
qb.push(" <= ");
|
||
qb.push_bind(value_to_text(&cond.value));
|
||
}
|
||
ComparisonOp::In => {
|
||
qb.push(" = ANY(");
|
||
let texts: Vec<Option<String>> = cond
|
||
.value
|
||
.as_array()
|
||
.map(|arr| arr.iter().map(value_to_text).collect())
|
||
.unwrap_or_default();
|
||
qb.push_bind(texts);
|
||
qb.push(")");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Append `jsonb_extract_path_text(data, $N1, $N2, …)` with each
|
||
/// segment bound as a separate text parameter. Variadic path lengths
|
||
/// (1–5) all flow through this single helper.
|
||
fn push_jsonb_path<'a>(qb: &mut QueryBuilder<'a, Postgres>, segments: &'a [String]) {
|
||
qb.push("jsonb_extract_path_text(data");
|
||
for seg in segments {
|
||
qb.push(", ");
|
||
qb.push_bind(seg.as_str());
|
||
}
|
||
qb.push(")");
|
||
}
|
||
|
||
/// JSON scalar → TEXT for binding. `Value::Null` is preserved as
|
||
/// `None` so the binding lands as SQL NULL (handled specially above for
|
||
/// `Eq` / `Ne`). Arrays + objects serialize to compact JSON; the user
|
||
/// is comparing against the JSONB text rendering, which is consistent
|
||
/// with `jsonb_extract_path_text`'s output for those types.
|
||
fn value_to_text(v: &Value) -> Option<String> {
|
||
match v {
|
||
Value::Null => None,
|
||
Value::String(s) => Some(s.clone()),
|
||
Value::Bool(b) => Some(b.to_string()),
|
||
Value::Number(n) => Some(n.to_string()),
|
||
Value::Array(_) | Value::Object(_) => Some(v.to_string()),
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// SQL-shape guardrail tests — pure (no DB) so they run in the default
|
||
// test suite. These are the highest-stakes tests in the release: they
|
||
// pin the cross-app isolation invariant at the SQL level.
|
||
// ----------------------------------------------------------------------------
|
||
|
||
// ----------------------------------------------------------------------------
|
||
// Connection-scoped mutations
|
||
//
|
||
// Generic over the executor so the same SQL serves the pooled trait methods
|
||
// above and `crate::atomic_write`, which runs the write and the trigger fan-out
|
||
// it produces on ONE connection inside ONE transaction.
|
||
// ----------------------------------------------------------------------------
|
||
|
||
pub(crate) async fn create_on<'c, E>(
|
||
exec: E,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
data: Value,
|
||
) -> Result<DocRow, DocsRepoError>
|
||
where
|
||
E: sqlx::PgExecutor<'c>,
|
||
{
|
||
let id = Uuid::new_v4();
|
||
let row: (DateTime<Utc>, DateTime<Utc>) = sqlx::query_as(
|
||
"INSERT INTO docs (app_id, collection, id, data) \
|
||
VALUES ($1, $2, $3, $4) \
|
||
RETURNING created_at, updated_at",
|
||
)
|
||
.bind(app_id.into_inner())
|
||
.bind(collection)
|
||
.bind(id)
|
||
.bind(&data)
|
||
.fetch_one(exec)
|
||
.await?;
|
||
Ok(DocRow {
|
||
id,
|
||
data,
|
||
created_at: row.0,
|
||
updated_at: row.1,
|
||
})
|
||
}
|
||
|
||
/// Returns the previous data (for the update event), `None` if the doc is
|
||
/// missing. The CTE captures the prior data alongside the UPDATE so the service
|
||
/// can emit `prev_data` without a second round trip.
|
||
pub(crate) async fn update_on<'c, E>(
|
||
exec: E,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
data: Value,
|
||
) -> Result<Option<Value>, DocsRepoError>
|
||
where
|
||
E: sqlx::PgExecutor<'c>,
|
||
{
|
||
let row: Option<(Option<Value>,)> = sqlx::query_as(
|
||
"WITH prev AS ( \
|
||
SELECT data FROM docs \
|
||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||
), \
|
||
updated AS ( \
|
||
UPDATE docs SET data = $4, updated_at = NOW() \
|
||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||
RETURNING 1 \
|
||
) \
|
||
SELECT (SELECT data FROM prev) FROM updated",
|
||
)
|
||
.bind(app_id.into_inner())
|
||
.bind(collection)
|
||
.bind(id)
|
||
.bind(&data)
|
||
.fetch_optional(exec)
|
||
.await?;
|
||
Ok(row.and_then(|(v,)| v))
|
||
}
|
||
|
||
/// Returns the deleted doc's data if it existed, `None` if no such doc.
|
||
pub(crate) async fn delete_on<'c, E>(
|
||
exec: E,
|
||
app_id: AppId,
|
||
collection: &str,
|
||
id: DocId,
|
||
) -> Result<Option<Value>, DocsRepoError>
|
||
where
|
||
E: sqlx::PgExecutor<'c>,
|
||
{
|
||
let row: Option<(Value,)> = sqlx::query_as(
|
||
"DELETE FROM docs \
|
||
WHERE app_id = $1 AND collection = $2 AND id = $3 \
|
||
RETURNING data",
|
||
)
|
||
.bind(app_id.into_inner())
|
||
.bind(collection)
|
||
.bind(id)
|
||
.fetch_optional(exec)
|
||
.await?;
|
||
Ok(row.map(|(v,)| v))
|
||
}
|
||
|
||
/// Total doc count for the app, across all its collections. Backs the per-app
|
||
/// row ceiling; run only on `create`.
|
||
pub(crate) async fn count_rows_on<'c, E>(exec: E, app_id: AppId) -> Result<u64, DocsRepoError>
|
||
where
|
||
E: sqlx::PgExecutor<'c>,
|
||
{
|
||
let (n,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM docs WHERE app_id = $1")
|
||
.bind(app_id.into_inner())
|
||
.fetch_one(exec)
|
||
.await?;
|
||
Ok(u64::try_from(n).unwrap_or(0))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod sql_shape_tests {
|
||
use super::*;
|
||
use crate::docs_filter::parse_filter;
|
||
use serde_json::json;
|
||
|
||
fn sql_for(filter_json: serde_json::Value) -> String {
|
||
let filter = parse_filter(&filter_json).unwrap();
|
||
let qb = build_find_query(
|
||
"docs",
|
||
"app_id",
|
||
AppId::new().into_inner(),
|
||
"users",
|
||
&filter,
|
||
);
|
||
qb.sql().to_string()
|
||
}
|
||
|
||
/// **Load-bearing**: every generated SELECT begins
|
||
/// `WHERE app_id = $1 AND collection = $2`. The app_id parameter
|
||
/// is the cross-app isolation gate. No user-supplied filter
|
||
/// fragment can ever appear before these clauses.
|
||
#[test]
|
||
fn every_query_starts_with_app_id_and_collection_predicate() {
|
||
let cases = vec![
|
||
json!({}),
|
||
json!({ "tier": "gold" }),
|
||
json!({ "created_at": { "$gt": "2026-01-01" } }),
|
||
json!({ "tier": { "$in": ["gold", "platinum"] } }),
|
||
json!({ "tier": "gold", "status": "active" }),
|
||
json!({ "$sort": { "created_at": -1 }, "$limit": 5 }),
|
||
json!({ "tier": "gold", "$sort": { "created_at": 1 } }),
|
||
json!({ "deleted_at": { "$ne": null } }),
|
||
];
|
||
for case in cases {
|
||
let sql = sql_for(case.clone());
|
||
assert!(
|
||
sql.starts_with(
|
||
"SELECT id, data, created_at, updated_at FROM docs WHERE app_id = $1 AND collection = $2"
|
||
),
|
||
"filter {case} produced SQL: {sql}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Every comparison value lands as a `$N` placeholder — there
|
||
/// should be NO double-quoted string literal in the SQL after the
|
||
/// fixed prefix. (This guards against an accidental `format!`
|
||
/// regression.)
|
||
#[test]
|
||
fn no_user_string_literal_in_sql() {
|
||
let sql = sql_for(json!({ "tier": "gold; DROP TABLE docs;--" }));
|
||
assert!(!sql.contains("gold"), "value leaked into SQL string: {sql}");
|
||
assert!(!sql.contains("DROP"), "value leaked into SQL string: {sql}");
|
||
}
|
||
|
||
/// Field-path segments also bind as parameters. A user passing a
|
||
/// path that looks like SQL keywords doesn't change the structure.
|
||
#[test]
|
||
fn no_user_path_literal_in_sql() {
|
||
let sql = sql_for(json!({ "drop_table_users": "v" }));
|
||
assert!(
|
||
!sql.contains("drop_table_users"),
|
||
"path leaked into SQL string: {sql}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_filter_sql_has_no_extra_conditions() {
|
||
let sql = sql_for(json!({}));
|
||
// After the fixed prefix, only ORDER BY + LIMIT — no `AND`s.
|
||
let suffix = sql
|
||
.trim_start_matches(
|
||
"SELECT id, data, created_at, updated_at FROM docs WHERE app_id = $1 AND collection = $2",
|
||
)
|
||
.trim();
|
||
assert!(
|
||
suffix.starts_with("ORDER BY"),
|
||
"expected ORDER BY immediately after base WHERE; got: {suffix}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn eq_with_null_emits_is_null() {
|
||
let sql = sql_for(json!({ "x": null }));
|
||
assert!(sql.contains("IS NULL"), "sql: {sql}");
|
||
}
|
||
|
||
#[test]
|
||
fn ne_with_null_emits_is_not_null() {
|
||
let sql = sql_for(json!({ "x": { "$ne": null } }));
|
||
assert!(sql.contains("IS NOT NULL"), "sql: {sql}");
|
||
}
|
||
|
||
#[test]
|
||
fn ne_with_value_uses_is_distinct_from() {
|
||
// IS DISTINCT FROM, NOT <> — see ComparisonOp::Ne comment.
|
||
let sql = sql_for(json!({ "x": { "$ne": "v" } }));
|
||
assert!(sql.contains("IS DISTINCT FROM"), "sql: {sql}");
|
||
assert!(!sql.contains(" <> "), "sql: {sql}");
|
||
}
|
||
|
||
#[test]
|
||
fn in_emits_any_array() {
|
||
let sql = sql_for(json!({ "x": { "$in": ["a", "b"] } }));
|
||
assert!(sql.contains("= ANY"), "sql: {sql}");
|
||
}
|
||
|
||
#[test]
|
||
fn sort_appends_tiebreaker_id_asc() {
|
||
let sql = sql_for(json!({ "$sort": { "created_at": -1 } }));
|
||
assert!(sql.contains("DESC, id ASC"), "sql: {sql}");
|
||
}
|
||
|
||
#[test]
|
||
fn jsonb_extract_path_used_for_field_access() {
|
||
let sql = sql_for(json!({ "user.email": "a@b" }));
|
||
assert!(sql.contains("jsonb_extract_path_text(data"), "sql: {sql}");
|
||
}
|
||
}
|