mod dot_commands;
mod engine;
mod formatter;
mod parser;
mod vfs_io;
#[cfg(test)]
mod tests;
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::error::Result;
use crate::fs::FileSystem;
use crate::interpreter::ExecResult;
use super::{Builtin, Context, check_help_version, resolve_path};
use dot_commands::{DotError, DotOutcome};
use engine::SqliteEngine;
use formatter::{OutputMode, OutputOpts, render};
use parser::Stmt;
const SQLITE_OPT_IN_ENV: &str = "BASHKIT_ALLOW_INPROCESS_SQLITE";
const DEFAULT_MAX_SCRIPT_BYTES: usize = 4 * 1024 * 1024; const DEFAULT_MAX_ROWS_PER_QUERY: usize = 1_000_000;
const DEFAULT_MAX_DB_BYTES: usize = 256 * 1024 * 1024; const DEFAULT_MAX_DURATION: std::time::Duration = std::time::Duration::from_secs(30);
const DEFAULT_MAX_STATEMENTS: usize = 10_000;
const DEFAULT_PRAGMA_DENY: &[&str] = &[
"cache_size",
"mmap_size",
"page_size",
"max_page_count",
"temp_store_directory",
"data_store_directory",
"compile_options",
"locking_mode",
"shared_cache",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum SqliteBackend {
#[default]
Memory,
Vfs,
}
impl SqliteBackend {
fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"memory" | "memio" | "mem" => Self::Memory,
"vfs" | "vfsio" => Self::Vfs,
_ => return None,
})
}
}
#[derive(Debug, Clone)]
pub struct SqliteLimits {
pub max_script_bytes: usize,
pub max_rows_per_query: usize,
pub max_db_bytes: usize,
pub max_duration: std::time::Duration,
pub max_statements: usize,
pub backend: SqliteBackend,
pub pragma_deny: Vec<String>,
}
impl Default for SqliteLimits {
fn default() -> Self {
Self {
max_script_bytes: DEFAULT_MAX_SCRIPT_BYTES,
max_rows_per_query: DEFAULT_MAX_ROWS_PER_QUERY,
max_db_bytes: DEFAULT_MAX_DB_BYTES,
max_duration: DEFAULT_MAX_DURATION,
max_statements: DEFAULT_MAX_STATEMENTS,
backend: SqliteBackend::default(),
pragma_deny: DEFAULT_PRAGMA_DENY
.iter()
.map(|s| (*s).to_string())
.collect(),
}
}
}
impl SqliteLimits {
#[must_use]
pub fn max_script_bytes(mut self, n: usize) -> Self {
self.max_script_bytes = n;
self
}
#[must_use]
pub fn max_rows_per_query(mut self, n: usize) -> Self {
self.max_rows_per_query = n;
self
}
#[must_use]
pub fn max_db_bytes(mut self, n: usize) -> Self {
self.max_db_bytes = n;
self
}
#[must_use]
pub fn max_duration(mut self, d: std::time::Duration) -> Self {
self.max_duration = d;
self
}
#[must_use]
pub fn max_statements(mut self, n: usize) -> Self {
self.max_statements = n;
self
}
#[must_use]
pub fn backend(mut self, backend: SqliteBackend) -> Self {
self.backend = backend;
self
}
#[must_use]
pub fn pragma_deny<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.pragma_deny = names
.into_iter()
.map(|n| n.into().to_ascii_lowercase())
.collect();
self
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SqliteInprocessOptIn(pub bool);
fn sqlite_inprocess_enabled(ctx: &Context<'_>) -> bool {
ctx.execution_extension::<SqliteInprocessOptIn>()
.is_some_and(|opt_in| opt_in.0)
|| {
#[cfg(test)]
{
let is_enabled = |v: &str| matches!(v, "1" | "true" | "TRUE" | "yes" | "YES");
ctx.env
.get(SQLITE_OPT_IN_ENV)
.is_some_and(|v| is_enabled(v))
}
#[cfg(not(test))]
{
false
}
}
}
pub struct Sqlite {
pub limits: SqliteLimits,
engine_cache: EngineCache,
}
type CacheKey = (SqliteBackend, std::path::PathBuf);
type EngineHandle = Arc<tokio::sync::Mutex<Option<engine::SqliteEngine>>>;
type EngineCache = Arc<std::sync::Mutex<std::collections::HashMap<CacheKey, EngineHandle>>>;
impl Sqlite {
pub fn new() -> Self {
Self {
limits: SqliteLimits::default(),
engine_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
}
}
pub fn with_limits(limits: SqliteLimits) -> Self {
Self {
limits,
engine_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
}
}
fn cache_handle(&self, key: &CacheKey) -> EngineHandle {
let mut cache = self
.engine_cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
cache
.entry(key.clone())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(None)))
.clone()
}
}
impl Default for Sqlite {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Builtin for Sqlite {
fn llm_hint(&self) -> Option<&'static str> {
Some(
"sqlite/sqlite3: Embedded SQLite-compatible engine (Turso, BETA). \
Usage: sqlite DB SQL... | sqlite DB <script | sqlite -separator , -header DB SELECT. \
Dot-commands: .tables .schema .dump .headers .mode .separator .nullvalue .read .help. \
Supports :memory:. No ATTACH/DETACH. \
Set BASHKIT_ALLOW_INPROCESS_SQLITE=1 to enable.",
)
}
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
let invocation_args: Vec<String> = ctx.args.to_vec();
if let Some(r) = check_help_version(&invocation_args, HELP_TEXT, Some("sqlite (turso 0.5)"))
{
return Ok(r);
}
if !sqlite_inprocess_enabled(&ctx) {
return Ok(ExecResult::err(
format!(
"sqlite: in-process SQLite disabled by default; set {SQLITE_OPT_IN_ENV}=1 to enable\n"
),
1,
));
}
let parsed = match parse_args(&invocation_args, ctx.stdin) {
Ok(p) => p,
Err(e) => {
return Ok(ExecResult::err(format!("sqlite: {e}\n"), 2));
}
};
let db_target = resolve_db_target(&parsed.db_arg, ctx.cwd);
let script_len = parsed.script.len();
if script_len > self.limits.max_script_bytes {
return Ok(ExecResult::err(
format!(
"sqlite: script too large ({script_len} bytes; limit {})\n",
self.limits.max_script_bytes
),
1,
));
}
let backend = parsed.backend.unwrap_or(self.limits.backend);
let mut opts = parsed.output;
let mut stdout = String::new();
let mut stderr = String::new();
let mut exit_code = 0i32;
let stmts = parser::split(&parsed.script);
if stmts.len() > self.limits.max_statements {
return Ok(ExecResult::err(
format!(
"sqlite: too many statements ({} > {} limit)\n",
stmts.len(),
self.limits.max_statements
),
1,
));
}
let deadline = engine::Deadline::new(self.limits.max_duration);
match &db_target {
DbTarget::Memory => {
let engine = match SqliteEngine::open_pure_memory() {
Ok(e) => e,
Err(msg) => {
return Ok(ExecResult::err(format!("sqlite: {msg}\n"), 1));
}
};
let outcome = run_statements(
&engine,
stmts,
&ctx.fs,
ctx.cwd,
&mut opts,
&mut stdout,
&self.limits,
deadline,
0,
)
.await;
if let Err(e) = outcome {
stderr.push_str(&format!("sqlite: {e}\n"));
exit_code = 1;
}
}
DbTarget::File { path } => {
let key: CacheKey = (backend, path.clone());
let handle = self.cache_handle(&key);
let mut guard = handle.lock().await;
if guard.is_none() {
match open_file_engine(backend, path, &ctx.fs, &self.limits).await {
Ok(e) => *guard = Some(e),
Err(msg) => {
return Ok(ExecResult::err(format!("sqlite: {msg}\n"), 1));
}
}
}
let engine = guard.as_ref().expect("engine populated above");
let outcome = run_statements(
engine,
stmts,
&ctx.fs,
ctx.cwd,
&mut opts,
&mut stdout,
&self.limits,
deadline,
0,
)
.await;
if let Err(e) = outcome {
stderr.push_str(&format!("sqlite: {e}\n"));
exit_code = 1;
}
let mut drop_cached_engine = false;
match backend {
SqliteBackend::Memory => {
if let Some(bytes) = engine.snapshot_bytes() {
if bytes.len() > self.limits.max_db_bytes {
stderr.push_str(&format!(
"sqlite: database file too large after execution ({} bytes; limit {})\n",
bytes.len(),
self.limits.max_db_bytes
));
exit_code = exit_code.max(1);
drop_cached_engine = true;
} else if let Err(e) = ctx.fs.write_file(path, &bytes).await {
stderr.push_str(&format!(
"sqlite: persist failed: {}: {e}\n",
path.display()
));
exit_code = exit_code.max(1);
}
}
}
SqliteBackend::Vfs => {
if let Err(e) = engine.flush_dirty().await {
stderr.push_str(&format!("sqlite: flush failed: {e}\n"));
exit_code = exit_code.max(1);
}
}
}
if drop_cached_engine {
*guard = None;
}
}
}
let mut result = ExecResult {
exit_code,
..Default::default()
};
result.stdout = stdout;
result.stderr = stderr;
Ok(result)
}
}
#[derive(Debug)]
enum DbTarget {
Memory,
File { path: PathBuf },
}
fn resolve_db_target(arg: &str, cwd: &Path) -> DbTarget {
if arg == ":memory:" || arg.is_empty() {
return DbTarget::Memory;
}
DbTarget::File {
path: resolve_path(cwd, arg),
}
}
#[derive(Debug)]
struct ParsedArgs {
db_arg: String,
script: String,
output: OutputOpts,
backend: Option<SqliteBackend>,
}
fn parse_args(args: &[String], stdin: Option<&str>) -> std::result::Result<ParsedArgs, String> {
let mut output = OutputOpts::default();
let mut backend: Option<SqliteBackend> = None;
let mut script_parts: Vec<String> = Vec::new();
let mut db_arg: Option<String> = None;
let mut i = 0;
while i < args.len() {
let a = &args[i];
match a.as_str() {
"-header" | "-headers" | "--header" | "--headers" => {
output.headers = true;
}
"-noheader" | "--noheader" => {
output.headers = false;
}
"-csv" | "--csv" => {
output.mode = OutputMode::Csv;
output.separator = ",".to_string();
}
"-tabs" | "--tabs" => {
output.mode = OutputMode::Tabs;
output.separator = "\t".to_string();
}
"-line" | "--line" => {
output.mode = OutputMode::Line;
}
"-list" | "--list" => {
output.mode = OutputMode::List;
}
"-box" | "--box" => {
output.mode = OutputMode::Box;
}
"-column" | "--column" => {
output.mode = OutputMode::Column;
}
"-json" | "--json" => {
output.mode = OutputMode::Json;
}
"-markdown" | "--markdown" => {
output.mode = OutputMode::Markdown;
}
"-separator" | "--separator" => {
let v = next_value(args, &mut i, "-separator")?;
output.separator = decode_escapes(&v);
}
"-nullvalue" | "--nullvalue" => {
let v = next_value(args, &mut i, "-nullvalue")?;
output.null_text = v;
}
"-cmd" | "--cmd" => {
let v = next_value(args, &mut i, "-cmd")?;
script_parts.push(v);
}
"-backend" | "--backend" => {
let v = next_value(args, &mut i, "-backend")?;
let b = SqliteBackend::parse(&v)
.ok_or_else(|| format!("invalid backend '{v}' (memory|vfs)"))?;
backend = Some(b);
}
"--" => {
i += 1;
while i < args.len() {
consume_positional(&args[i], &mut db_arg, &mut script_parts);
i += 1;
}
break;
}
arg if arg.starts_with('-') && arg != "-" => {
return Err(format!("unknown option: {arg}"));
}
_ => {
consume_positional(a, &mut db_arg, &mut script_parts);
}
}
i += 1;
}
let db_arg = db_arg.unwrap_or_else(|| ":memory:".to_string());
let mut script = script_parts.join(";\n");
if script.trim().is_empty()
&& let Some(input) = stdin
&& !input.is_empty()
{
script = input.to_string();
}
Ok(ParsedArgs {
db_arg,
script,
output,
backend,
})
}
fn next_value(args: &[String], i: &mut usize, flag: &str) -> std::result::Result<String, String> {
*i += 1;
args.get(*i)
.cloned()
.ok_or_else(|| format!("option {flag} requires an argument"))
}
fn consume_positional(arg: &str, db: &mut Option<String>, script: &mut Vec<String>) {
if db.is_none() {
*db = Some(arg.to_string());
} else {
script.push(arg.to_string());
}
}
fn decode_escapes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\'
&& let Some(&next) = chars.peek()
{
chars.next();
match next {
't' => out.push('\t'),
'n' => out.push('\n'),
'r' => out.push('\r'),
'0' => out.push('\0'),
'\\' => out.push('\\'),
other => {
out.push('\\');
out.push(other);
}
}
continue;
}
out.push(c);
}
out
}
async fn open_file_engine(
backend: SqliteBackend,
path: &Path,
fs: &Arc<dyn FileSystem>,
limits: &SqliteLimits,
) -> std::result::Result<SqliteEngine, String> {
match backend {
SqliteBackend::Memory => {
let initial = match fs.read_file(path).await {
Ok(bytes) => {
if bytes.len() > limits.max_db_bytes {
return Err(format!(
"database file too large ({} bytes; limit {})",
bytes.len(),
limits.max_db_bytes
));
}
Some(bytes)
}
Err(_) => None,
};
SqliteEngine::open_memory(initial.as_deref())
}
SqliteBackend::Vfs => {
let handle = vfs_io::current_handle_or_default();
let io = vfs_io::BashkitVfsIO::new_with_cap(fs.clone(), handle, limits.max_db_bytes);
let path_str = path.to_string_lossy().into_owned();
SqliteEngine::open_vfs(io, &path_str)
}
}
}
const MAX_DOT_READ_DEPTH: usize = 16;
#[allow(clippy::too_many_arguments)]
async fn run_statements(
engine: &SqliteEngine,
stmts: Vec<Stmt>,
fs: &Arc<dyn FileSystem>,
cwd: &Path,
opts: &mut OutputOpts,
stdout: &mut String,
limits: &SqliteLimits,
deadline: engine::Deadline,
depth: usize,
) -> std::result::Result<(), String> {
if depth > MAX_DOT_READ_DEPTH {
return Err(format!(
".read nesting too deep (limit {MAX_DOT_READ_DEPTH})"
));
}
for stmt in stmts {
if deadline.expired() {
return Err("query timed out".to_string());
}
match stmt {
Stmt::Sql(sql) => {
check_sql_policy(&sql, limits)?;
let outcome = engine.execute(&sql, deadline).map_err(|e| sanitize(&e))?;
if outcome.rows.len() > limits.max_rows_per_query {
return Err(format!(
"result set exceeds row cap ({} > {})",
outcome.rows.len(),
limits.max_rows_per_query
));
}
let rendered = render(&outcome.columns, &outcome.rows, opts);
stdout.push_str(&rendered);
}
Stmt::Dot(line) => {
let result = dot_commands::dispatch(&line, engine, opts, deadline);
match result {
Ok(DotOutcome::Stdout(s)) => stdout.push_str(&s),
Ok(DotOutcome::Configured) => {}
Ok(DotOutcome::Quit) => return Ok(()),
Ok(DotOutcome::Read(p)) => {
let abs = if p.is_absolute() { p } else { cwd.join(&p) };
let bytes = fs
.read_file(&abs)
.await
.map_err(|e| format!("cannot read {}: {e}", abs.display()))?;
let nested = String::from_utf8(bytes)
.map_err(|_| format!("{} is not valid UTF-8", abs.display()))?;
let nested_stmts = parser::split(&nested);
Box::pin(run_statements(
engine,
nested_stmts,
fs,
cwd,
opts,
stdout,
limits,
deadline,
depth + 1,
))
.await?;
}
Err(DotError::BadCommand(c)) => {
return Err(format!("unknown dot-command: .{c}"));
}
Err(e) => {
return Err(format!("{e}"));
}
}
}
}
}
Ok(())
}
fn check_sql_policy(sql: &str, limits: &SqliteLimits) -> std::result::Result<(), String> {
match parser::leading_keyword(sql).as_deref() {
Some("ATTACH") | Some("DETACH") => {
return Err("ATTACH/DETACH is not supported in the bashkit sandbox; \
cross-database access bypasses VFS isolation"
.to_string());
}
_ => {}
}
if let Some(name) = parser::pragma_name(sql)
&& limits.pragma_deny.iter().any(|denied| denied == &name)
{
return Err(format!(
"PRAGMA {name} is denied by SqliteLimits::pragma_deny"
));
}
Ok(())
}
fn sanitize(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
for line in msg.lines() {
let cleaned = match line.find(" at /") {
Some(idx) => &line[..idx],
None => line,
};
out.push_str(cleaned);
out.push('\n');
}
out.trim_end().to_string()
}
const HELP_TEXT: &str = concat!(
"usage: sqlite [OPTIONS] DB [SQL ...]\n",
" sqlite [OPTIONS] :memory: [SQL ...]\n",
"Options:\n",
" -header, --header Include column headers\n",
" -noheader, --noheader Suppress column headers (default)\n",
" -csv, --csv Output mode: CSV\n",
" -tabs, --tabs Output mode: tabs\n",
" -line, --line Output mode: name=value lines\n",
" -list, --list Output mode: separator-joined (default)\n",
" -box, --box Output mode: ASCII box table\n",
" -column, --column Output mode: column-aligned\n",
" -json, --json Output mode: JSON array of objects\n",
" -markdown, --markdown Output mode: Markdown table\n",
" -separator SEP Field separator (e.g. '|', ',', '\\t')\n",
" -nullvalue STR Placeholder for NULL\n",
" -cmd SQL Run extra SQL before positional script\n",
" -backend memory|vfs Pick the IO backend (default: memory)\n",
" --help Show this message\n",
" --version Print engine version\n",
"Dot-commands: .help .quit .exit .tables .schema .indexes\n",
" .headers .mode .separator .nullvalue\n",
" .dump .read PATH\n",
);