Skip to main content

earl_protocol_sql/
sandbox.rs

1use std::sync::Once;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use serde_json::{Map, Value};
6use sqlx::{Column, Row, TypeInfo};
7
8static INSTALL_DRIVERS: Once = Once::new();
9
10/// Execute a SQL query against the given connection URL and return the result rows as JSON objects.
11pub async fn execute_query(
12    connection_url: &str,
13    query: &str,
14    params: &[Value],
15    read_only: bool,
16    max_rows: usize,
17    timeout: Duration,
18) -> Result<Vec<Map<String, Value>>> {
19    INSTALL_DRIVERS.call_once(|| {
20        sqlx::any::install_default_drivers();
21    });
22
23    // For SQLite, enforce read-only mode via the connection URL before connecting.
24    let connection_url: std::borrow::Cow<'_, str> = if read_only
25        && connection_url.to_ascii_lowercase().starts_with("sqlite")
26        && !connection_url.to_ascii_lowercase().contains("mode=ro")
27    {
28        let separator = if connection_url.contains('?') {
29            "&"
30        } else {
31            "?"
32        };
33        std::borrow::Cow::Owned(format!("{connection_url}{separator}mode=ro"))
34    } else {
35        std::borrow::Cow::Borrowed(connection_url)
36    };
37
38    let pool = sqlx::any::AnyPoolOptions::new()
39        .max_connections(1)
40        .acquire_timeout(timeout)
41        .connect(&connection_url)
42        .await
43        .context("failed connecting to SQL database")?;
44
45    // For PostgreSQL and MySQL, enforce read-only mode via explicit read-only
46    // transactions instead of SET commands (which can be overridden by users).
47    // SQLite is already handled via mode=ro connection parameter above.
48    let url_lower = connection_url.to_ascii_lowercase();
49    let use_read_only_transaction =
50        read_only && (url_lower.starts_with("postgres") || url_lower.starts_with("mysql"));
51
52    if use_read_only_transaction {
53        let begin_stmt = if url_lower.starts_with("postgres") {
54            "BEGIN READ ONLY"
55        } else {
56            "START TRANSACTION READ ONLY"
57        };
58        sqlx::query(begin_stmt)
59            .execute(&pool)
60            .await
61            .context("failed starting read-only transaction")?;
62    }
63
64    // codeql[rust/cleartext-storage-database] - False positive: the connection URL (which may
65    // contain credentials) is used to *connect* to the database, not to store data in it.
66    // The statement comes directly from the caller and all data values are
67    // bound separately below; SQLx 0.9 requires this audit to be explicit.
68    let mut sqlx_query = sqlx::query(sqlx::AssertSqlSafe(query));
69    for param in params {
70        sqlx_query = bind_json_param(sqlx_query, param);
71    }
72
73    let query_result = tokio::time::timeout(timeout, sqlx_query.fetch_all(&pool))
74        .await
75        .map_err(|_| anyhow::anyhow!("SQL query timed out after {timeout:?}"));
76
77    // Always rollback the read-only transaction, even on error.
78    if use_read_only_transaction {
79        let _ = sqlx::query("ROLLBACK").execute(&pool).await;
80    }
81
82    let rows = query_result?.context("SQL query execution failed")?;
83
84    let mut results = Vec::with_capacity(rows.len().min(max_rows));
85    for row in rows.iter().take(max_rows) {
86        results.push(row_to_json(row)?);
87    }
88
89    pool.close().await;
90
91    Ok(results)
92}
93
94fn bind_json_param(
95    query: sqlx::query::Query<'static, sqlx::Any, sqlx::any::AnyArguments>,
96    value: &Value,
97) -> sqlx::query::Query<'static, sqlx::Any, sqlx::any::AnyArguments> {
98    match value {
99        Value::Null => query.bind(None::<String>),
100        Value::Bool(b) => query.bind(*b),
101        Value::Number(n) => {
102            if let Some(i) = n.as_i64() {
103                query.bind(i)
104            } else if let Some(f) = n.as_f64() {
105                query.bind(f)
106            } else {
107                query.bind(n.to_string())
108            }
109        }
110        Value::String(s) => query.bind(s.as_str()),
111        _ => query.bind(serde_json::to_string(value).unwrap_or_default()),
112    }
113}
114
115fn row_to_json(row: &sqlx::any::AnyRow) -> Result<Map<String, Value>> {
116    let mut map = Map::new();
117    for col in row.columns() {
118        let name = col.name().to_string();
119        let type_name = col.type_info().name();
120        let value = match type_name {
121            "INTEGER" | "INT" | "INT4" | "INT8" | "BIGINT" => row
122                .try_get::<i64, _>(col.ordinal())
123                .map(|v| Value::Number(v.into()))
124                .unwrap_or(Value::Null),
125            "REAL" | "FLOAT" | "FLOAT4" | "FLOAT8" | "DOUBLE" | "NUMERIC" => row
126                .try_get::<f64, _>(col.ordinal())
127                .ok()
128                .and_then(|v| serde_json::Number::from_f64(v).map(Value::Number))
129                .unwrap_or(Value::Null),
130            "BOOLEAN" | "BOOL" => row
131                .try_get::<bool, _>(col.ordinal())
132                .map(Value::Bool)
133                .unwrap_or(Value::Null),
134            _ => {
135                // For unknown types (e.g. SQLite "NULL" for literal expressions),
136                // try decoding as each type in order: i64 -> f64 -> bool -> String.
137                let ordinal = col.ordinal();
138                if let Ok(v) = row.try_get::<i64, _>(ordinal) {
139                    Value::Number(v.into())
140                } else if let Ok(v) = row.try_get::<f64, _>(ordinal) {
141                    serde_json::Number::from_f64(v)
142                        .map(Value::Number)
143                        .unwrap_or(Value::Null)
144                } else if let Ok(v) = row.try_get::<bool, _>(ordinal) {
145                    Value::Bool(v)
146                } else if let Ok(v) = row.try_get::<String, _>(ordinal) {
147                    Value::String(v)
148                } else {
149                    Value::Null
150                }
151            }
152        };
153        map.insert(name, value);
154    }
155    Ok(map)
156}