Skip to main content

netget/state/
sqlite.rs

1//! SQLite database management
2//!
3//! Provides database instances, schema tracking, and query execution for protocols
4
5use crate::utils::clock::Instant;
6#[cfg(feature = "sqlite")]
7use anyhow::{Context, Result};
8#[cfg(feature = "sqlite")]
9use rusqlite::Connection;
10#[cfg(feature = "sqlite")]
11use std::collections::HashMap;
12#[cfg(feature = "sqlite")]
13use std::path::PathBuf;
14#[cfg(feature = "sqlite")]
15use std::sync::Mutex; // Always import for DatabaseInstance fields
16
17use crate::state::{ClientId, ServerId};
18
19/// Path value that marks an in-memory (non-file-backed) database.
20pub const MEMORY_DATABASE_PATH: &str = ":memory:";
21
22/// Longest accepted database name.
23pub const MAX_DATABASE_NAME_LEN: usize = 64;
24
25/// Hard limits apply before copying query output into JSON.
26pub const MAX_QUERY_ROWS: usize = 10_000;
27pub const MAX_QUERY_RESULT_BYTES: usize = 8 * 1024 * 1024;
28pub const MAX_QUERY_VALUE_BYTES: i32 = 8 * 1024 * 1024;
29pub const MAX_QUERY_SQL_BYTES: i32 = 1024 * 1024;
30/// Bound CPU work as well as output size (e.g. recursive CTEs with no output).
31pub const MAX_QUERY_VM_STEPS: usize = 10_000_000;
32pub const MAX_QUERY_DURATION: std::time::Duration = std::time::Duration::from_secs(5);
33
34/// SQLite identifiers may contain quotes and punctuation. Quote names read from
35/// sqlite_master before interpolating them into introspection/count statements.
36#[cfg(feature = "sqlite")]
37fn quoted_identifier(name: &str) -> String {
38    format!("\"{}\"", name.replace('"', "\"\""))
39}
40
41/// Validate a database name supplied by the model.
42///
43/// The name is not just a label: `create_database` turns it into a filesystem path
44/// (`./netget_db_<name>.db`) that `delete_database` later `remove_file`s. A name
45/// containing `/`, `..`, a NUL, or a leading `/` therefore reads and *destroys* files
46/// outside the working directory. Generated text must never reach a path builder
47/// unchecked, so the name is checked against a strict allowlist and **rejected** rather
48/// than sanitised — a silently rewritten name would make the model's own bookkeeping
49/// (it refers to databases by name) disagree with what exists on disk.
50pub fn validate_database_name(name: &str) -> anyhow::Result<()> {
51    if name.is_empty() {
52        anyhow::bail!(
53            "Invalid database name: name must not be empty. \
54             Allowed: 1-{} characters from A-Z, a-z, 0-9, '_' and '-'.",
55            MAX_DATABASE_NAME_LEN
56        );
57    }
58    if name.len() > MAX_DATABASE_NAME_LEN {
59        anyhow::bail!(
60            "Invalid database name '{}': {} bytes exceeds the {}-byte limit.",
61            name,
62            name.len(),
63            MAX_DATABASE_NAME_LEN
64        );
65    }
66    if let Some(bad) = name
67        .chars()
68        .find(|c| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '-'))
69    {
70        anyhow::bail!(
71            "Invalid database name '{}': character {:?} is not allowed. \
72             A database name may contain only A-Z, a-z, 0-9, '_' and '-' \
73             (it becomes the filename './netget_db_<name>.db').",
74            name,
75            bad
76        );
77    }
78    Ok(())
79}
80
81/// The one filesystem location a file-backed database of this name may occupy.
82///
83/// Returns an error for any name [`validate_database_name`] rejects, so callers cannot
84/// build the path first and validate later.
85pub fn database_file_path(name: &str) -> anyhow::Result<String> {
86    validate_database_name(name)?;
87    Ok(format!("./netget_db_{}.db", name))
88}
89
90/// Unique identifier for a database instance
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
92pub struct DatabaseId(u32);
93
94impl DatabaseId {
95    /// Create a new database ID from a u32
96    pub fn new(id: u32) -> Self {
97        Self(id)
98    }
99
100    /// Get the raw ID value
101    pub fn as_u32(&self) -> u32 {
102        self.0
103    }
104
105    /// Parse from string (expects format "db-123" or just "123")
106    pub fn from_string(s: &str) -> Option<Self> {
107        let s = s.trim();
108        let id_str = s.strip_prefix("db-").unwrap_or(s);
109        id_str.parse::<u32>().ok().map(Self)
110    }
111}
112
113impl std::fmt::Display for DatabaseId {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        write!(f, "db-{}", self.0)
116    }
117}
118
119/// Database owner (server or client)
120#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
121pub enum DatabaseOwner {
122    /// Database owned by a server
123    Server(ServerId),
124    /// Database owned by a client
125    Client(ClientId),
126    /// Global database (not tied to any server/client)
127    Global,
128}
129
130impl std::fmt::Display for DatabaseOwner {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        match self {
133            Self::Server(id) => write!(f, "Server {}", id),
134            Self::Client(id) => write!(f, "Client {}", id),
135            Self::Global => write!(f, "Global"),
136        }
137    }
138}
139
140/// Table schema information
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct TableSchema {
143    /// Table name
144    pub name: String,
145    /// Column definitions (e.g., "id INTEGER PRIMARY KEY", "name TEXT NOT NULL")
146    pub columns: Vec<String>,
147    /// Row count
148    pub row_count: u64,
149}
150
151/// Database instance metadata and schema
152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
153pub struct DatabaseInstance {
154    /// Unique database ID
155    pub id: DatabaseId,
156    /// Database name (user-friendly)
157    pub name: String,
158    /// Database path (or ":memory:" for in-memory)
159    pub path: String,
160    /// Owner (server, client, or global)
161    pub owner: DatabaseOwner,
162    /// Table schemas
163    pub tables: Vec<TableSchema>,
164    /// When the database was created (not serialized)
165    #[serde(skip, default = "Instant::now")]
166    pub created_at: Instant,
167    /// Last query execution time (not serialized)
168    #[serde(skip, default)]
169    pub last_query_at: Option<Instant>,
170    /// Total number of queries executed
171    pub query_count: u64,
172}
173
174impl DatabaseInstance {
175    /// Create a new database instance
176    #[cfg(feature = "sqlite")]
177    pub fn new(id: DatabaseId, name: String, path: String, owner: DatabaseOwner) -> Self {
178        Self {
179            id,
180            name,
181            path,
182            owner,
183            tables: Vec::new(),
184            created_at: Instant::now(),
185            last_query_at: None,
186            query_count: 0,
187        }
188    }
189
190    /// Check if this is an in-memory database
191    pub fn is_memory(&self) -> bool {
192        self.path == ":memory:"
193    }
194
195    /// Update table schemas by introspecting the database
196    #[cfg(feature = "sqlite")]
197    pub fn refresh_schema(&mut self, conn: &Connection) -> Result<()> {
198        self.tables.clear();
199
200        // Get all tables
201        let mut stmt = conn.prepare(
202            "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
203        )?;
204        let table_names: Vec<String> = stmt
205            .query_map([], |row| row.get(0))?
206            .collect::<rusqlite::Result<_>>()?;
207
208        // Get schema for each table
209        for table_name in table_names {
210            let mut columns = Vec::new();
211
212            // Get column information
213            let quoted_name = quoted_identifier(&table_name);
214            let mut stmt = conn.prepare(&format!("PRAGMA table_info({quoted_name})"))?;
215            let rows = stmt.query_map([], |row| {
216                let name: String = row.get(1)?;
217                let type_name: String = row.get(2)?;
218                let not_null: i32 = row.get(3)?;
219                let pk: i32 = row.get(5)?;
220
221                let mut col_def = format!("{} {}", name, type_name);
222                if pk > 0 {
223                    col_def.push_str(" PRIMARY KEY");
224                } else if not_null != 0 {
225                    col_def.push_str(" NOT NULL");
226                }
227
228                Ok(col_def)
229            })?;
230
231            for row in rows {
232                columns.push(row?);
233            }
234
235            // Get row count
236            let row_count: u64 =
237                conn.query_row(&format!("SELECT COUNT(*) FROM {quoted_name}"), [], |row| {
238                    row.get(0)
239                })?;
240
241            self.tables.push(TableSchema {
242                name: table_name,
243                columns,
244                row_count,
245            });
246        }
247
248        Ok(())
249    }
250
251    /// Get a summary of the database schema for LLM prompts
252    pub fn schema_summary(&self) -> String {
253        if self.tables.is_empty() {
254            return format!("{} ({}): No tables", self.name, self.id);
255        }
256
257        let mut summary = format!("{} ({}):\n", self.name, self.id);
258        for table in &self.tables {
259            summary.push_str(&format!("  - {} ({} rows)\n", table.name, table.row_count));
260            for column in &table.columns {
261                summary.push_str(&format!("      {}\n", column));
262            }
263        }
264        summary
265    }
266
267    /// Increment query count and update last query time
268    #[cfg(feature = "sqlite")]
269    pub fn record_query(&mut self) {
270        self.query_count += 1;
271        self.last_query_at = Some(Instant::now());
272    }
273
274    /// Update row counts for all tables (more efficient than full schema refresh)
275    #[cfg(feature = "sqlite")]
276    pub fn update_row_counts(&mut self, conn: &Connection) -> Result<()> {
277        for table in &mut self.tables {
278            table.row_count = conn.query_row(
279                &format!("SELECT COUNT(*) FROM {}", quoted_identifier(&table.name)),
280                [],
281                |row| row.get(0),
282            )?;
283        }
284        Ok(())
285    }
286}
287
288/// Database connection wrapper (single connection protected by Mutex)
289#[cfg(feature = "sqlite")]
290pub struct DatabaseConnection {
291    /// SQLite connection protected by Mutex
292    conn: Mutex<Connection>,
293    /// Database metadata
294    instance: DatabaseInstance,
295}
296
297/// Refuse every statement that would make the connection touch a file other than
298/// the one it was opened on.
299///
300/// `ATTACH` reports its filename only when it is a string literal; a computed one
301/// (`'/etc/' || 'x'`) reaches the authorizer as the raw `SQLITE_ATTACH` code with no
302/// argument, so that case is matched on the code, not on the parsed variant. An
303/// empty filename or `:memory:` is a private temporary database and stays allowed —
304/// a plain `VACUUM` attaches one.
305#[cfg(feature = "sqlite")]
306fn filesystem_authorizer(ctx: rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization {
307    use rusqlite::hooks::{AuthAction, Authorization};
308    match ctx.action {
309        AuthAction::Attach { filename } => {
310            if filename.is_empty() || filename == MEMORY_DATABASE_PATH {
311                Authorization::Allow
312            } else {
313                Authorization::Deny
314            }
315        }
316        AuthAction::Unknown { code, .. } if code == rusqlite::ffi::SQLITE_ATTACH => {
317            Authorization::Deny
318        }
319        AuthAction::Function { function_name }
320            if function_name.eq_ignore_ascii_case("load_extension") =>
321        {
322            Authorization::Deny
323        }
324        _ => Authorization::Allow,
325    }
326}
327
328#[cfg(feature = "sqlite")]
329impl DatabaseConnection {
330    /// Create a new database connection
331    pub fn new(instance: DatabaseInstance) -> Result<Self> {
332        let conn =
333            Connection::open(&instance.path).context("Failed to open database connection")?;
334
335        // The SQL on this connection is model-authored, so the connection must not be a
336        // way to the filesystem. `create_database` validates the *name* precisely so a
337        // model can never choose a path; `ATTACH DATABASE '<path>'` and
338        // `VACUUM INTO '<path>'` (which attaches its target internally) would otherwise
339        // create, overwrite or read any SQLite file the process can reach in one
340        // statement. `load_extension` is already disabled at the C API level; denying
341        // it here says so where the other refusals live.
342        conn.authorizer(Some(filesystem_authorizer));
343
344        conn.set_limit(
345            rusqlite::limits::Limit::SQLITE_LIMIT_LENGTH,
346            MAX_QUERY_VALUE_BYTES,
347        )
348        .context("Failed to set SQLite value size limit")?;
349        conn.set_limit(
350            rusqlite::limits::Limit::SQLITE_LIMIT_SQL_LENGTH,
351            MAX_QUERY_SQL_BYTES,
352        )
353        .context("Failed to set SQLite SQL size limit")?;
354        Ok(Self {
355            conn: Mutex::new(conn),
356            instance,
357        })
358    }
359
360    /// Get the database metadata
361    pub fn instance(&self) -> &DatabaseInstance {
362        &self.instance
363    }
364
365    /// Get a mutable reference to the database metadata
366    pub fn instance_mut(&mut self) -> &mut DatabaseInstance {
367        &mut self.instance
368    }
369
370    /// Execute a SQL query and return results as JSON
371    ///
372    /// The connection mutex is taken with `unwrap_or_else(|e| e.into_inner())` rather than
373    /// `unwrap()`: a panic anywhere under this lock would otherwise poison it permanently
374    /// and every later query on this database — including the ones that would report the
375    /// problem — would panic in turn. The `Connection` itself stays usable.
376    pub fn execute_query(&mut self, sql: &str) -> Result<QueryResult> {
377        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
378
379        // Record query execution
380        self.instance.record_query();
381
382        let started = std::time::Instant::now();
383        let mut steps = 0usize;
384        conn.progress_handler(
385            1000,
386            Some(move || {
387                steps += 1000;
388                steps >= MAX_QUERY_VM_STEPS || started.elapsed() >= MAX_QUERY_DURATION
389            }),
390        );
391        let result = (|| -> Result<QueryResult> {
392            use rusqlite::fallible_iterator::FallibleIterator;
393            let mut batch = rusqlite::Batch::new(&conn, sql);
394            let mut stmt = batch.next()?.context("query contains no SQL statement")?;
395            anyhow::ensure!(
396                batch.next()?.is_none(),
397                "execute_query accepts exactly one SQL statement"
398            );
399            let readonly = stmt.readonly();
400            let result = if stmt.column_count() > 0 {
401                let column_names: Vec<String> = stmt
402                    .column_names()
403                    .iter()
404                    .map(|name| name.to_string())
405                    .collect();
406                let mut bytes: usize = column_names
407                    .iter()
408                    .map(|name| name.len().saturating_mul(6).saturating_add(3))
409                    .sum();
410                let mut rows = Vec::new();
411                let mut query_rows = stmt.query([])?;
412                while let Some(row) = query_rows.next()? {
413                    anyhow::ensure!(rows.len() < MAX_QUERY_ROWS, "query result exceeds {} rows; use LIMIT (a modifying statement may already have executed)", MAX_QUERY_ROWS);
414                    let mut values = Vec::with_capacity(column_names.len());
415                    bytes = bytes.saturating_add(2);
416                    for i in 0..column_names.len() {
417                        use rusqlite::types::ValueRef;
418                        let value = row.get_ref(i)?;
419                        let cost = match value {
420                            ValueRef::Text(text) => text.len().saturating_mul(6).saturating_add(3),
421                            ValueRef::Blob(blob) => blob.len().saturating_mul(2).saturating_add(3),
422                            _ => 32,
423                        };
424                        bytes = bytes.saturating_add(cost);
425                        anyhow::ensure!(bytes <= MAX_QUERY_RESULT_BYTES, "query result exceeds {} bytes; select fewer/smaller values (a modifying statement may already have executed)", MAX_QUERY_RESULT_BYTES);
426                        values.push(match value {
427                            ValueRef::Null => serde_json::Value::Null,
428                            ValueRef::Integer(value) => serde_json::json!(value),
429                            ValueRef::Real(value) => serde_json::json!(value),
430                            ValueRef::Text(value) => serde_json::Value::String(
431                                String::from_utf8_lossy(value).into_owned(),
432                            ),
433                            ValueRef::Blob(value) => serde_json::Value::String(hex::encode(value)),
434                        });
435                    }
436                    rows.push(values);
437                }
438                QueryResult::Select {
439                    columns: column_names,
440                    rows,
441                }
442            } else {
443                QueryResult::Modified {
444                    affected_rows: stmt.execute([])?,
445                }
446            };
447            drop(stmt);
448            // The prepared statement classifies WITH, comments, REPLACE, RETURNING,
449            // PRAGMA and DDL reliably; textual prefix checks do not.
450            if !readonly {
451                self.instance.refresh_schema(&conn)?;
452            }
453            Ok(result)
454        })();
455        conn.progress_handler(0, None::<fn() -> bool>);
456        result.map_err(|error| anyhow::anyhow!("SQLite query failed: {error:#}"))
457    }
458
459    /// Refresh table schemas
460    pub fn refresh_schema(&mut self) -> Result<()> {
461        let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
462        self.instance.refresh_schema(&conn)
463    }
464}
465
466/// Result of a SQL query execution
467#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
468#[serde(tag = "type")]
469pub enum QueryResult {
470    /// SELECT query result
471    Select {
472        /// Column names
473        columns: Vec<String>,
474        /// Rows (each row is an array of values)
475        rows: Vec<Vec<serde_json::Value>>,
476    },
477    /// DML/DDL query result (INSERT, UPDATE, DELETE, CREATE, etc.)
478    Modified {
479        /// Number of rows affected
480        affected_rows: usize,
481    },
482}
483
484impl QueryResult {
485    /// Format result as a human-readable string
486    pub fn format(&self) -> String {
487        match self {
488            Self::Select { columns, rows } => {
489                if rows.is_empty() {
490                    return format!("No rows returned. Columns: {}", columns.join(", "));
491                }
492
493                let mut output = String::new();
494                output.push_str(&columns.join(" | "));
495                output.push('\n');
496                output.push_str(&"-".repeat(output.len()));
497                output.push('\n');
498
499                for row in rows {
500                    let row_str: Vec<String> = row
501                        .iter()
502                        .map(|v| match v {
503                            serde_json::Value::Null => "NULL".to_string(),
504                            serde_json::Value::String(s) => s.clone(),
505                            other => other.to_string(),
506                        })
507                        .collect();
508                    output.push_str(&row_str.join(" | "));
509                    output.push('\n');
510                }
511
512                output
513            }
514            Self::Modified { affected_rows } => {
515                format!("{} row(s) affected", affected_rows)
516            }
517        }
518    }
519
520    /// Get row count
521    pub fn row_count(&self) -> usize {
522        match self {
523            Self::Select { rows, .. } => rows.len(),
524            Self::Modified { affected_rows } => *affected_rows,
525        }
526    }
527}
528
529/// Database manager (holds all database connections)
530#[cfg(feature = "sqlite")]
531pub struct DatabaseManager {
532    /// Map of database ID to connection
533    connections: HashMap<DatabaseId, DatabaseConnection>,
534}
535
536#[cfg(feature = "sqlite")]
537impl DatabaseManager {
538    /// Create a new database manager
539    pub fn new() -> Self {
540        Self {
541            connections: HashMap::new(),
542        }
543    }
544
545    /// Create a new database
546    pub fn create_database(
547        &mut self,
548        id: DatabaseId,
549        name: String,
550        path: String,
551        owner: DatabaseOwner,
552        init_sql: Option<&str>,
553    ) -> Result<()> {
554        // Defence in depth: the name reaches this point from a model-authored action, and
555        // `delete_database` will `remove_file` whatever path is recorded here. Validate at
556        // the boundary that owns the filesystem effect, not only at the caller — the
557        // caller's own check is one refactor away from being skipped.
558        //
559        // Only the *name* is constrained. `path` is a Rust-caller parameter (tests open
560        // databases at explicit temp paths) and no model-authored value reaches it except
561        // through `database_file_path`, which validates the name first.
562        validate_database_name(&name)?;
563
564        // Create database instance
565        let instance = DatabaseInstance::new(id, name, path, owner);
566
567        // Create connection
568        let mut conn = DatabaseConnection::new(instance)?;
569
570        // Execute initialization SQL if provided
571        if let Some(sql) = init_sql {
572            // Use execute_batch for multi-statement SQL
573            let db_conn = conn.conn.lock().unwrap_or_else(|e| e.into_inner());
574            db_conn.execute_batch(sql)?;
575            drop(db_conn);
576        }
577
578        // Refresh schema
579        conn.refresh_schema()?;
580
581        // Store connection
582        self.connections.insert(id, conn);
583
584        Ok(())
585    }
586
587    /// Get a database instance (metadata only)
588    pub fn get_instance(&self, id: DatabaseId) -> Option<&DatabaseInstance> {
589        self.connections.get(&id).map(|conn| conn.instance())
590    }
591
592    /// Get all database instances
593    pub fn get_all_instances(&self) -> Vec<&DatabaseInstance> {
594        self.connections
595            .values()
596            .map(|conn| conn.instance())
597            .collect()
598    }
599
600    /// Execute a query on a database
601    pub fn execute_query(&mut self, id: DatabaseId, sql: &str) -> Result<QueryResult> {
602        let conn = self
603            .connections
604            .get_mut(&id)
605            .context("Database not found")?;
606        conn.execute_query(sql)
607    }
608
609    /// Delete a database
610    pub fn delete_database(&mut self, id: DatabaseId) -> Result<()> {
611        let conn = self.connections.remove(&id).context("Database not found")?;
612
613        // Delete file if not in-memory
614        if !conn.instance().is_memory() {
615            let path = PathBuf::from(&conn.instance().path);
616            if path.exists() {
617                std::fs::remove_file(&path).context("Failed to delete database file")?;
618            }
619        }
620
621        Ok(())
622    }
623
624    /// Get databases owned by a server
625    pub fn get_databases_by_server(&self, server_id: ServerId) -> Vec<&DatabaseInstance> {
626        self.connections
627            .values()
628            .map(|conn| conn.instance())
629            .filter(|instance| instance.owner == DatabaseOwner::Server(server_id))
630            .collect()
631    }
632
633    /// Get databases owned by a client
634    pub fn get_databases_by_client(&self, client_id: ClientId) -> Vec<&DatabaseInstance> {
635        self.connections
636            .values()
637            .map(|conn| conn.instance())
638            .filter(|instance| instance.owner == DatabaseOwner::Client(client_id))
639            .collect()
640    }
641
642    /// Delete all databases owned by a server (called when server closes)
643    pub fn delete_databases_by_server(&mut self, server_id: ServerId) -> Result<()> {
644        let db_ids: Vec<DatabaseId> = self
645            .connections
646            .values()
647            .filter(|conn| conn.instance().owner == DatabaseOwner::Server(server_id))
648            .map(|conn| conn.instance().id)
649            .collect();
650
651        for id in db_ids {
652            self.delete_database(id)?;
653        }
654
655        Ok(())
656    }
657
658    /// Delete all databases owned by a client (called when client disconnects)
659    pub fn delete_databases_by_client(&mut self, client_id: ClientId) -> Result<()> {
660        let db_ids: Vec<DatabaseId> = self
661            .connections
662            .values()
663            .filter(|conn| conn.instance().owner == DatabaseOwner::Client(client_id))
664            .map(|conn| conn.instance().id)
665            .collect();
666
667        for id in db_ids {
668            self.delete_database(id)?;
669        }
670
671        Ok(())
672    }
673}
674
675#[cfg(not(feature = "sqlite"))]
676pub struct DatabaseManager;
677
678#[cfg(not(feature = "sqlite"))]
679impl DatabaseManager {
680    pub fn new() -> Self {
681        Self
682    }
683}