#[derive(Debug)]
pub enum GuardError {
FullTableUpdate {
table: String,
},
FullTableDelete {
table: String,
},
ParseError(String),
}
impl std::fmt::Display for GuardError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GuardError::FullTableUpdate { table } => write!(
f,
"Blocked full-table UPDATE on `{}` (no WHERE clause). Add WHERE clause or use GuardPolicy::Permissive.",
table
),
GuardError::FullTableDelete { table } => write!(
f,
"Blocked full-table DELETE on `{}` (no WHERE clause). Add WHERE clause or use GuardPolicy::Permissive.",
table
),
GuardError::ParseError(msg) => write!(f, "SQL parse error in guard: {}", msg),
}
}
}
impl std::error::Error for GuardError {}
pub type GuardResult<T> = Result<T, GuardError>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GuardPolicy {
#[default]
Strict,
Permissive,
Disabled,
}
#[derive(Debug, Clone, Copy)]
pub struct SafeSqlGuard {
pub policy: GuardPolicy,
}
impl SafeSqlGuard {
pub fn new(policy: GuardPolicy) -> Self {
Self { policy }
}
pub fn strict() -> Self {
Self::new(GuardPolicy::Strict)
}
pub fn permissive() -> Self {
Self::new(GuardPolicy::Permissive)
}
pub fn disabled() -> Self {
Self::new(GuardPolicy::Disabled)
}
pub fn check(&self, sql: &str) -> GuardResult<()> {
match self.policy {
GuardPolicy::Disabled => Ok(()),
GuardPolicy::Permissive => {
Ok(())
}
GuardPolicy::Strict => check_sql_strict(sql),
}
}
pub fn check_update(&self, sql: &str) -> GuardResult<()> {
self.check(sql)
}
pub fn check_delete(&self, sql: &str) -> GuardResult<()> {
self.check(sql)
}
}
impl Default for SafeSqlGuard {
fn default() -> Self {
Self::strict()
}
}
fn check_sql_strict(sql: &str) -> GuardResult<()> {
let normalized = normalize_sql(sql);
let upper = normalized.to_uppercase();
if upper.starts_with("UPDATE") {
let table = extract_update_table(&normalized, &upper);
if !has_where_after_keyword(&upper, "SET") {
return Err(GuardError::FullTableUpdate {
table: table.unwrap_or_else(|| "unknown".to_string()),
});
}
}
if upper.starts_with("DELETE") {
let table = extract_delete_table(&normalized, &upper);
if !has_where_after_keyword(&upper, "FROM") {
return Err(GuardError::FullTableDelete {
table: table.unwrap_or_else(|| "unknown".to_string()),
});
}
}
Ok(())
}
fn normalize_sql(sql: &str) -> String {
let without_line_comments: String = sql
.lines()
.map(|line| {
if let Some(idx) = line.find("--") {
&line[..idx]
} else {
line
}
})
.collect::<Vec<&str>>()
.join(" ");
let mut without_block_comments = String::with_capacity(without_line_comments.len());
let mut in_block_comment = false;
let mut chars = without_line_comments.chars().peekable();
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'*') {
in_block_comment = true;
chars.next(); continue;
}
if in_block_comment && c == '*' && chars.peek() == Some(&'/') {
in_block_comment = false;
chars.next(); continue;
}
if !in_block_comment {
without_block_comments.push(c);
}
}
let mut result = String::with_capacity(without_block_comments.len());
let mut prev_whitespace = false;
for c in without_block_comments.chars() {
if c.is_whitespace() {
if !prev_whitespace {
result.push(' ');
prev_whitespace = true;
}
} else {
result.push(c);
prev_whitespace = false;
}
}
result.trim().to_string()
}
fn has_where_after_keyword(upper_sql: &str, keyword: &str) -> bool {
let kw_pos = match find_keyword_independent(upper_sql, keyword) {
Some(idx) => idx,
None => return false,
};
let after_kw = &upper_sql[kw_pos + keyword.len()..];
let where_idx = match find_where_at_depth_zero(after_kw) {
Some(idx) => idx,
None => return false,
};
let after_where = after_kw[where_idx + 5..].trim();
!after_where.is_empty()
}
fn find_where_at_depth_zero(sql: &str) -> Option<usize> {
let bytes = sql.as_bytes();
let mut depth: i32 = 0;
let mut i = 0;
while i + 5 <= bytes.len() {
let c = bytes[i];
if c == b'(' {
depth += 1;
i += 1;
continue;
}
if c == b')' {
depth -= 1;
if depth < 0 {
depth = 0;
}
i += 1;
continue;
}
if depth == 0 && &bytes[i..i + 5] == b"WHERE" {
let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
let next_idx = i + 5;
let next_ok = next_idx >= bytes.len()
|| !bytes[next_idx].is_ascii_alphanumeric() && bytes[next_idx] != b'_';
if prev_ok && next_ok {
return Some(i);
}
}
i += 1;
}
None
}
fn find_keyword_independent(sql: &str, keyword: &str) -> Option<usize> {
let kw_len = keyword.len();
if kw_len == 0 || sql.len() < kw_len {
return None;
}
let bytes = sql.as_bytes();
let kw_bytes = keyword.as_bytes();
let mut i = 0;
while i + kw_len <= bytes.len() {
if &bytes[i..i + kw_len] == kw_bytes {
let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
let next_idx = i + kw_len;
let next_ok = next_idx >= bytes.len()
|| !bytes[next_idx].is_ascii_alphanumeric() && bytes[next_idx] != b'_';
if prev_ok && next_ok {
return Some(i);
}
}
i += 1;
}
None
}
fn extract_update_table(sql: &str, upper: &str) -> Option<String> {
let update_end = upper.find("UPDATE").map(|i| i + 6)?;
let set_idx = upper.find(" SET ")?;
let between = sql[update_end..set_idx].trim();
let table = if (between.starts_with('`') && between.ends_with('`'))
|| (between.starts_with('"') && between.ends_with('"'))
{
between[1..between.len() - 1].to_string()
} else {
between.to_string()
};
let table = table.rsplit('.').next().unwrap_or(&table).to_string();
if table.is_empty() {
None
} else {
Some(table)
}
}
fn extract_delete_table(sql: &str, upper: &str) -> Option<String> {
if let Some(from_idx) = upper.find("DELETE FROM") {
let after_from = sql[from_idx + 11..].trim_start();
let end = after_from
.find(|c: char| c.is_whitespace())
.unwrap_or(after_from.len());
let raw = after_from[..end].trim();
return parse_table_name(raw);
}
if upper.starts_with("DELETE ") {
let after_delete = sql[7..].trim_start();
let end = after_delete
.find(|c: char| c.is_whitespace())
.unwrap_or(after_delete.len());
let raw = after_delete[..end].trim();
return parse_table_name(raw);
}
None
}
fn parse_table_name(raw: &str) -> Option<String> {
let table = if (raw.starts_with('`') && raw.ends_with('`') && raw.len() >= 2)
|| (raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2)
{
raw[1..raw.len() - 1].to_string()
} else {
raw.to_string()
};
let table = table.rsplit('.').next().unwrap_or(&table).to_string();
if table.is_empty() {
None
} else {
Some(table)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strict_blocks_update_without_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE users SET name = 'a'");
assert!(matches!(
result,
Err(GuardError::FullTableUpdate { table }) if table == "users"
));
}
#[test]
fn test_strict_blocks_update_without_where_multiple_columns() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE users SET name = 'a', age = 30, status = 'active'");
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_strict_allows_update_with_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE users SET name = 'a' WHERE id = 1");
assert!(result.is_ok());
}
#[test]
fn test_strict_allows_update_with_where_in() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE users SET name = 'a' WHERE id IN (1, 2, 3)");
assert!(result.is_ok());
}
#[test]
fn test_strict_blocks_update_with_quoted_table() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE `users` SET name = 'a'");
assert!(matches!(
result,
Err(GuardError::FullTableUpdate { table }) if table == "users"
));
}
#[test]
fn test_strict_blocks_update_with_double_quoted_table() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE \"users\" SET name = 'a'");
assert!(matches!(
result,
Err(GuardError::FullTableUpdate { table }) if table == "users"
));
}
#[test]
fn test_strict_blocks_update_with_schema_qualified_table() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE public.users SET name = 'a'");
assert!(matches!(
result,
Err(GuardError::FullTableUpdate { table }) if table == "users"
));
}
#[test]
fn test_strict_blocks_delete_without_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("DELETE FROM users");
assert!(matches!(
result,
Err(GuardError::FullTableDelete { table }) if table == "users"
));
}
#[test]
fn test_strict_allows_delete_with_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("DELETE FROM users WHERE id = 1");
assert!(result.is_ok());
}
#[test]
fn test_strict_blocks_delete_with_quoted_table() {
let guard = SafeSqlGuard::strict();
let result = guard.check("DELETE FROM `users`");
assert!(matches!(
result,
Err(GuardError::FullTableDelete { table }) if table == "users"
));
}
#[test]
fn test_strict_blocks_delete_mysql_extension() {
let guard = SafeSqlGuard::strict();
let result = guard.check("DELETE users");
assert!(matches!(
result,
Err(GuardError::FullTableDelete { table }) if table == "users"
));
}
#[test]
fn test_permissive_allows_update_without_where() {
let guard = SafeSqlGuard::permissive();
let result = guard.check("UPDATE users SET name = 'a'");
assert!(result.is_ok());
}
#[test]
fn test_permissive_allows_delete_without_where() {
let guard = SafeSqlGuard::permissive();
let result = guard.check("DELETE FROM users");
assert!(result.is_ok());
}
#[test]
fn test_disabled_allows_everything() {
let guard = SafeSqlGuard::disabled();
assert!(guard.check("UPDATE users SET name = 'a'").is_ok());
assert!(guard.check("DELETE FROM users").is_ok());
}
#[test]
fn test_strict_allows_select_without_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("SELECT * FROM users");
assert!(result.is_ok());
}
#[test]
fn test_strict_allows_insert_without_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("INSERT INTO users (name) VALUES ('a')");
assert!(result.is_ok());
}
#[test]
fn test_strict_allows_create_table() {
let guard = SafeSqlGuard::strict();
let result = guard.check("CREATE TABLE users (id INT)");
assert!(result.is_ok());
}
#[test]
fn test_strict_blocks_multiline_update_without_where() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users\nSET name = 'a',\n age = 30";
let result = guard.check(sql);
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_strict_allows_multiline_update_with_where() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users\nSET name = 'a'\nWHERE id = 1";
let result = guard.check(sql);
assert!(result.is_ok());
}
#[test]
fn test_strict_blocks_update_with_line_comment_only() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users SET name = 'a' -- WHERE id = 1";
let result = guard.check(sql);
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_strict_blocks_update_with_block_comment_only() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users SET name = 'a' /* WHERE id = 1 */";
let result = guard.check(sql);
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_strict_allows_update_with_real_where_and_comment() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users SET name = 'a' /* update name */ WHERE id = 1";
let result = guard.check(sql);
assert!(result.is_ok());
}
#[test]
fn test_strict_does_not_treat_nowhere_as_where() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE my_table SET somewhere = 'x'";
let result = guard.check(sql);
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_check_update_method() {
let guard = SafeSqlGuard::strict();
assert!(guard.check_update("UPDATE users SET name = 'a'").is_err());
assert!(guard
.check_update("UPDATE users SET name = 'a' WHERE id = 1")
.is_ok());
}
#[test]
fn test_check_delete_method() {
let guard = SafeSqlGuard::strict();
assert!(guard.check_delete("DELETE FROM users").is_err());
assert!(guard.check_delete("DELETE FROM users WHERE id = 1").is_ok());
}
#[test]
fn test_default_guard_is_strict() {
let guard = SafeSqlGuard::default();
assert_eq!(guard.policy, GuardPolicy::Strict);
assert!(guard.check("UPDATE users SET name = 'a'").is_err());
}
#[test]
fn test_guard_error_display_full_table_update() {
let e = GuardError::FullTableUpdate {
table: "users".to_string(),
};
let s = format!("{}", e);
assert!(s.contains("Blocked full-table UPDATE"));
assert!(s.contains("users"));
}
#[test]
fn test_guard_error_display_full_table_delete() {
let e = GuardError::FullTableDelete {
table: "orders".to_string(),
};
let s = format!("{}", e);
assert!(s.contains("Blocked full-table DELETE"));
assert!(s.contains("orders"));
}
#[test]
fn test_guard_policy_default_is_strict() {
let p = GuardPolicy::default();
assert_eq!(p, GuardPolicy::Strict);
}
#[test]
fn test_strict_blocks_truncate() {
let guard = SafeSqlGuard::strict();
let result = guard.check("TRUNCATE TABLE users");
assert!(result.is_ok());
}
#[test]
fn test_strict_blocks_update_with_lowercase_keywords() {
let guard = SafeSqlGuard::strict();
let result = guard.check("update users set name = 'a'");
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_strict_blocks_delete_with_lowercase_keywords() {
let guard = SafeSqlGuard::strict();
let result = guard.check("delete from users");
assert!(matches!(result, Err(GuardError::FullTableDelete { .. })));
}
#[test]
fn test_strict_allows_update_with_lowercase_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("update users set name = 'a' where id = 1");
assert!(result.is_ok());
}
#[test]
fn test_strict_blocks_update_with_empty_where() {
let guard = SafeSqlGuard::strict();
let result = guard.check("UPDATE users SET name = 'a' WHERE ");
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
#[test]
fn test_normalize_sql_collapses_whitespace() {
let sql = "UPDATE users\n\nSET name = 'a'";
let normalized = normalize_sql(sql);
assert_eq!(normalized, "UPDATE users SET name = 'a'");
}
#[test]
fn test_normalize_sql_removes_line_comments() {
let sql = "UPDATE users -- this is a comment\nSET name = 'a'";
let normalized = normalize_sql(sql);
assert!(normalized.contains("UPDATE users"));
assert!(!normalized.contains("this is a comment"));
}
#[test]
fn test_normalize_sql_removes_block_comments() {
let sql = "UPDATE users /* block comment */ SET name = 'a'";
let normalized = normalize_sql(sql);
assert!(!normalized.contains("block comment"));
assert!(normalized.contains("UPDATE users"));
assert!(normalized.contains("SET name = 'a'"));
}
#[test]
fn test_blocks_update_with_where_only_in_subquery() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users SET name = (SELECT name FROM other WHERE id = 1)";
let result = guard.check(sql);
assert!(
matches!(result, Err(GuardError::FullTableUpdate { table }) if table == "users"),
"子查询中的 WHERE 不应被误认为外层 UPDATE 的 WHERE,应拦截"
);
}
#[test]
fn test_blocks_delete_with_where_only_in_subquery() {
let guard = SafeSqlGuard::strict();
let sql = "DELETE FROM users WHERE id IN (SELECT id FROM other)";
let result = guard.check(sql);
assert!(result.is_ok(), "外层 WHERE 应被识别");
let sql2 = "DELETE FROM users RETURNING (SELECT id FROM other WHERE x = 1)";
let result2 = guard.check(sql2);
assert!(
matches!(result2, Err(GuardError::FullTableDelete { table }) if table == "users"),
"子查询中的 WHERE 不应被误认为外层 DELETE 的 WHERE,应拦截"
);
}
#[test]
fn test_allows_update_with_real_where_and_subquery_where() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE users SET name = 'a' WHERE id IN (SELECT id FROM other WHERE active = 1)";
let result = guard.check(sql);
assert!(result.is_ok());
}
#[test]
fn test_blocks_update_with_field_named_where() {
let guard = SafeSqlGuard::strict();
let sql = "UPDATE my_table SET somewhere = 'x'";
let result = guard.check(sql);
assert!(matches!(result, Err(GuardError::FullTableUpdate { .. })));
}
}