#[cfg(feature = "sqlite")]
use std::path::{Path, PathBuf};
#[cfg(feature = "sqlite")]
const MAX_ROWS_DEFAULT: usize = 100;
#[cfg(feature = "sqlite")]
const MAX_ROWS_LIMIT: usize = 10_000;
#[cfg(feature = "sqlite")]
static READ_ONLY_PREFIXES: &[&str] = &["select", "pragma", "explain", "with"];
#[cfg(feature = "sqlite")]
fn is_read_only(sql: &str) -> bool {
let trimmed = sql.trim_start().to_lowercase();
READ_ONLY_PREFIXES
.iter()
.any(|prefix| trimmed.starts_with(prefix))
}
#[cfg(feature = "sqlite")]
#[must_use]
pub fn discover_databases(dir: &Path) -> Vec<PathBuf> {
let mut results = Vec::new();
discover_recursive(dir, 0, 2, &mut results);
results
}
#[cfg(feature = "sqlite")]
fn discover_recursive(dir: &Path, depth: u32, max_depth: u32, results: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_symlink() {
continue;
}
if path.is_file() {
if let Some(ext) = path.extension().and_then(|e| e.to_str())
&& matches!(ext, "sqlite" | "sqlite3" | "db" | "sdb")
{
results.push(path);
}
} else if path.is_dir() && depth < max_depth {
discover_recursive(&path, depth + 1, max_depth, results);
}
}
}
#[cfg(feature = "sqlite")]
pub fn query(
db_path: &Path,
sql: &str,
params: &[serde_json::Value],
max_rows: Option<usize>,
) -> Result<serde_json::Value, String> {
if !is_read_only(sql) {
return Err(
"only SELECT, PRAGMA, EXPLAIN, and WITH queries are allowed (read-only access)"
.to_string(),
);
}
let max_rows = max_rows.unwrap_or(MAX_ROWS_DEFAULT).min(MAX_ROWS_LIMIT);
let conn = rusqlite::Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("failed to open database: {e}"))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.map_err(|e| format!("failed to set timeout: {e}"))?;
let mut stmt = conn
.prepare(sql)
.map_err(|e| format!("failed to prepare query: {e}"))?;
let column_names: Vec<String> = stmt
.column_names()
.iter()
.map(|s| (*s).to_string())
.collect();
let column_count = column_names.len();
let sqlite_params: Vec<Box<dyn rusqlite::types::ToSql>> =
params.iter().map(json_to_sql).collect();
let param_refs: Vec<&dyn rusqlite::types::ToSql> = sqlite_params.iter().map(|b| &**b).collect();
let mut rows_out: Vec<serde_json::Value> = Vec::new();
let mut rows = stmt
.query(param_refs.as_slice())
.map_err(|e| format!("query execution failed: {e}"))?;
while let Some(row) = rows.next().map_err(|e| format!("row read failed: {e}"))? {
if rows_out.len() >= max_rows {
break;
}
let mut obj = serde_json::Map::new();
for (i, col_name) in column_names.iter().enumerate().take(column_count) {
let value = row_value_to_json(row, i);
obj.insert(col_name.clone(), value);
}
rows_out.push(serde_json::Value::Object(obj));
}
let truncated = rows_out.len() == max_rows;
Ok(serde_json::json!({
"columns": column_names,
"rows": rows_out,
"row_count": rows_out.len(),
"truncated": truncated,
"max_rows": max_rows,
}))
}
#[cfg(feature = "sqlite")]
fn json_to_sql(val: &serde_json::Value) -> Box<dyn rusqlite::types::ToSql> {
match val {
serde_json::Value::Null => Box::new(rusqlite::types::Null),
serde_json::Value::Bool(b) => Box::new(*b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Box::new(i)
} else if let Some(f) = n.as_f64() {
Box::new(f)
} else {
Box::new(n.to_string())
}
}
serde_json::Value::String(s) => Box::new(s.clone()),
other => Box::new(other.to_string()),
}
}
#[cfg(feature = "sqlite")]
fn row_value_to_json(row: &rusqlite::Row, idx: usize) -> serde_json::Value {
use rusqlite::types::ValueRef;
match row.get_ref(idx) {
Ok(ValueRef::Null) => serde_json::Value::Null,
Ok(ValueRef::Integer(i)) => serde_json::json!(i),
Ok(ValueRef::Real(f)) => serde_json::json!(f),
Ok(ValueRef::Text(t)) => {
let s = String::from_utf8_lossy(t);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&s)
&& (parsed.is_object() || parsed.is_array())
{
return parsed;
}
serde_json::Value::String(s.into_owned())
}
Ok(ValueRef::Blob(b)) => {
use base64::Engine;
serde_json::json!({
"__blob": true,
"size": b.len(),
"base64": base64::engine::general_purpose::STANDARD.encode(b),
})
}
Err(_) => serde_json::Value::Null,
}
}
#[cfg(all(test, feature = "sqlite"))]
mod tests {
use super::*;
fn create_test_db() -> (tempfile::NamedTempFile, PathBuf) {
let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
let path = file.path().to_path_buf();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
"CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, score REAL);
INSERT INTO users VALUES (1, 'Alice', 95.5);
INSERT INTO users VALUES (2, 'Bob', 87.0);
INSERT INTO users VALUES (3, 'Charlie', 92.3);",
)
.unwrap();
(file, path)
}
#[test]
fn select_all_rows() {
let (_f, path) = create_test_db();
let result = query(&path, "SELECT * FROM users", &[], None).unwrap();
assert_eq!(result["row_count"], 3);
assert_eq!(
result["columns"],
serde_json::json!(["id", "name", "score"])
);
assert_eq!(result["rows"][0]["name"], "Alice");
assert_eq!(result["rows"][1]["name"], "Bob");
}
#[test]
fn select_with_params() {
let (_f, path) = create_test_db();
let result = query(
&path,
"SELECT name FROM users WHERE score > ?",
&[serde_json::json!(90.0)],
None,
)
.unwrap();
assert_eq!(result["row_count"], 2);
}
#[test]
fn max_rows_truncation() {
let (_f, path) = create_test_db();
let result = query(&path, "SELECT * FROM users", &[], Some(2)).unwrap();
assert_eq!(result["row_count"], 2);
assert_eq!(result["truncated"], true);
}
#[test]
fn rejects_insert() {
let (_f, path) = create_test_db();
let err = query(
&path,
"INSERT INTO users VALUES (4, 'Eve', 99.0)",
&[],
None,
)
.unwrap_err();
assert!(err.contains("read-only"));
}
#[test]
fn rejects_delete() {
let (_f, path) = create_test_db();
let err = query(&path, "DELETE FROM users", &[], None).unwrap_err();
assert!(err.contains("read-only"));
}
#[test]
fn rejects_drop() {
let (_f, path) = create_test_db();
let err = query(&path, "DROP TABLE users", &[], None).unwrap_err();
assert!(err.contains("read-only"));
}
#[test]
fn rejects_update() {
let (_f, path) = create_test_db();
let err = query(&path, "UPDATE users SET name = 'X'", &[], None).unwrap_err();
assert!(err.contains("read-only"));
}
#[test]
fn pragma_works() {
let (_f, path) = create_test_db();
let result = query(&path, "PRAGMA table_info(users)", &[], None).unwrap();
assert!(result["row_count"].as_u64().unwrap() >= 3);
}
#[test]
fn with_cte_works() {
let (_f, path) = create_test_db();
let result = query(
&path,
"WITH top AS (SELECT * FROM users WHERE score > 90) SELECT name FROM top",
&[],
None,
)
.unwrap();
assert_eq!(result["row_count"], 2);
}
#[test]
fn nonexistent_db_fails() {
let err = query(Path::new("/nonexistent/db.sqlite"), "SELECT 1", &[], None).unwrap_err();
assert!(err.contains("failed to open"));
}
#[test]
fn json_column_parsed() {
let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
let path = file.path().to_path_buf();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch(
r#"CREATE TABLE config (key TEXT, value TEXT);
INSERT INTO config VALUES ('settings', '{"theme":"dark","lang":"en"}');"#,
)
.unwrap();
let result = query(&path, "SELECT * FROM config", &[], None).unwrap();
assert!(result["rows"][0]["value"].is_object());
assert_eq!(result["rows"][0]["value"]["theme"], "dark");
}
#[test]
fn discover_finds_sqlite_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::File::create(dir.path().join("app.sqlite")).unwrap();
std::fs::File::create(dir.path().join("cache.db")).unwrap();
std::fs::File::create(dir.path().join("readme.txt")).unwrap();
let sub = dir.path().join("subdir");
std::fs::create_dir(&sub).unwrap();
std::fs::File::create(sub.join("deep.sqlite3")).unwrap();
let dbs = discover_databases(dir.path());
assert_eq!(dbs.len(), 3);
}
#[test]
fn blob_column_base64() {
let file = tempfile::NamedTempFile::with_suffix(".sqlite").unwrap();
let path = file.path().to_path_buf();
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("CREATE TABLE blobs (id INTEGER, data BLOB)")
.unwrap();
conn.execute("INSERT INTO blobs VALUES (1, X'DEADBEEF')", [])
.unwrap();
let result = query(&path, "SELECT * FROM blobs", &[], None).unwrap();
assert!(result["rows"][0]["data"]["__blob"].as_bool().unwrap());
assert_eq!(result["rows"][0]["data"]["size"], 4);
}
}