diff --git a/ggsql-R/tests/testthat/test-engine.R b/ggsql-R/tests/testthat/test-engine.R index 460fce3c..65c8dafd 100644 --- a/ggsql-R/tests/testthat/test-engine.R +++ b/ggsql-R/tests/testthat/test-engine.R @@ -37,6 +37,7 @@ test_that("engine can handle a query without visualisation statement", { }) test_that("engine does not return a table when merely creating data", { + skip("Currently SQL queries are not allowed to alter environment") query <- "COPY ( SELECT * FROM (VALUES diff --git a/src/cli.rs b/src/cli.rs index 23c173c9..50678a1d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -335,7 +335,13 @@ fn print_table_fallback(query: &str, reader: &DuckDBReader, max_rows: usize) { } }; - let sql_part = source_tree.extract_sql().unwrap_or_default(); + let sql_part = match source_tree.extract_sql() { + Ok(sql) => sql.unwrap_or_default(), + Err(e) => { + eprintln!("SQL validation error: {}", e); + std::process::exit(1); + } + }; let data = reader.execute_sql(&sql_part); if let Err(e) = data { diff --git a/src/execute/cte.rs b/src/execute/cte.rs index 5a6b665f..ccb01b20 100644 --- a/src/execute/cte.rs +++ b/src/execute/cte.rs @@ -238,7 +238,8 @@ pub fn transform_global_sql( // Non-SELECT executable SQL (CREATE, INSERT, UPDATE, DELETE) // OR VISUALISE FROM (which injects SELECT * FROM ) // Extract SQL (with injection if VISUALISE FROM) and transform CTE references - let sql = source_tree.extract_sql()?; + // Note: Safety validation is done in execute/mod.rs, so use unchecked here + let sql = source_tree.extract_sql_unchecked()?; Some(transform_cte_references(&sql, materialized_ctes)) } else { // No executable SQL (just CTEs) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 625a17b4..2cb40d4f 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -926,7 +926,8 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result = HashMap::new(); // Extract SQL once (reused later for PreparedData) - let sql_part = source_tree.extract_sql(); + // Safety validation happens here - will error on dangerous SQL patterns + let sql_part = source_tree.extract_sql()?; // Execute global SQL if present // If there's a WITH clause, extract just the trailing SELECT and transform CTE references. diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 79396f30..94d82d07 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -146,16 +146,27 @@ fn parse_value_node(node: &Node, source: &SourceTree, context: &str) -> Result

DataSource { +/// Parse a data source node (identifier, string file path, or subquery) +/// Returns an error if a subquery contains dangerous SQL patterns. +fn parse_data_source(node: &Node, source: &SourceTree) -> Result { match node.kind() { "string" => { let path = parse_string_node(node, source); - DataSource::FilePath(path) + Ok(DataSource::FilePath(path)) + } + "subquery" => { + // Subquery: validate the SQL content for safety + let text = source.get_text(node); + crate::validate::validate_sql_safety(&text)?; + Ok(DataSource::Identifier(text)) } _ => { let text = source.get_text(node); - DataSource::Identifier(text) + // Check if identifier looks like a subquery (starts with `(`) + if text.trim_start().starts_with('(') { + crate::validate::validate_sql_safety(&text)?; + } + Ok(DataSource::Identifier(text)) } } } @@ -258,7 +269,7 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result { // Extract the 'table' field from table_ref (grammar: FROM table_ref) let query = "(table_ref table: (_) @table)"; if let Some(table_node) = source.find_node(&child, query) { - spec.source = Some(parse_data_source(&table_node, source)); + spec.source = Some(parse_data_source(&table_node, source)?); } } "viz_clause" => { @@ -453,7 +464,8 @@ fn build_layer(node: &Node, source: &SourceTree) -> Result { aesthetics = parse_mapping(&child, source)?; layer_source = child .child_by_field_name("layer_source") - .map(|src| parse_data_source(&src, source)); + .map(|src| parse_data_source(&src, source)) + .transpose()?; } "remapping_clause" => { // Parse stat result remappings (same syntax as mapping_clause) @@ -569,11 +581,12 @@ fn parse_partition_clause(node: &Node, source: &SourceTree) -> Result Result { let query = "(filter_expression) @expr"; if let Some(filter_text) = source.find_text(node, query) { - Ok(SqlExpression::new(filter_text.trim().to_string())) + SqlExpression::new(filter_text.trim().to_string()) } else { Err(GgsqlError::ParseError( "Could not find filter expression in filter clause".to_string(), @@ -582,11 +595,12 @@ fn parse_filter_clause(node: &Node, source: &SourceTree) -> Result Result { let query = "(order_expression) @expr"; if let Some(order_text) = source.find_text(node, query) { - Ok(SqlExpression::new(order_text.trim().to_string())) + SqlExpression::new(order_text.trim().to_string()) } else { Err(GgsqlError::ParseError( "Could not find order expression in order clause".to_string(), @@ -3308,7 +3322,7 @@ mod tests { let root = source.root(); let from_node = source.find_node(&root, "(table_ref) @ref").unwrap(); - let parsed = parse_data_source(&from_node, &source); + let parsed = parse_data_source(&from_node, &source).unwrap(); assert!(matches!(parsed, DataSource::Identifier(ref name) if name == "sales")); // Test file path - table_ref contains a string child @@ -3317,7 +3331,7 @@ mod tests { let from_node2 = source2.find_node(&root2, "(table_ref) @ref").unwrap(); let string_node = source2.find_node(&from_node2, "(string) @s").unwrap(); - let parsed2 = parse_data_source(&string_node, &source2); + let parsed2 = parse_data_source(&string_node, &source2).unwrap(); assert!(matches!(parsed2, DataSource::FilePath(ref path) if path == "data.csv")); } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 6c80d63a..8488b594 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -95,7 +95,7 @@ mod tests { "#; let source_tree = SourceTree::new(query).unwrap(); - let sql = source_tree.extract_sql().unwrap(); + let sql = source_tree.extract_sql().unwrap().unwrap(); assert!(sql.contains("SELECT date, revenue FROM sales")); assert!(sql.contains("WHERE year = 2024")); assert!(!sql.contains("VISUALISE")); diff --git a/src/parser/source_tree.rs b/src/parser/source_tree.rs index ef4acc57..ec7215ef 100644 --- a/src/parser/source_tree.rs +++ b/src/parser/source_tree.rs @@ -3,6 +3,7 @@ //! The `SourceTree` struct wraps a tree-sitter parse tree along with the source text //! and language, providing high-level query operations for tree traversal and text extraction. +use crate::validate::validate_sql_safety; use crate::{GgsqlError, Result}; use tree_sitter::{Language, Node, Parser, Query, QueryCursor, StreamingIterator, Tree}; @@ -107,11 +108,25 @@ impl<'a> SourceTree<'a> { .collect() } - /// Extract the SQL portion of the query (before VISUALISE) + /// Extract the SQL portion of the query (before VISUALISE) with safety validation. /// /// If VISUALISE FROM is used, this injects "SELECT * FROM " - /// Returns None if there's no SQL portion and no VISUALISE FROM injection needed - pub fn extract_sql(&self) -> Option { + /// Returns None if there's no SQL portion and no VISUALISE FROM injection needed. + /// + /// Returns an error if the SQL contains dangerous operations (DROP, DELETE, etc.) + pub fn extract_sql(&self) -> Result> { + let sql = self.extract_sql_unchecked(); + if let Some(ref sql_text) = sql { + validate_sql_safety(sql_text)?; + } + Ok(sql) + } + + /// Extract the SQL portion without safety validation. + /// + /// Use this only in trusted environments or when validation is handled separately. + /// Prefer `extract_sql()` for user-provided queries. + pub fn extract_sql_unchecked(&self) -> Option { let root = self.root(); // Check if there's any VISUALISE statement @@ -181,7 +196,7 @@ mod tests { let query = "SELECT * FROM data VISUALISE DRAW point MAPPING x AS x, y AS y"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); assert_eq!(sql, "SELECT * FROM data"); let viz = tree.extract_visualise().unwrap(); @@ -194,7 +209,7 @@ mod tests { let query = "SELECT * FROM data visualise x, y DRAW point"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); assert_eq!(sql, "SELECT * FROM data"); let viz = tree.extract_visualise().unwrap(); @@ -206,7 +221,7 @@ mod tests { let query = "SELECT * FROM data WHERE x > 5"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); assert_eq!(sql, query); let viz = tree.extract_visualise(); @@ -218,7 +233,7 @@ mod tests { let query = "VISUALISE FROM mtcars DRAW point MAPPING mpg AS x, hp AS y"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); // Should inject SELECT * FROM mtcars assert_eq!(sql, "SELECT * FROM mtcars"); @@ -232,7 +247,7 @@ mod tests { "WITH cte AS (SELECT * FROM x) VISUALISE FROM cte DRAW point MAPPING a AS x, b AS y"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); // Should inject SELECT * FROM cte after the WITH assert!(sql.contains("WITH cte AS (SELECT * FROM x)")); assert!(sql.contains("SELECT * FROM cte")); @@ -243,10 +258,11 @@ mod tests { #[test] fn test_extract_sql_visualise_from_after_create() { + // CREATE is blocked by safety validation, use unchecked for this test let query = "CREATE TABLE x AS SELECT 1; VISUALISE FROM x"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql_unchecked().unwrap(); assert!(sql.contains("CREATE TABLE x AS SELECT 1;")); assert!(sql.contains("SELECT * FROM x")); @@ -257,7 +273,7 @@ mod tests { let query2 = "CREATE TABLE x AS SELECT 1 VISUALISE FROM x"; let tree2 = SourceTree::new(query2).unwrap(); - let sql2 = tree2.extract_sql().unwrap(); + let sql2 = tree2.extract_sql_unchecked().unwrap(); assert!(sql2.contains("CREATE TABLE x AS SELECT 1")); assert!(sql2.contains("SELECT * FROM x")); @@ -267,10 +283,11 @@ mod tests { #[test] fn test_extract_sql_visualise_from_after_insert() { + // INSERT is blocked by safety validation, use unchecked for this test let query = "INSERT INTO x VALUES (1) VISUALISE FROM x DRAW"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql_unchecked().unwrap(); assert!(sql.contains("INSERT")); let viz = tree.extract_visualise().unwrap(); @@ -282,7 +299,7 @@ mod tests { let query = "SELECT * FROM x VISUALISE DRAW point MAPPING a AS x, b AS y"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); // Should NOT inject anything - just extract SQL normally assert_eq!(sql, "SELECT * FROM x"); assert!(!sql.contains("SELECT * FROM SELECT")); // Make sure we didn't double-inject @@ -293,7 +310,7 @@ mod tests { let query = "VISUALISE FROM 'mtcars.csv' DRAW point MAPPING mpg AS x, hp AS y"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); // Should inject SELECT * FROM 'mtcars.csv' with quotes preserved assert_eq!(sql, "SELECT * FROM 'mtcars.csv'"); @@ -307,7 +324,7 @@ mod tests { r#"VISUALISE FROM "data/sales.parquet" DRAW bar MAPPING region AS x, total AS y"#; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); // Should inject SELECT * FROM "data/sales.parquet" with quotes preserved assert_eq!(sql, r#"SELECT * FROM "data/sales.parquet""#); @@ -320,7 +337,7 @@ mod tests { let query = "WITH prep AS (SELECT * FROM 'raw.csv' WHERE year = 2024) VISUALISE FROM prep DRAW line MAPPING date AS x, value AS y"; let tree = SourceTree::new(query).unwrap(); - let sql = tree.extract_sql().unwrap(); + let sql = tree.extract_sql().unwrap().unwrap(); // Should inject SELECT * FROM prep after WITH assert!(sql.contains("WITH prep AS")); assert!(sql.contains("SELECT * FROM prep")); @@ -328,6 +345,38 @@ mod tests { assert!(sql.contains("'raw.csv'")); } + // ======================================================================== + // SQL Safety Validation Tests + // ======================================================================== + + #[test] + fn test_extract_sql_blocks_delete() { + // DELETE is supported by grammar, so validation should block it + let query = "DELETE FROM users VISUALISE DRAW point"; + let tree = SourceTree::new(query).unwrap(); + let result = tree.extract_sql(); + assert!(result.is_err(), "Expected error, got: {:?}", result); + assert!(result.unwrap_err().to_string().contains("DELETE")); + } + + #[test] + fn test_extract_sql_blocks_update() { + // UPDATE is supported by grammar, so validation should block it + let query = "UPDATE users SET name = 'foo' VISUALISE DRAW point"; + let tree = SourceTree::new(query).unwrap(); + let result = tree.extract_sql(); + assert!(result.is_err(), "Expected error, got: {:?}", result); + assert!(result.unwrap_err().to_string().contains("UPDATE")); + } + + #[test] + fn test_extract_sql_allows_keyword_in_string() { + let query = "SELECT 'DELETE' as x FROM data VISUALISE DRAW point"; + let tree = SourceTree::new(query).unwrap(); + let result = tree.extract_sql(); + assert!(result.is_ok(), "Should allow DELETE in string literal"); + } + // ======================================================================== // Query Method Tests: find_node() // ======================================================================== diff --git a/src/plot/main.rs b/src/plot/main.rs index 08eb3d53..0c43bd62 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -350,7 +350,7 @@ mod tests { #[test] fn test_layer_with_filter() { - let filter = SqlExpression::new("year > 2020"); + let filter = SqlExpression::new("year > 2020").unwrap(); let layer = Layer::new(Geom::point()).with_filter(filter); assert!(layer.filter.is_some()); assert_eq!(layer.filter.as_ref().unwrap().as_str(), "year > 2020"); diff --git a/src/plot/types.rs b/src/plot/types.rs index 99a0a34e..57eb9442 100644 --- a/src/plot/types.rs +++ b/src/plot/types.rs @@ -4,6 +4,7 @@ //! settings, and values. These are the building blocks used in AST types //! to capture what the user specified in their query. +use crate::validate::validate_sql_safety; use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, Timelike}; use polars::prelude::DataType; use serde::{Deserialize, Serialize}; @@ -835,9 +836,14 @@ pub enum CastTargetType { } impl SqlExpression { - /// Create a new SQL expression from raw text - pub fn new(sql: impl Into) -> Self { - Self(sql.into()) + /// Create a new SQL expression from raw text. + /// + /// Returns an error if the expression contains dangerous SQL patterns + /// (DROP, DELETE, UPDATE, INSERT, etc.) + pub fn new(sql: impl Into) -> crate::Result { + let sql = sql.into(); + validate_sql_safety(&sql)?; + Ok(Self(sql)) } /// Get the raw SQL text diff --git a/src/validate.rs b/src/validate/mod.rs similarity index 84% rename from src/validate.rs rename to src/validate/mod.rs index b07e8585..6f2ec066 100644 --- a/src/validate.rs +++ b/src/validate/mod.rs @@ -3,6 +3,9 @@ //! This module provides query syntax and semantic validation without executing //! any SQL. Use this for IDE integration, syntax checking, and query inspection. +pub mod sql_safety; +pub use sql_safety::validate_sql_safety; + use crate::parser; use crate::Result; @@ -110,9 +113,17 @@ pub fn validate(query: &str) -> Result { }; // Extract SQL and viz portions using existing tree - let sql_part = source_tree.extract_sql().unwrap_or_default(); + let sql_part = source_tree.extract_sql_unchecked().unwrap_or_default(); let viz_part = source_tree.extract_visualise().unwrap_or_default(); + // Validate SQL safety + if let Err(e) = validate_sql_safety(&sql_part) { + errors.push(ValidationError { + message: e.to_string(), + location: None, + }); + } + let root = source_tree.root(); let has_visual = source_tree .find_node(&root, "(visualise_statement) @viz") @@ -125,7 +136,7 @@ pub fn validate(query: &str) -> Result { visual: viz_part, has_visual: false, tree: None, - valid: true, + valid: errors.is_empty(), errors, warnings, }); @@ -282,4 +293,41 @@ mod tests { assert!(validated.valid()); assert!(validated.errors().is_empty()); } + + // SQL safety tests - use DELETE/UPDATE which are supported by grammar + #[test] + fn test_validate_blocks_delete() { + let validated = + validate("DELETE FROM users VISUALISE DRAW point MAPPING 1 AS x, 1 AS y").unwrap(); + assert!(!validated.valid()); + assert!( + validated.errors()[0].message.contains("DELETE"), + "Expected DELETE error, got: {:?}", + validated.errors() + ); + } + + #[test] + fn test_validate_blocks_update() { + let validated = + validate("UPDATE users SET x = 1 VISUALISE DRAW point MAPPING 1 AS x, 1 AS y").unwrap(); + assert!(!validated.valid()); + assert!( + validated.errors()[0].message.contains("UPDATE"), + "Expected UPDATE error, got: {:?}", + validated.errors() + ); + } + + #[test] + fn test_validate_allows_keyword_in_string() { + let validated = + validate("SELECT 'DELETE' as x, 1 as y VISUALISE DRAW point MAPPING x AS x, y AS y") + .unwrap(); + assert!( + validated.valid(), + "Should allow DELETE in string literal: {:?}", + validated.errors() + ); + } } diff --git a/src/validate/sql_safety.rs b/src/validate/sql_safety.rs new file mode 100644 index 00000000..d9810a68 --- /dev/null +++ b/src/validate/sql_safety.rs @@ -0,0 +1,257 @@ +//! SQL safety validation to prevent dangerous operations. +//! +//! This module validates SQL queries to block dangerous patterns that could +//! modify data, alter schema, or execute privileged operations. + +use crate::{GgsqlError, Result}; +use regex::Regex; +use std::sync::LazyLock; + +/// Dangerous SQL keywords (case-insensitive) +/// Covers: DDL, DML, permissions, and database-specific dangerous operations +static DANGEROUS_PATTERN: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b(DROP|TRUNCATE|ALTER|DELETE|UPDATE|INSERT|GRANT|REVOKE|COPY|ATTACH|DETACH|LOAD|INSTALL|CALL|EXEC|EXECUTE|VACUUM|PRAGMA|IMPORT|EXPORT)\b").unwrap() +}); + +/// SQL Server dangerous extended procedures (xp_cmdshell allows OS command execution!) +static SQLSERVER_DANGEROUS: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\b(xp_cmdshell|xp_regread|xp_regwrite|xp_dirtree|xp_fileexist|xp_subdirs|sp_OACreate|sp_OAMethod|OPENROWSET|OPENDATASOURCE)\b").unwrap() +}); + +/// CREATE OR REPLACE pattern +static CREATE_OR_REPLACE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)CREATE\s+OR\s+REPLACE").unwrap()); + +/// Data-modifying CTEs (WITH ... DELETE/UPDATE/INSERT ... RETURNING) +static CTE_DML: LazyLock = LazyLock::new(|| { + Regex::new(r"(?i)\bWITH\b.*?\b(DELETE|UPDATE|INSERT)\b.*?\bRETURNING\b").unwrap() +}); + +/// Regex to match line comments (-- ...) +static LINE_COMMENT: LazyLock = LazyLock::new(|| Regex::new(r"--[^\n]*").unwrap()); + +/// Regex to match block comments (/* ... */) +static BLOCK_COMMENT: LazyLock = LazyLock::new(|| Regex::new(r"/\*.*?\*/").unwrap()); + +/// Regex to match string literals ('...' with escape handling) +static STRING_LITERAL: LazyLock = + LazyLock::new(|| Regex::new(r"'(?:[^'\\]|\\.)*'").unwrap()); + +/// Validate SQL for dangerous operations. +/// +/// Blocks: DROP, DELETE, TRUNCATE, UPDATE, INSERT, ALTER, GRANT, REVOKE, +/// CREATE OR REPLACE, and data-modifying CTEs. +pub fn validate_sql_safety(sql: &str) -> Result<()> { + if sql.trim().is_empty() { + return Ok(()); + } + + let preprocessed = preprocess_sql(sql); + + if CREATE_OR_REPLACE.is_match(&preprocessed) { + return Err(GgsqlError::ValidationError( + "CREATE OR REPLACE is not allowed for safety reasons".into(), + )); + } + + if CTE_DML.is_match(&preprocessed) { + return Err(GgsqlError::ValidationError( + "Data-modifying CTEs (WITH ... DELETE/UPDATE/INSERT ... RETURNING) are not allowed" + .into(), + )); + } + + if let Some(caps) = DANGEROUS_PATTERN.captures(&preprocessed) { + let keyword = caps.get(1).unwrap().as_str().to_uppercase(); + return Err(GgsqlError::ValidationError(format!( + "{} statements are not allowed for safety reasons", + keyword + ))); + } + + // SQL Server dangerous extended procedures + if let Some(caps) = SQLSERVER_DANGEROUS.captures(&preprocessed) { + let proc = caps.get(1).unwrap().as_str(); + return Err(GgsqlError::ValidationError(format!( + "{} is not allowed for safety reasons", + proc + ))); + } + + Ok(()) +} + +/// Remove comments and string literals to avoid false positives. +fn preprocess_sql(sql: &str) -> String { + let mut result = sql.to_string(); + // Remove line comments (-- ...) + result = LINE_COMMENT.replace_all(&result, "").to_string(); + // Remove block comments (/* ... */) + result = BLOCK_COMMENT.replace_all(&result, "").to_string(); + // Replace string literals with placeholder + result = STRING_LITERAL.replace_all(&result, "'...'").to_string(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + // Safe queries + #[test] + fn test_safe_select() { + assert!(validate_sql_safety("SELECT * FROM users").is_ok()); + } + + #[test] + fn test_safe_compound_statement() { + // Compound statements ARE allowed in main SQL + assert!(validate_sql_safety("SELECT 1; SELECT 2").is_ok()); + } + + #[test] + fn test_keyword_in_string() { + assert!(validate_sql_safety("SELECT * FROM t WHERE name = 'DROP TABLE'").is_ok()); + } + + #[test] + fn test_keyword_in_comment() { + assert!(validate_sql_safety("SELECT * -- DROP TABLE").is_ok()); + assert!(validate_sql_safety("SELECT /* DELETE */ *").is_ok()); + } + + // Dangerous queries + #[test] + fn test_drop_blocked() { + let r = validate_sql_safety("DROP TABLE users"); + assert!(r.is_err()); + assert!(r.unwrap_err().to_string().contains("DROP")); + } + + #[test] + fn test_delete_blocked() { + assert!(validate_sql_safety("DELETE FROM users").is_err()); + } + + #[test] + fn test_update_blocked() { + assert!(validate_sql_safety("UPDATE users SET x = 1").is_err()); + } + + #[test] + fn test_insert_blocked() { + assert!(validate_sql_safety("INSERT INTO users VALUES (1)").is_err()); + } + + #[test] + fn test_truncate_blocked() { + assert!(validate_sql_safety("TRUNCATE TABLE users").is_err()); + } + + #[test] + fn test_alter_blocked() { + assert!(validate_sql_safety("ALTER TABLE users ADD x INT").is_err()); + } + + #[test] + fn test_grant_blocked() { + assert!(validate_sql_safety("GRANT SELECT ON users TO public").is_err()); + } + + #[test] + fn test_revoke_blocked() { + assert!(validate_sql_safety("REVOKE SELECT ON users FROM public").is_err()); + } + + #[test] + fn test_create_or_replace_blocked() { + assert!(validate_sql_safety("CREATE OR REPLACE VIEW v AS SELECT 1").is_err()); + } + + #[test] + fn test_data_modifying_cte_blocked() { + assert!( + validate_sql_safety("WITH d AS (DELETE FROM users RETURNING *) SELECT * FROM d") + .is_err() + ); + } + + #[test] + fn test_case_insensitive() { + assert!(validate_sql_safety("drop table users").is_err()); + assert!(validate_sql_safety("DrOp TaBlE users").is_err()); + } + + // Database-specific dangerous operations + #[test] + fn test_copy_blocked() { + assert!(validate_sql_safety("COPY users TO '/tmp/data.csv'").is_err()); + assert!(validate_sql_safety("COPY users FROM '/tmp/data.csv'").is_err()); + } + + #[test] + fn test_attach_blocked() { + assert!(validate_sql_safety("ATTACH DATABASE 'other.db' AS other").is_err()); + assert!(validate_sql_safety("DETACH DATABASE other").is_err()); + } + + #[test] + fn test_load_install_blocked() { + assert!(validate_sql_safety("LOAD 'httpfs'").is_err()); + assert!(validate_sql_safety("INSTALL httpfs").is_err()); + } + + #[test] + fn test_exec_blocked() { + assert!(validate_sql_safety("EXEC sp_executesql @sql").is_err()); + assert!(validate_sql_safety("EXECUTE sp_help").is_err()); + assert!(validate_sql_safety("CALL my_procedure()").is_err()); + } + + #[test] + fn test_vacuum_pragma_blocked() { + assert!(validate_sql_safety("VACUUM").is_err()); + assert!(validate_sql_safety("PRAGMA table_info(users)").is_err()); + } + + #[test] + fn test_import_export_blocked() { + assert!(validate_sql_safety("EXPORT DATABASE '/backup'").is_err()); + assert!(validate_sql_safety("IMPORT DATABASE '/backup'").is_err()); + } + + // SQL Server dangerous procedures + #[test] + fn test_xp_cmdshell_blocked() { + // xp_cmdshell allows OS command execution - extremely dangerous! + assert!(validate_sql_safety("EXEC xp_cmdshell 'whoami'").is_err()); + assert!(validate_sql_safety("xp_cmdshell 'dir'").is_err()); + } + + #[test] + fn test_xp_registry_blocked() { + assert!(validate_sql_safety("EXEC xp_regread 'HKEY_LOCAL_MACHINE'").is_err()); + assert!(validate_sql_safety("EXEC xp_regwrite 'HKEY_LOCAL_MACHINE'").is_err()); + } + + #[test] + fn test_xp_filesystem_blocked() { + assert!(validate_sql_safety("EXEC xp_dirtree '/etc'").is_err()); + assert!(validate_sql_safety("EXEC xp_fileexist '/etc/passwd'").is_err()); + assert!(validate_sql_safety("EXEC xp_subdirs 'C:\\'").is_err()); + } + + #[test] + fn test_sp_oa_blocked() { + // OLE automation can execute arbitrary code + assert!(validate_sql_safety("EXEC sp_OACreate 'WScript.Shell'").is_err()); + assert!(validate_sql_safety("EXEC sp_OAMethod @obj, 'Run'").is_err()); + } + + #[test] + fn test_openrowset_blocked() { + // OPENROWSET can read external files and execute code + assert!(validate_sql_safety("SELECT * FROM OPENROWSET('SQLNCLI', ...)").is_err()); + assert!(validate_sql_safety("SELECT * FROM OPENDATASOURCE('SQLNCLI', ...)").is_err()); + } +}