use sqlparser::ast::Statement;
use sqlparser::dialect::GenericDialect;
use sqlparser::parser::Parser;
const ALLOWED_DDL_STATEMENTS: &[&str] = &[
"CreateTable",
"AlterTable",
"CreateIndex",
"CreateView",
"Truncate",
"Query", ];
const FORBIDDEN_PATTERNS: &[&str] = &["DROP DATABASE", "DROP ALL"];
#[derive(Debug)]
pub enum DdlValidationResult {
Allowed,
Forbidden(String),
ParseError(String),
}
pub struct DdlGuard {
dialect: GenericDialect,
}
impl DdlGuard {
pub fn new() -> Self {
Self {
dialect: GenericDialect {},
}
}
pub fn validate(&self, sql: &str) -> Result<DdlValidationResult, String> {
let sql_trimmed = sql.trim();
if sql_trimmed.is_empty() {
return Ok(DdlValidationResult::Forbidden("Empty SQL statement".to_string()));
}
let sql_upper = sql_trimmed.to_uppercase();
for pattern in FORBIDDEN_PATTERNS {
if sql_upper.contains(pattern) {
return Ok(DdlValidationResult::Forbidden(format!(
"Contains forbidden pattern: {}",
pattern
)));
}
}
let statements =
Parser::parse_sql(&self.dialect, sql_trimmed).map_err(|e| format!("Failed to parse SQL: {}", e))?;
if statements.is_empty() {
return Ok(DdlValidationResult::Forbidden(
"Empty SQL statement after parsing".to_string(),
));
}
for stmt in &statements {
if !Self::is_allowed_statement(stmt) {
return Ok(DdlValidationResult::Forbidden(format!(
"Statement type '{}' is not in the allowed DDL whitelist",
Self::statement_type_name(stmt)
)));
}
}
Ok(DdlValidationResult::Allowed)
}
fn is_allowed_statement(stmt: &Statement) -> bool {
match stmt {
Statement::Drop { object_type, .. } => {
let type_str = format!("{:?}", object_type);
type_str.contains("Index") || type_str.contains("View")
}
_ => {
let type_name = Self::statement_type_name(stmt);
ALLOWED_DDL_STATEMENTS.contains(&type_name.as_str())
}
}
}
fn statement_type_name(stmt: &Statement) -> String {
match stmt {
Statement::CreateTable(_) => "CreateTable".to_string(),
Statement::AlterTable(_) => "AlterTable".to_string(),
Statement::CreateIndex(_) => "CreateIndex".to_string(),
Statement::CreateView(_) => "CreateView".to_string(),
Statement::Drop { object_type, .. } => {
let type_str = format!("{:?}", object_type);
if type_str.contains("Table") {
"DropTable".to_string()
} else if type_str.contains("Index") {
"DropIndex".to_string()
} else if type_str.contains("View") {
"DropView".to_string()
} else if type_str.contains("Database") {
"DropDatabase".to_string()
} else {
format!("Drop{:?}", object_type)
}
}
Statement::Truncate(_) => "Truncate".to_string(),
Statement::Query(_) => "Query".to_string(),
Statement::Insert(_) => "Insert".to_string(),
Statement::Update(_) => "Update".to_string(),
Statement::Delete(_) => "Delete".to_string(),
Statement::Set(_) => "Set".to_string(),
_ => format!("{:?}", stmt),
}
}
}
impl Default for DdlGuard {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn guard() -> DdlGuard {
DdlGuard::new()
}
#[test]
fn test_valid_create_table() {
let result = guard().validate("CREATE TABLE users (id INT PRIMARY KEY)").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_table_lowercase() {
let result = guard().validate("create table users (id int)").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_or_replace() {
let result = guard().validate("CREATE OR REPLACE TABLE users (id INT)").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_alter_table() {
let result = guard()
.validate("ALTER TABLE users ADD COLUMN name VARCHAR(255)")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_index() {
let result = guard().validate("CREATE INDEX idx_name ON users (name)").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_drop_index() {
let result = guard().validate("DROP INDEX idx_name").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_drop_view() {
let result = guard().validate("DROP VIEW active_users").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_create_view() {
let result = guard()
.validate("CREATE VIEW active_users AS SELECT * FROM users WHERE active = true")
.unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_valid_select() {
let result = guard().validate("SELECT 1").unwrap();
assert!(matches!(result, DdlValidationResult::Allowed));
}
#[test]
fn test_drop_database_rejected() {
let result = guard().validate("DROP DATABASE production").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("DROP DATABASE")
));
}
#[test]
fn test_drop_database_lowercase_rejected() {
let result = guard().validate("drop database production").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("forbidden")
));
}
#[test]
fn test_drop_all_rejected() {
let result = guard().validate("DROP ALL TABLES").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("DROP ALL")
));
}
#[test]
fn test_drop_table_not_in_whitelist() {
let result = guard().validate("DROP TABLE users").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("DropTable")
));
}
#[test]
fn test_insert_not_allowed() {
let result = guard().validate("INSERT INTO users (id) VALUES (1)").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Insert")
));
}
#[test]
fn test_update_not_allowed() {
let result = guard().validate("UPDATE users SET name = 'test' WHERE id = 1").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Update")
));
}
#[test]
fn test_delete_not_allowed() {
let result = guard().validate("DELETE FROM users WHERE id = 1").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Delete")
));
}
#[test]
fn test_delete_lowercase_not_allowed() {
let result = guard().validate("delete from users where id = 1").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Delete")
));
}
#[test]
fn test_empty_sql() {
let result = guard().validate("").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Empty")
));
}
#[test]
fn test_whitespace_sql() {
let result = guard().validate(" \n\t ").unwrap();
assert!(matches!(
result,
DdlValidationResult::Forbidden(ref msg) if msg.contains("Empty")
));
}
}