Skip to main content

powdb_server/
handler.rs

1use crate::protocol::{Message, WireParam};
2use powdb_auth::{Permission, Role, UserStore};
3use powdb_query::executor::{is_read_only_statement, Engine};
4use powdb_query::parser;
5use powdb_query::result::{QueryError, QueryResult};
6use powdb_storage::types::Value;
7use std::collections::HashMap;
8use std::net::IpAddr;
9use std::sync::{Arc, Mutex, RwLock};
10use std::time::{Duration, Instant};
11use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader, BufWriter};
12use tokio::sync::watch;
13use tracing::{debug, error, info, warn};
14use zeroize::Zeroizing;
15
16/// Tracks per-IP authentication failure counts for rate limiting.
17pub type AuthRateLimiter = Arc<Mutex<HashMap<IpAddr, (u32, Instant)>>>;
18
19/// Maximum query text length accepted from the wire (1 MB).
20const MAX_QUERY_LENGTH: usize = 1024 * 1024;
21
22/// Timeout for writing a response to the client. Prevents slow-drain
23/// clients from blocking the handler indefinitely.
24const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
25
26/// Maximum number of auth failures per IP within the rate-limit window.
27const MAX_AUTH_FAILURES: u32 = 5;
28
29/// Window during which auth failures are counted (60 seconds).
30const AUTH_FAILURE_WINDOW: Duration = Duration::from_secs(60);
31
32/// Create a new shared rate limiter.
33pub fn new_rate_limiter() -> AuthRateLimiter {
34    Arc::new(Mutex::new(HashMap::new()))
35}
36
37/// Check whether an IP is rate-limited and record a failure if requested.
38/// Returns `true` if the IP should be rejected.
39fn is_rate_limited(limiter: &AuthRateLimiter, ip: IpAddr) -> bool {
40    let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
41    // Clean up stale entries while we have the lock.
42    let now = Instant::now();
43    map.retain(|_, (_, ts)| now.duration_since(*ts) < AUTH_FAILURE_WINDOW);
44
45    if let Some((count, _)) = map.get(&ip) {
46        *count >= MAX_AUTH_FAILURES
47    } else {
48        false
49    }
50}
51
52/// Record an auth failure for the given IP.
53fn record_auth_failure(limiter: &AuthRateLimiter, ip: IpAddr) {
54    let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
55    let now = Instant::now();
56    let entry = map.entry(ip).or_insert((0, now));
57    // Reset counter if the window has elapsed.
58    if now.duration_since(entry.1) >= AUTH_FAILURE_WINDOW {
59        *entry = (1, now);
60    } else {
61        entry.0 += 1;
62    }
63}
64
65/// Clear the failure counter on successful auth.
66fn clear_auth_failures(limiter: &AuthRateLimiter, ip: IpAddr) {
67    let mut map = limiter.lock().unwrap_or_else(|e| e.into_inner());
68    map.remove(&ip);
69}
70
71/// Constant-time password comparison. Hashes both inputs to fixed-size
72/// SHA-256 digests so neither length nor content leaks through timing.
73fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
74    use sha2::{Digest, Sha256};
75    let ha = Sha256::digest(a);
76    let hb = Sha256::digest(b);
77    let mut diff = 0u8;
78    for (x, y) in ha.iter().zip(hb.iter()) {
79        diff |= x ^ y;
80    }
81    diff == 0
82}
83
84/// An authenticated connection's identity. Bound at connect time and consulted
85/// on every query by [`dispatch_query`] to enforce the user's role: a
86/// `readonly` principal may only execute read statements.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Principal {
89    pub name: String,
90    pub role: String,
91}
92
93/// Whether a parsed statement is data-definition (schema) work: creating,
94/// altering, or dropping a type or view. `explain <ddl>` is classified by its
95/// inner statement so `explain drop User` needs the same permission as
96/// `drop User`. Mutations that change *rows* (insert/update/delete/upsert/
97/// refresh) and transaction control are NOT DDL — they fall under `Write`.
98fn is_ddl_statement(stmt: &powdb_query::ast::Statement) -> bool {
99    use powdb_query::ast::Statement;
100    let inner = match stmt {
101        Statement::Explain(inner) => inner.as_ref(),
102        other => other,
103    };
104    matches!(
105        inner,
106        Statement::CreateType(_)
107            | Statement::AlterTable(_)
108            | Statement::DropTable(_)
109            | Statement::CreateView(_)
110            | Statement::DropView(_)
111    )
112}
113
114/// The capability a parsed statement requires under the RBAC lattice
115/// (`crates/auth/src/role.rs`). Reads need [`Permission::Read`]; schema
116/// definition needs [`Permission::Ddl`]; every other mutation needs
117/// [`Permission::Write`]. [`Permission::Admin`] is reserved for user/role
118/// management, which is CLI-only today and never reaches this wire path.
119fn required_permission(stmt: &powdb_query::ast::Statement) -> Permission {
120    if is_read_only_statement(stmt) {
121        Permission::Read
122    } else if is_ddl_statement(stmt) {
123        Permission::Ddl
124    } else {
125        Permission::Write
126    }
127}
128
129/// Enforce the principal's role against a parsed statement using the full
130/// permission lattice. Reads are always permitted (any authenticated role can
131/// read — unknown role names still read but fail closed on any mutation).
132/// Mutations require the specific capability the statement maps to: row
133/// mutations need `Write`, schema changes need `Ddl`. Unknown role names
134/// resolve to no builtin and therefore grant nothing beyond reads.
135///
136/// Classification uses the parsed AST via
137/// [`powdb_query::executor::is_read_only_statement`] — the exact same
138/// classifier the RwLock read/write split relies on — so the permission
139/// boundary and the concurrency boundary can never disagree.
140fn check_statement_permitted(
141    principal: Option<&Principal>,
142    stmt: &powdb_query::ast::Statement,
143) -> Result<(), QueryError> {
144    let Some(p) = principal else {
145        // No per-user identity (shared-password or open mode): full access,
146        // byte-identical to the pre-RBAC behavior.
147        return Ok(());
148    };
149    // Reads are permitted for every authenticated principal (preserves the
150    // pre-lattice contract that any connected role may run read-only queries).
151    if is_read_only_statement(stmt) {
152        return Ok(());
153    }
154    let needed = required_permission(stmt);
155    if Role::builtin(&p.role).is_some_and(|r| r.allows(needed)) {
156        return Ok(());
157    }
158    let kind = if needed == Permission::Ddl {
159        "schema-definition"
160    } else {
161        "write"
162    };
163    Err(QueryError::Execution(format!(
164        "permission denied: role '{}' cannot execute {kind} statements",
165        p.role
166    )))
167}
168
169/// Result of the connect-time authentication decision.
170#[derive(Debug, Clone, PartialEq, Eq)]
171pub enum AuthOutcome {
172    /// Authenticated. `principal` is `Some` when a named user authenticated via
173    /// the UserStore, and `None` for the legacy shared-password / open paths
174    /// where there is no per-user identity.
175    Authenticated { principal: Option<Principal> },
176    /// Rejected. The caller sends a generic "authentication failed" error and
177    /// records a rate-limit failure — it must not reveal which check failed.
178    Rejected,
179}
180
181/// Pure, exhaustively-testable authentication decision for a CONNECT handshake.
182///
183/// Policy:
184/// - If `users` has at least one user, multi-user auth is in force: a
185///   `username` is required and `users.authenticate(username, password)` must
186///   succeed. Unknown user, wrong password, or a missing username all reject
187///   with an indistinguishable `Rejected` (no user-vs-password leak).
188/// - If `users` is empty, fall back verbatim to the legacy behavior: when
189///   `expected_password` is `Some`, the candidate must match it (constant time);
190///   when `None`, no auth is required (open). The `username` is ignored here so
191///   that a new client talking to a shared-password server still connects.
192pub fn authenticate_connect(
193    users: &UserStore,
194    expected_password: Option<&str>,
195    username: Option<&str>,
196    password: Option<&str>,
197) -> AuthOutcome {
198    if !users.is_empty() {
199        // Multi-user mode: a username is mandatory.
200        let Some(name) = username else {
201            return AuthOutcome::Rejected;
202        };
203        let Some(candidate) = password else {
204            return AuthOutcome::Rejected;
205        };
206        match users.authenticate(name, candidate) {
207            Some(user) => AuthOutcome::Authenticated {
208                principal: Some(Principal {
209                    name: user.name.clone(),
210                    role: user.role.clone(),
211                }),
212            },
213            None => AuthOutcome::Rejected,
214        }
215    } else {
216        // Legacy shared-password fallback (byte-identical to prior behavior).
217        match expected_password {
218            Some(expected) => {
219                if password.is_some_and(|p| constant_time_eq(p.as_bytes(), expected.as_bytes())) {
220                    AuthOutcome::Authenticated { principal: None }
221                } else {
222                    AuthOutcome::Rejected
223                }
224            }
225            None => AuthOutcome::Authenticated { principal: None },
226        }
227    }
228}
229
230/// Error messages that are safe to forward to the client verbatim.
231const SAFE_ERROR_PREFIXES: &[&str] = &[
232    "table not found",
233    "column not found",
234    "parse error",
235    "type mismatch",
236    "unknown table",
237    "unknown column",
238    "unknown function",
239    "syntax error",
240    "expected",
241    "unexpected",
242    "missing",
243    "duplicate",
244    "invalid",
245    "cannot",
246    "no such",
247    "already exists",
248    "permission denied",
249    "row too large",
250    "unique constraint violation",
251    // Resource-limit errors carry actionable guidance (e.g. "add a LIMIT
252    // clause") and leak no internal state, so surface them verbatim instead
253    // of masking them to the generic message. See QueryError::{SortLimit,
254    // JoinLimit,MemoryLimit}Exceeded in crates/query/src/result.rs.
255    "sort input exceeds",
256    "join result exceeds",
257    "query exceeded memory budget",
258];
259
260/// Sanitize an error message before sending it to the client.
261/// Known safe errors are passed through; everything else is replaced
262/// with a generic message to avoid leaking internal details.
263fn sanitize_error(e: &str) -> String {
264    let lower = e.to_lowercase();
265    for prefix in SAFE_ERROR_PREFIXES {
266        if lower.starts_with(prefix) {
267            return e.to_string();
268        }
269    }
270    "query execution error".into()
271}
272
273/// Write a message to the client with a timeout. Returns false if the
274/// write failed or timed out (caller should close the connection).
275async fn write_msg<W: AsyncWrite + Unpin>(writer: &mut BufWriter<W>, msg: &Message) -> bool {
276    let write_fut = async {
277        if msg.write_to(writer).await.is_err() {
278            return false;
279        }
280        writer.flush().await.is_ok()
281    };
282    tokio::time::timeout(WRITE_TIMEOUT, write_fut)
283        .await
284        .unwrap_or_default()
285}
286
287/// Options for a single connection, bundled to keep `handle_connection`'s
288/// argument list short.
289pub struct ConnOpts<'a> {
290    pub engine: Arc<RwLock<Engine>>,
291    /// Expected client password. Wrapped in `Zeroizing` so the secret is wiped
292    /// from memory on drop (defends against leaking via a core dump).
293    pub expected_password: Option<Zeroizing<String>>,
294    /// Multi-user store loaded from the data dir at startup. When it has users,
295    /// the handshake authenticates `(username, password)` against it; when empty
296    /// the server falls back to `expected_password`. Shared across connections.
297    pub users: Arc<UserStore>,
298    pub shutdown_rx: &'a mut watch::Receiver<bool>,
299    pub idle_timeout: Duration,
300    pub query_timeout: Duration,
301    pub rate_limiter: Option<&'a AuthRateLimiter>,
302    pub peer_addr: Option<std::net::SocketAddr>,
303}
304
305/// Execute a query against the engine under the RwLock. Read-only
306/// statements acquire `.read()` so concurrent SELECTs can scan in
307/// parallel; mutations acquire `.write()`.
308///
309/// When `principal` is `Some`, the user's role is enforced first: a role
310/// without the `Write` permission (i.e. `readonly`) gets a clean
311/// "permission denied" error for any non-read statement, before any lock
312/// is taken or any engine state is touched.
313fn dispatch_query(
314    engine: &Arc<RwLock<Engine>>,
315    query: &str,
316    principal: Option<&Principal>,
317) -> Result<QueryResult, QueryError> {
318    let stmt_result = parser::parse(query).map_err(|e| e.to_string());
319
320    // Role enforcement happens on the parsed AST. Statements that fail to
321    // parse fall through — the engine returns the parse error itself and
322    // can never execute anything for them.
323    if let Ok(stmt) = &stmt_result {
324        check_statement_permitted(principal, stmt)?;
325    }
326
327    let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
328    if can_try_read {
329        let res = {
330            let eng = engine
331                .read()
332                .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
333            eng.execute_powql_readonly(query)
334        };
335        match res {
336            Ok(r) => return Ok(r),
337            Err(QueryError::ReadonlyNeedsWrite) => {
338                // Escalate: fall through to the write path below.
339            }
340            Err(e) => return Err(e),
341        }
342    }
343
344    let mut eng = engine
345        .write()
346        .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
347    eng.execute_powql(query)
348}
349
350/// Convert a wire parameter into the query-crate [`ParamValue`] used for
351/// token-level binding.
352fn wire_param_to_value(p: &WireParam) -> powdb_query::ast::ParamValue {
353    use powdb_query::ast::ParamValue;
354    match p {
355        WireParam::Null => ParamValue::Null,
356        WireParam::Int(v) => ParamValue::Int(*v),
357        WireParam::Float(v) => ParamValue::Float(*v),
358        WireParam::Bool(v) => ParamValue::Bool(*v),
359        WireParam::Str(s) => ParamValue::Str(s.clone()),
360    }
361}
362
363/// Parameterized counterpart of [`dispatch_query`]. Routes through the exact
364/// same role-enforcement and read/write escalation logic, but binds the
365/// `$N` placeholders at the token level via the query crate's
366/// `parse_with_params` path. A string parameter can never change the query's
367/// shape — it is substituted as a literal token, not interpolated text.
368fn dispatch_query_with_params(
369    engine: &Arc<RwLock<Engine>>,
370    query: &str,
371    params: &[WireParam],
372    principal: Option<&Principal>,
373) -> Result<QueryResult, QueryError> {
374    let bound: Vec<powdb_query::ast::ParamValue> = params.iter().map(wire_param_to_value).collect();
375
376    // Parse once (with params bound) so role enforcement and read/write
377    // classification see exactly the statement that will execute.
378    let stmt_result = parser::parse_with_params(query, &bound).map_err(|e| e.to_string());
379
380    if let Ok(stmt) = &stmt_result {
381        check_statement_permitted(principal, stmt)?;
382    }
383
384    let can_try_read = matches!(&stmt_result, Ok(s) if is_read_only_statement(s));
385    if can_try_read {
386        let res = {
387            let eng = engine
388                .read()
389                .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
390            eng.execute_powql_readonly_with_params(query, &bound)
391        };
392        match res {
393            Ok(r) => return Ok(r),
394            Err(QueryError::ReadonlyNeedsWrite) => {
395                // Escalate to the write path below.
396            }
397            Err(e) => return Err(e),
398        }
399    }
400
401    let mut eng = engine
402        .write()
403        .map_err(|e| QueryError::Execution(format!("lock poisoned: {e}")))?;
404    eng.execute_powql_with_params(query, &bound)
405}
406
407pub async fn handle_connection<S>(stream: S, opts: ConnOpts<'_>)
408where
409    S: AsyncRead + AsyncWrite + Unpin,
410{
411    let ConnOpts {
412        engine,
413        expected_password,
414        users,
415        shutdown_rx,
416        idle_timeout,
417        query_timeout,
418        rate_limiter,
419        peer_addr,
420    } = opts;
421
422    let peer = peer_addr
423        .map(|a| a.to_string())
424        .unwrap_or_else(|| "unknown".into());
425    let peer_ip = peer_addr.map(|a| a.ip());
426
427    let (reader, writer) = tokio::io::split(stream);
428    let mut reader = BufReader::new(reader);
429    let mut writer = BufWriter::new(writer);
430
431    // Wait for Connect message (with idle timeout).
432    // Accept Ping messages before authentication so load balancers can
433    // health-check without completing a full CONNECT handshake.
434    // Uses the smaller pre-auth payload limit (4 KB) to prevent memory abuse.
435    let connect_msg = loop {
436        match tokio::time::timeout(idle_timeout, Message::read_from_preauth(&mut reader)).await {
437            Ok(Ok(Some(Message::Ping))) => {
438                debug!(peer = %peer, "pre-auth ping");
439                if !write_msg(&mut writer, &Message::Pong).await {
440                    return;
441                }
442                continue;
443            }
444            Ok(Ok(Some(msg))) => break msg,
445            Ok(Ok(None)) => {
446                debug!(peer = %peer, "client closed before CONNECT");
447                return;
448            }
449            Ok(Err(e)) => {
450                error!(peer = %peer, error = %e, "error reading CONNECT");
451                return;
452            }
453            Err(_) => {
454                warn!(peer = %peer, "idle timeout waiting for CONNECT");
455                return;
456            }
457        }
458    };
459
460    // The authenticated identity for this connection. Bound at connect time
461    // and enforced on every query by `dispatch_query`.
462    let principal: Option<Principal>;
463    match connect_msg {
464        Message::Connect {
465            db_name,
466            password,
467            username,
468        } => {
469            // Check rate limiting before verifying credentials.
470            if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
471                if is_rate_limited(limiter, ip) {
472                    warn!(peer = %peer, "rate limited: too many auth failures");
473                    let err = Message::Error {
474                        message: "too many auth failures, try again later".into(),
475                    };
476                    write_msg(&mut writer, &err).await;
477                    return;
478                }
479            }
480
481            let outcome = authenticate_connect(
482                &users,
483                expected_password.as_ref().map(|p| p.as_str()),
484                username.as_deref(),
485                password.as_ref().map(|p| p.as_str()),
486            );
487
488            match outcome {
489                AuthOutcome::Rejected => {
490                    warn!(peer = %peer, db = %db_name, "auth rejected");
491                    // Record the failure for rate limiting.
492                    if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
493                        record_auth_failure(limiter, ip);
494                    }
495                    let err = Message::Error {
496                        message: "authentication failed".into(),
497                    };
498                    write_msg(&mut writer, &err).await;
499                    return;
500                }
501                AuthOutcome::Authenticated {
502                    principal: auth_principal,
503                } => {
504                    // Auth succeeded — clear any prior failure count.
505                    if let (Some(limiter), Some(ip)) = (rate_limiter, peer_ip) {
506                        clear_auth_failures(limiter, ip);
507                    }
508                    match &auth_principal {
509                        Some(p) => {
510                            info!(peer = %peer, db = %db_name, user = %p.name, role = %p.role, "authenticated");
511                        }
512                        None => {
513                            info!(peer = %peer, db = %db_name, "client connected");
514                        }
515                    }
516                    principal = auth_principal;
517                }
518            }
519
520            let ok = Message::ConnectOk {
521                version: env!("CARGO_PKG_VERSION").into(),
522            };
523            if !write_msg(&mut writer, &ok).await {
524                return;
525            }
526        }
527        _ => {
528            warn!(peer = %peer, "first message was not CONNECT");
529            let err = Message::Error {
530                message: "expected CONNECT".into(),
531            };
532            write_msg(&mut writer, &err).await;
533            return;
534        }
535    }
536
537    // Main query loop with idle timeout and shutdown awareness.
538    loop {
539        let msg = tokio::select! {
540            // Read next message with idle timeout.
541            result = tokio::time::timeout(idle_timeout, Message::read_from(&mut reader)) => {
542                match result {
543                    Ok(Ok(Some(msg))) => msg,
544                    Ok(Ok(None)) => break,
545                    Ok(Err(e)) => {
546                        error!(peer = %peer, error = %e, "read error");
547                        break;
548                    }
549                    Err(_) => {
550                        info!(peer = %peer, "idle timeout, closing connection");
551                        let err = Message::Error { message: "idle timeout".into() };
552                        write_msg(&mut writer, &err).await;
553                        break;
554                    }
555                }
556            }
557            // If server is shutting down, notify client and close.
558            _ = shutdown_rx.changed() => {
559                if *shutdown_rx.borrow() {
560                    info!(peer = %peer, "server shutting down, closing connection");
561                    let err = Message::Error { message: "server shutting down".into() };
562                    write_msg(&mut writer, &err).await;
563                    break;
564                }
565                continue;
566            }
567        };
568
569        let response = match msg {
570            Message::Ping => {
571                debug!(peer = %peer, "ping");
572                Message::Pong
573            }
574            Message::Query { query } => {
575                if query.len() > MAX_QUERY_LENGTH {
576                    Message::Error {
577                        message: format!(
578                            "query too large: {} bytes (max {})",
579                            query.len(),
580                            MAX_QUERY_LENGTH
581                        ),
582                    }
583                } else {
584                    debug!(peer = %peer, query = %query, "received query");
585                    let handle = tokio::task::spawn_blocking({
586                        let engine = engine.clone();
587                        let query = query.clone();
588                        let principal = principal.clone();
589                        move || dispatch_query(&engine, &query, principal.as_ref())
590                    });
591                    let abort_handle = handle.abort_handle();
592                    match tokio::time::timeout(query_timeout, handle).await {
593                        Ok(Ok(Ok(result))) => query_result_to_message(result),
594                        Ok(Ok(Err(e))) => Message::Error {
595                            message: sanitize_error(&e.to_string()),
596                        },
597                        Ok(Err(e)) => Message::Error {
598                            message: format!("internal error: {e}"),
599                        },
600                        Err(_) => {
601                            abort_handle.abort();
602                            warn!(peer = %peer, query = %query, "query timeout exceeded");
603                            Message::Error {
604                                message: "query timeout exceeded".into(),
605                            }
606                        }
607                    }
608                }
609            }
610            Message::QueryWithParams { query, params } => {
611                if query.len() > MAX_QUERY_LENGTH {
612                    Message::Error {
613                        message: format!(
614                            "query too large: {} bytes (max {})",
615                            query.len(),
616                            MAX_QUERY_LENGTH
617                        ),
618                    }
619                } else {
620                    debug!(peer = %peer, query = %query, n_params = params.len(), "received parameterized query");
621                    let handle = tokio::task::spawn_blocking({
622                        let engine = engine.clone();
623                        let query = query.clone();
624                        let params = params.clone();
625                        let principal = principal.clone();
626                        move || {
627                            dispatch_query_with_params(&engine, &query, &params, principal.as_ref())
628                        }
629                    });
630                    let abort_handle = handle.abort_handle();
631                    match tokio::time::timeout(query_timeout, handle).await {
632                        Ok(Ok(Ok(result))) => query_result_to_message(result),
633                        Ok(Ok(Err(e))) => Message::Error {
634                            message: sanitize_error(&e.to_string()),
635                        },
636                        Ok(Err(e)) => Message::Error {
637                            message: format!("internal error: {e}"),
638                        },
639                        Err(_) => {
640                            abort_handle.abort();
641                            warn!(peer = %peer, query = %query, "query timeout exceeded");
642                            Message::Error {
643                                message: "query timeout exceeded".into(),
644                            }
645                        }
646                    }
647                }
648            }
649            Message::Disconnect => {
650                debug!(peer = %peer, "received DISCONNECT");
651                break;
652            }
653            _ => Message::Error {
654                message: "unexpected message type".into(),
655            },
656        };
657
658        if !write_msg(&mut writer, &response).await {
659            break;
660        }
661    }
662
663    info!(peer = %peer, "client disconnected");
664}
665
666fn query_result_to_message(result: QueryResult) -> Message {
667    match result {
668        QueryResult::Rows { columns, rows } => {
669            let str_rows: Vec<Vec<String>> = rows
670                .iter()
671                .map(|row| row.iter().map(value_to_display).collect())
672                .collect();
673            Message::ResultRows {
674                columns,
675                rows: str_rows,
676            }
677        }
678        QueryResult::Scalar(val) => Message::ResultScalar {
679            value: value_to_display(&val),
680        },
681        QueryResult::Modified(n) => Message::ResultOk { affected: n },
682        QueryResult::Created(name) => Message::ResultMessage {
683            message: format!("type {name} created"),
684        },
685        QueryResult::Executed { message } => Message::ResultMessage { message },
686    }
687}
688
689fn value_to_display(v: &Value) -> String {
690    match v {
691        Value::Int(n)      => n.to_string(),
692        Value::Float(n)    => format!("{n}"),
693        Value::Bool(b)     => b.to_string(),
694        Value::Str(s)      => s.clone(),
695        Value::DateTime(t) => format!("{t}"),
696        Value::Uuid(u)     => format!("{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
697            u[0], u[1], u[2], u[3], u[4], u[5], u[6], u[7],
698            u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15]),
699        Value::Bytes(b)    => format!("<{} bytes>", b.len()),
700        // NULL is serialized as the bareword "null" on the wire. This is the
701        // sentinel the TypeScript client's typed-row decoder already
702        // documents and matches (`coerceValue` treats the exact token
703        // "null" as NULL for non-str columns); the previous "{}" rendering
704        // was a bug that neither the TS client nor the CLI recognized.
705        Value::Empty       => "null".into(),
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    // ---- Wire NULL rendering (Fix: remote protocol rendered NULL as `{}`) ----
714
715    #[test]
716    fn null_serializes_as_null_bareword_on_wire() {
717        assert_eq!(value_to_display(&Value::Empty), "null");
718    }
719
720    // ---- Error sanitization allowlist ----
721
722    #[test]
723    fn unique_violation_error_surfaces_to_remote_clients() {
724        // The storage layer reports the actionable message; the server must
725        // not replace it with the generic "query execution error".
726        assert_eq!(
727            sanitize_error("unique constraint violation on User.email"),
728            "unique constraint violation on User.email"
729        );
730    }
731
732    #[test]
733    fn internal_errors_stay_generic() {
734        assert_eq!(
735            sanitize_error("some internal io panic detail"),
736            "query execution error"
737        );
738    }
739
740    #[test]
741    fn resource_limit_errors_surface_actionable_hints() {
742        // These carry user-actionable guidance and leak no internal state, so
743        // they must reach the client verbatim — not be masked to the generic
744        // message. The exact strings come from QueryError's Display impl
745        // (crates/query/src/result.rs).
746        for msg in [
747            "sort input exceeds row limit — add a LIMIT clause",
748            "join result exceeds row limit",
749            "query exceeded memory budget: requested 100 bytes, limit 50 bytes",
750        ] {
751            assert_eq!(sanitize_error(msg), msg, "should pass through verbatim");
752        }
753    }
754
755    // ---- Role enforcement (Fix: readonly role was not enforced) ----
756
757    fn parsed(q: &str) -> powdb_query::ast::Statement {
758        parser::parse(q).unwrap()
759    }
760
761    fn principal(role: &str) -> Option<Principal> {
762        Some(Principal {
763            name: "u".into(),
764            role: role.into(),
765        })
766    }
767
768    #[test]
769    fn readonly_can_read_but_not_write() {
770        let p = principal("readonly");
771        // Reads pass.
772        assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
773        assert!(check_statement_permitted(p.as_ref(), &parsed("count(User)")).is_ok());
774        assert!(check_statement_permitted(p.as_ref(), &parsed("explain User")).is_ok());
775        // Writes, DDL, and transaction control are denied.
776        for q in [
777            r#"insert User { name := "x" }"#,
778            "User filter .id = 1 update { age := 2 }",
779            "User filter .id = 1 delete",
780            "drop User",
781            "alter User add column c: str",
782            "type T { required id: int }",
783            "begin",
784            "commit",
785            "rollback",
786        ] {
787            let err = check_statement_permitted(p.as_ref(), &parsed(q))
788                .expect_err(&format!("must deny: {q}"));
789            assert!(
790                err.to_string().contains("permission denied"),
791                "unexpected error for {q}: {err}"
792            );
793        }
794    }
795
796    #[test]
797    fn readwrite_and_admin_have_full_query_access() {
798        for role in ["readwrite", "admin"] {
799            let p = principal(role);
800            assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
801            assert!(check_statement_permitted(
802                p.as_ref(),
803                &parsed(r#"insert User { name := "x" }"#)
804            )
805            .is_ok());
806            assert!(check_statement_permitted(p.as_ref(), &parsed("drop User")).is_ok());
807        }
808    }
809
810    #[test]
811    fn unknown_role_fails_closed_for_writes() {
812        let p = principal("mystery");
813        assert!(check_statement_permitted(p.as_ref(), &parsed("User")).is_ok());
814        assert!(
815            check_statement_permitted(p.as_ref(), &parsed(r#"insert User { name := "x" }"#))
816                .is_err()
817        );
818    }
819
820    #[test]
821    fn no_principal_means_full_access() {
822        // Shared-password / open mode: no per-user identity, no restriction.
823        assert!(check_statement_permitted(None, &parsed("drop User")).is_ok());
824        assert!(check_statement_permitted(None, &parsed(r#"insert User { name := "x" }"#)).is_ok());
825    }
826
827    fn store_with_alice() -> UserStore {
828        let mut s = UserStore::new();
829        s.create_user("alice", "pw", "readwrite").unwrap();
830        s
831    }
832
833    // ---- Empty store: legacy shared-password fallback ----
834
835    #[test]
836    fn empty_store_no_password_is_open() {
837        let s = UserStore::new();
838        assert_eq!(
839            authenticate_connect(&s, None, None, None),
840            AuthOutcome::Authenticated { principal: None }
841        );
842        // Even a stray username/password is accepted (legacy open behavior).
843        assert_eq!(
844            authenticate_connect(&s, None, Some("x"), Some("y")),
845            AuthOutcome::Authenticated { principal: None }
846        );
847    }
848
849    #[test]
850    fn empty_store_correct_shared_password_succeeds() {
851        let s = UserStore::new();
852        assert_eq!(
853            authenticate_connect(&s, Some("pw"), None, Some("pw")),
854            AuthOutcome::Authenticated { principal: None }
855        );
856    }
857
858    #[test]
859    fn empty_store_wrong_shared_password_rejected() {
860        let s = UserStore::new();
861        assert_eq!(
862            authenticate_connect(&s, Some("pw"), None, Some("bad")),
863            AuthOutcome::Rejected
864        );
865    }
866
867    #[test]
868    fn empty_store_missing_password_rejected_when_expected() {
869        let s = UserStore::new();
870        assert_eq!(
871            authenticate_connect(&s, Some("pw"), None, None),
872            AuthOutcome::Rejected
873        );
874    }
875
876    #[test]
877    fn empty_store_ignores_username_for_shared_password() {
878        // A new client may send a username even against a shared-password
879        // server; the username is ignored and the password still governs.
880        let s = UserStore::new();
881        assert_eq!(
882            authenticate_connect(&s, Some("pw"), Some("whoever"), Some("pw")),
883            AuthOutcome::Authenticated { principal: None }
884        );
885    }
886
887    // ---- Populated store: multi-user auth ----
888
889    #[test]
890    fn user_auth_success_binds_principal() {
891        let s = store_with_alice();
892        assert_eq!(
893            authenticate_connect(&s, None, Some("alice"), Some("pw")),
894            AuthOutcome::Authenticated {
895                principal: Some(Principal {
896                    name: "alice".into(),
897                    role: "readwrite".into(),
898                })
899            }
900        );
901    }
902
903    #[test]
904    fn user_auth_wrong_password_rejected() {
905        let s = store_with_alice();
906        assert_eq!(
907            authenticate_connect(&s, None, Some("alice"), Some("bad")),
908            AuthOutcome::Rejected
909        );
910    }
911
912    #[test]
913    fn user_auth_unknown_user_rejected() {
914        let s = store_with_alice();
915        assert_eq!(
916            authenticate_connect(&s, None, Some("mallory"), Some("pw")),
917            AuthOutcome::Rejected
918        );
919    }
920
921    #[test]
922    fn user_auth_missing_username_rejected() {
923        let s = store_with_alice();
924        assert_eq!(
925            authenticate_connect(&s, None, None, Some("pw")),
926            AuthOutcome::Rejected
927        );
928    }
929
930    #[test]
931    fn user_auth_missing_password_rejected() {
932        let s = store_with_alice();
933        assert_eq!(
934            authenticate_connect(&s, Some("pw"), Some("alice"), None),
935            AuthOutcome::Rejected
936        );
937    }
938
939    #[test]
940    fn user_auth_ignores_shared_password_when_users_present() {
941        // With users present, the shared password is irrelevant: supplying it as
942        // the password without a valid user must NOT authenticate.
943        let s = store_with_alice();
944        assert_eq!(
945            authenticate_connect(&s, Some("shared"), None, Some("shared")),
946            AuthOutcome::Rejected
947        );
948    }
949}