From e9ce788be8cf0df2a98ccd24ba8d227ca3bee7a3 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Sat, 24 Oct 2026 05:17:34 -0700 Subject: [PATCH] =?UTF-8?q?fix(database):=20=E7=94=9F=E4=BA=A7=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E6=8C=89=E8=AF=8D=E6=B3=95=E5=88=A4=E5=AE=9A=E5=8D=B1?= =?UTF-8?q?=E9=99=A9=20SQL=EF=BC=8C=E6=89=A7=E8=A1=8C=E5=89=8D=E5=85=88?= =?UTF-8?q?=E7=94=B1=E5=90=8E=E7=AB=AF=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidecar 的危险 SQL 判定从字符串前缀/" WHERE " 匹配改为词法分析:去掉注释、 字符串、引号标识符和 $$ 块;按语句逐条检查;WHERE 必须直接限定该 UPDATE/DELETE(子查询里的 WHERE 不算);识别 WITH ... DELETE 与数据修改型 CTE;DROP/TRUNCATE(含 ALTER ... DROP)需要确认;排除 FOR UPDATE、 ON DELETE CASCADE、GRANT、MySQL TRUNCATE() 等非写入用法;MySQL/MariaDB 与 PostgreSQL E'' 字符串按反斜杠转义处理,SQL Server 识别 [标识符]。 - 修复根因:执行前格式化会把 WHERE 换到新行,旧判定只认 " WHERE ",导致 生产保护连接上所有带 WHERE 的 UPDATE/DELETE 都被拒绝。 - sidecar 新增不连库的 checkWriteProtection 方法;macOS 在生产保护连接执行写入 前先问后端,需要确认时弹出原有确认框,避免批量执行到一半被拒。 - 补充 Rust 金样例测试与 Swift 预检测试。 Co-Authored-By: Claude Opus 5.5 --- .../Database/DatabaseSQLWorkspaceView.swift | 31 +- .../Application/DatabaseFeatureModel.swift | 19 ++ .../Services/DatabaseSidecarService.swift | 8 + .../LitheTests/LitheCoreLogicTests.swift | 55 ++++ rust/lithe-db-sidecar/src/main.rs | 309 +++++++++++++++++- .../features/database-query-history.json | 7 +- 6 files changed, 400 insertions(+), 29 deletions(-) diff --git a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift index 8fd22507fb..e87535a91c 100644 --- a/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift +++ b/macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift @@ -182,6 +182,7 @@ struct DatabaseSQLWorkspaceView: View { @State private var pendingScope: DatabaseSQLExecutionScope = .all @State private var pendingTabID: UUID? @State private var showsRiskConfirmation = false + @State private var isCheckingRisk = false @State private var sqlSelection = "" @State private var sqlSelectionRange = NSRange(location: 0, length: 0) @State private var showsQueryHistory = false @@ -240,7 +241,7 @@ struct DatabaseSQLWorkspaceView: View { } .litheToolbarIconButton() .accessibilityLabel("Run") - .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil) + .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil || isCheckingRisk) .help(hasSQLSelection ? "Run selected SQL (Command-Return)" : "Run all SQL (Command-Return)") LitheMenu { @@ -251,7 +252,7 @@ struct DatabaseSQLWorkspaceView: View { LitheIDEAIcon(resourcePath: "expui/general/chevronDown.svg", size: LitheTheme.Metrics.toolbarIconSize, preservesOriginalColors: true) } .litheToolbarIconButton() - .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil) + .disabled(feature.selectedSQLTab?.isRunning == true || feature.selectedProfile == nil || isCheckingRisk) .workbenchHoverHelp(Text("Choose SQL execution scope")) .accessibilityLabel("Choose SQL execution scope") @@ -390,17 +391,21 @@ struct DatabaseSQLWorkspaceView: View { } private func run(scope: DatabaseSQLExecutionScope) { - guard let tabID = feature.selectedSQLTabID else { return } - let analysis = feature.analysis(forSQLTab: tabID, scope: scope) - if analysis.requiresConfirmation { - pendingRisk = analysis - pendingScope = scope - pendingTabID = tabID - showsRiskConfirmation = true - } else { - model.showToolWindow(.databaseServices) - let selection = sqlSelectionRange - Task { await feature.runSQL(in: tabID, scope: scope, selectionRange: selection) } + guard let tabID = feature.selectedSQLTabID, !isCheckingRisk else { return } + let selection = sqlSelectionRange + isCheckingRisk = true + Task { + let risk = await feature.sqlConfirmation(forSQLTab: tabID, scope: scope) + isCheckingRisk = false + if let risk { + pendingRisk = risk + pendingScope = scope + pendingTabID = tabID + showsRiskConfirmation = true + } else { + model.showToolWindow(.databaseServices) + await feature.runSQL(in: tabID, scope: scope, selectionRange: selection) + } } } diff --git a/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift b/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift index a5e4b73f27..e6dbd61dee 100644 --- a/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift +++ b/macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift @@ -1640,6 +1640,25 @@ package final class DatabaseFeatureModel: ObservableObject { DatabaseSQLAnalyzer.analyze(sql(for: tabID, scope: scope)) } + /// Returns the analysis the user must confirm before running, or nil when the SQL may run directly. + /// The database helper owns production protection, so it is asked before the first statement runs; + /// otherwise a statement it rejects would stop the batch after earlier statements were applied. + package func sqlConfirmation(forSQLTab tabID: UUID, scope: DatabaseSQLExecutionScope = .all) async -> DatabaseSQLAnalysis? { + let sql = sql(for: tabID, scope: scope) + let analysis = DatabaseSQLAnalyzer.analyze(sql) + if analysis.requiresConfirmation { return analysis } + guard analysis.canExecute, let profile = selectedProfile, profile.productionProtection, + analysis.statements.contains(where: { !DatabaseSQLAnalyzer.analyze($0).kind.usesQueryEndpoint }) else { return nil } + let connection = connection(profile) + // A helper failure falls through: execute reports it, and the helper still refuses unconfirmed risky SQL. + let requiresConfirmation = (try? await Task.detached { [operations] in + try operations.requiresWriteConfirmation(connection: connection, sql: sql) + }.value) ?? false + guard requiresConfirmation else { return nil } + return DatabaseSQLAnalysis(kind: analysis.kind, statementCount: analysis.statementCount, requiresConfirmation: true, + warning: "Production protection requires confirmation before this SQL runs.", statements: analysis.statements) + } + private func sql(for tabID: UUID, scope: DatabaseSQLExecutionScope) -> String { switch scope { case .all: diff --git a/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift b/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift index d44cbc0f00..eadf7f97da 100644 --- a/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift +++ b/macos/Sources/LitheDatabaseModule/Services/DatabaseSidecarService.swift @@ -392,6 +392,8 @@ package protocol DatabaseOperations: Sendable { func pageTable(connection: DatabaseConnection, schema: String, table: String, limit: Int, offset: Int, filters: [DatabaseFilter], sort: [DatabaseSort]) throws -> DatabaseQueryResult func query(connection: DatabaseConnection, sql: String, values: [DatabaseValue], limit: Int) throws -> DatabaseQueryResult func execute(connection: DatabaseConnection, sql: String, values: [DatabaseValue], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult + /// Whether `execute` would reject `sql` under production protection until confirmed; does not connect. + func requiresWriteConfirmation(connection: DatabaseConnection, sql: String) throws -> Bool func applyChanges(connection: DatabaseConnection, schema: String, mutations: [DatabaseMutation], confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult func applySchemaChange(connection: DatabaseConnection, schema: String, change: DatabaseSchemaChange, confirmed: Bool, allowWrite: Bool) throws -> DatabaseExecuteResult func explain(connection: DatabaseConnection, sql: String, format: String) throws -> DatabaseQueryResult @@ -531,6 +533,11 @@ package final class DatabaseSidecarService: DatabaseOperations, @unchecked Senda try request(method: "execute", params: QueryParams(connection: connection, sql: sql, values: values, confirmed: confirmed, allowWrite: allowWrite)) } + package func requiresWriteConfirmation(connection: DatabaseConnection, sql: String) throws -> Bool { + let result: WriteProtectionResult = try request(method: "checkWriteProtection", params: QueryParams(connection: connection, sql: sql)) + return result.requiresConfirmation + } + package func applyChanges(connection: DatabaseConnection, schema: String = "", mutations: [DatabaseMutation], confirmed: Bool = false, allowWrite: Bool = false) throws -> DatabaseExecuteResult { try request(method: "applyChanges", params: MutationParams(connection: connection, schema: schema, mutations: mutations, confirmed: confirmed, allowWrite: allowWrite)) } @@ -701,6 +708,7 @@ private struct EmptyParams: Codable {} private struct ConnectionParams: Codable { let connection: DatabaseConnection } private struct ConnectedResult: Codable { let connected: Bool } private struct EmptyResult: Codable {} +private struct WriteProtectionResult: Codable { let requiresConfirmation: Bool } private struct DatabaseRowsResult: Decodable { package let rows: [DatabaseRow] diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index c401d74e76..6ae9b7a29c 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -1982,6 +1982,61 @@ struct LitheCoreLogicTests { #expect(feature.sqlHistory.first?.sql.contains("SELECT\n 1") == true) } + @Test + @MainActor + func databaseSQLConfirmationAsksTheHelperBeforeProtectedWrites() async throws { + let preferences = DatabaseTestKeyValueStore() + let store = DatabaseConnectionStore(store: preferences, secureStore: DatabaseTestSecureStore()) + let protected = DatabaseProfile(name: "Production", kind: .sqlite, path: "/tmp/lithe-protected.sqlite", productionProtection: true) + let local = DatabaseProfile(name: "Local", kind: .sqlite, path: "/tmp/lithe-local.sqlite") + try store.save([protected, local]) + let runner = RecordingProcessRunner { request in + let object = try! JSONSerialization.jsonObject(with: try! #require(request.standardInput)) as! [String: Any] + let id = object["id"] as! String + let sql = (object["params"] as? [String: Any])?["sql"] as? String ?? "" + guard object["method"] as? String == "checkWriteProtection" else { + return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":[]}"#, exitCode: 0) + } + // Stand-in for the helper's lexer: the subquery WHERE does not bound the outer UPDATE. + let requires = sql.contains("SELECT") + return ProcessResult(output: #"{"id":"\#(id)","ok":true,"result":{"requiresConfirmation":\#(requires)}}"#, exitCode: 0) + } + let feature = DatabaseFeatureModel( + operations: DatabaseSidecarService(processRunner: runner, executableURL: URL(fileURLWithPath: "/tmp/lithe-db-sidecar")), + connectionStore: store + ) + func checkedSQL() -> [String] { + runner.requests.compactMap { request in + guard let input = request.standardInput, + let object = try? JSONSerialization.jsonObject(with: input) as? [String: Any], + object["method"] as? String == "checkWriteProtection" else { return nil } + return (object["params"] as? [String: Any])?["sql"] as? String + } + } + + await feature.select(protected) + feature.addSQLTab(sql: "UPDATE items SET value = (SELECT max(value) FROM items WHERE id = 1)") + let tabID = try #require(feature.selectedSQLTabID) + // The local analyzer sees a WHERE token, so only the helper can ask for confirmation here. + #expect(!feature.analysis(forSQLTab: tabID).requiresConfirmation) + let risk = try #require(await feature.sqlConfirmation(forSQLTab: tabID)) + #expect(risk.requiresConfirmation) + #expect(risk.warning == "Production protection requires confirmation before this SQL runs.") + + // A bounded write and a read-only query run without a prompt; queries never reach the helper check. + feature.updateSQL("UPDATE items SET value = 2\nWHERE id = 1", in: tabID) + #expect(await feature.sqlConfirmation(forSQLTab: tabID) == nil) + feature.updateSQL("SELECT 1", in: tabID) + #expect(await feature.sqlConfirmation(forSQLTab: tabID) == nil) + #expect(checkedSQL().count == 2) + + // Without production protection the helper is not consulted, even for SQL it would flag. + await feature.select(local) + feature.addSQLTab(sql: "UPDATE items SET value = 2 WHERE id = (SELECT 1)") + #expect(await feature.sqlConfirmation(forSQLTab: try #require(feature.selectedSQLTabID)) == nil) + #expect(checkedSQL().count == 2) + } + @Test @MainActor func databaseSQLBatchStopsAfterTheFirstFailedStatement() async throws { diff --git a/rust/lithe-db-sidecar/src/main.rs b/rust/lithe-db-sidecar/src/main.rs index cb697072db..91df8a0a37 100644 --- a/rust/lithe-db-sidecar/src/main.rs +++ b/rust/lithe-db-sidecar/src/main.rs @@ -275,6 +275,8 @@ async fn dispatch(request: Request) -> Response { "databaseTypes": ["mysql", "mariadb", "postgresql", "sqlite", "sqlserver", "mongodb", "redis", "nacos"], "features": ["testConnection", "schema", "indexes", "foreignKeys", "objects", "schemaChanges", "pagedFilterSort", "transactionalCrud", "query", "explain", "diagnostics", "csvImportExport", "jsonImportExport", "sqlBackupRestore", "writeProtection", "sqlServerDataGrid", "mongoCollections", "mongoDocumentEditor", "redisScan", "redisStringHashEditor", "redisTTL", "redisFlushDatabase", "nacosConfigManagement", "nacosServiceDiscovery"] })) + } else if request.method == "checkWriteProtection" { + check_write_protection(request.params) } else { database_method(&request.method, request.params).await }; @@ -284,6 +286,17 @@ async fn dispatch(request: Request) -> Response { } } +/// Reports whether `execute` would reject `sql` until the user confirms, without +/// connecting. Clients ask before running a batch so a statement rejected by +/// production protection cannot stop it after earlier statements were applied. +fn check_write_protection(value: Value) -> DbResult { + let params: Params = + serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; + let requires_confirmation = params.connection.production_protection + && is_dangerous_sql(¶ms.connection.kind, ¶ms.sql); + Ok(json!({"requiresConfirmation": requires_confirmation})) +} + async fn database_method(method: &str, value: Value) -> DbResult { let params: Params = serde_json::from_value(value).map_err(|e| ("invalid_params".into(), e.to_string()))?; @@ -2810,7 +2823,7 @@ fn ensure_write_allowed( "This connection is configured as read-only".into(), )); } - if connection.production_protection && is_dangerous_sql(sql) && !confirmed { + if connection.production_protection && is_dangerous_sql(&connection.kind, sql) && !confirmed { return Err(( "confirmation_required".into(), "Production protection requires explicit confirmation".into(), @@ -2858,7 +2871,7 @@ fn ensure_transaction_allowed( if connection.production_protection && statements .iter() - .any(|statement| is_dangerous_sql(&statement.sql)) + .any(|statement| is_dangerous_sql(&connection.kind, &statement.sql)) && !confirmed { return Err(( @@ -2869,13 +2882,199 @@ fn ensure_transaction_allowed( Ok(()) } -fn is_dangerous_sql(sql: &str) -> bool { - let upper = sql.trim_start().to_ascii_uppercase(); - upper == "RESTORE" - || upper.starts_with("DROP ") - || upper.starts_with("TRUNCATE ") - || (upper.starts_with("UPDATE ") && !upper.contains(" WHERE ")) - || (upper.starts_with("DELETE ") && !upper.contains(" WHERE ")) +/// Decides whether production protection must ask before running `sql`. +/// +/// The script is lexed rather than pattern-matched so that line breaks, comments, +/// string literals and quoted identifiers cannot hide or fake a keyword. A +/// statement is dangerous when it restores a backup, drops or truncates +/// anything, or contains an `UPDATE`/`DELETE` whose own clause has no `WHERE` +/// (a `WHERE` inside a subquery does not bound the outer write). Every +/// statement of a multi-statement script is checked. +fn is_dangerous_sql(kind: &str, sql: &str) -> bool { + sql_statements(kind, sql) + .iter() + .any(|statement| is_dangerous_statement(statement)) +} + +/// Structural SQL token. Literals, quoted identifiers and comments produce no +/// token, so their contents never read as keywords. +#[derive(Debug, PartialEq)] +enum SqlToken { + /// Unquoted word, uppercased. + Word(String), + Open, + Close, +} + +/// Splits `sql` into top-level statements of structural tokens. +fn sql_statements(kind: &str, sql: &str) -> Vec> { + // MySQL and MariaDB treat backslash as an escape inside quotes by default; + // the other dialects only escape by doubling the quote character. + let backslash_escapes = matches!(kind, "mysql" | "mariadb"); + let chars: Vec = sql.chars().collect(); + let mut statements = vec![Vec::new()]; + let mut index = 0; + while index < chars.len() { + let character = chars[index]; + let next = chars.get(index + 1).copied(); + let tokens = statements + .last_mut() + .expect("statement list is never empty"); + match character { + '-' if next == Some('-') => { + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + } + '/' if next == Some('*') => { + // PostgreSQL nests block comments; a nested opener in other + // dialects is rare enough that over-skipping is acceptable. + let mut depth = 0; + while index < chars.len() { + if chars[index] == '/' && chars.get(index + 1) == Some(&'*') { + depth += 1; + index += 2; + } else if chars[index] == '*' && chars.get(index + 1) == Some(&'/') { + depth -= 1; + index += 2; + if depth == 0 { + break; + } + } else { + index += 1; + } + } + } + '\'' => { + // PostgreSQL E'...' strings accept backslash escapes. + let escape_string = tokens.last() == Some(&SqlToken::Word("E".into())) + && index > 0 + && chars[index - 1].eq_ignore_ascii_case(&'e'); + if escape_string { + tokens.pop(); + } + index = skip_quoted(&chars, index, '\'', backslash_escapes || escape_string); + } + '"' => index = skip_quoted(&chars, index, '"', backslash_escapes), + '`' => index = skip_quoted(&chars, index, '`', false), + '[' if kind == "sqlserver" => index = skip_quoted(&chars, index, ']', false), + '$' => match dollar_quote_tag(&chars, index) { + Some(tag) => { + let start = index + tag.len(); + index = (start..=chars.len().saturating_sub(tag.len())) + .find(|&end| chars[end..end + tag.len()] == tag[..]) + .map_or(chars.len(), |end| end + tag.len()); + } + None => index += 1, + }, + '(' => { + tokens.push(SqlToken::Open); + index += 1; + } + ')' => { + tokens.push(SqlToken::Close); + index += 1; + } + ';' => { + statements.push(Vec::new()); + index += 1; + } + _ if character.is_alphanumeric() || character == '_' => { + let start = index; + while index < chars.len() + && (chars[index].is_alphanumeric() || matches!(chars[index], '_' | '$')) + { + index += 1; + } + let word: String = chars[start..index].iter().collect(); + tokens.push(SqlToken::Word(word.to_uppercase())); + } + _ => index += 1, + } + } + statements.retain(|tokens| !tokens.is_empty()); + statements +} + +/// Returns the index just past the closing `close` of a quoted run that opens +/// at `start`. A doubled closing character is an escaped literal character. +fn skip_quoted(chars: &[char], start: usize, close: char, backslash_escapes: bool) -> usize { + let mut index = start + 1; + while index < chars.len() { + if backslash_escapes && chars[index] == '\\' { + index += 2; + } else if chars[index] == close { + if chars.get(index + 1) == Some(&close) { + index += 2; + } else { + return index + 1; + } + } else { + index += 1; + } + } + chars.len() +} + +/// Returns the `$tag$` opener at `start`, if any. `$1` style placeholders are +/// not dollar quotes because a tag cannot start with a digit. +fn dollar_quote_tag(chars: &[char], start: usize) -> Option> { + let mut index = start + 1; + if chars + .get(index) + .is_some_and(|character| character.is_ascii_digit()) + { + return None; + } + while chars + .get(index) + .is_some_and(|character| character.is_alphanumeric() || *character == '_') + { + index += 1; + } + (chars.get(index) == Some(&'$')).then(|| chars[start..=index].to_vec()) +} + +fn is_dangerous_statement(tokens: &[SqlToken]) -> bool { + let word = |index: usize| match tokens.get(index) { + Some(SqlToken::Word(word)) => Some(word.as_str()), + _ => None, + }; + if word(0) == Some("RESTORE") { + return true; + } + let mut depths = Vec::with_capacity(tokens.len()); + let mut depth = 0usize; + for token in tokens { + if *token == SqlToken::Close { + depth = depth.saturating_sub(1); + } + depths.push(depth); + if *token == SqlToken::Open { + depth += 1; + } + } + (0..tokens.len()).any(|index| match word(index) { + // TRUNCATE(x, d) is MySQL's numeric function, not the statement. + Some("DROP" | "TRUNCATE") => tokens.get(index + 1) != Some(&SqlToken::Open), + // Only a verb opens a write: at statement start, as a subquery or CTE + // body after `(`, or as the main statement after a `WITH ... )` list. + // This excludes FOR UPDATE, ON DELETE CASCADE, GRANT UPDATE, trigger + // events and MERGE/upsert actions, which are bounded elsewhere. + Some("UPDATE" | "DELETE") + if index == 0 || matches!(tokens[index - 1], SqlToken::Open | SqlToken::Close) => + { + let clause_depth = depths[index]; + !tokens[index + 1..] + .iter() + .zip(&depths[index + 1..]) + .take_while(|(_, depth)| **depth >= clause_depth) + .any(|(token, depth)| { + *depth == clause_depth && *token == SqlToken::Word("WHERE".into()) + }) + } + _ => false, + }) } async fn apply_changes( @@ -4198,14 +4397,96 @@ mod tests { #[test] fn write_protection_classifies_dangerous_sql() { - assert!(is_dangerous_sql("RESTORE")); - assert!(is_dangerous_sql("DROP TABLE users")); - assert!(is_dangerous_sql("UPDATE users SET active = 0")); + for sql in [ + "RESTORE", + "DROP TABLE users", + "drop table users", + "TRUNCATE users", + "ALTER TABLE users DROP COLUMN email", + "UPDATE users SET active = 0", + "DELETE FROM users", + "DELETE\nFROM users", + // Comments must not hide the verb from the check. + "-- cleanup\nDELETE FROM users", + "/* cleanup */ DELETE FROM users", + // A WHERE in a comment, literal or subquery does not bound the write. + "DELETE FROM users -- WHERE id = 1", + "UPDATE users SET note = 'WHERE id = 1'", + "UPDATE users SET rank = (SELECT max(rank) FROM ranks WHERE ranks.id = 1)", + "DELETE FROM \"where\"", + "WITH stale AS (SELECT id FROM users WHERE active = 0) DELETE FROM users", + "WITH gone AS (DELETE FROM users RETURNING id) SELECT count(*) FROM gone", + "SELECT 1; DELETE FROM users", + "UPDATE users SET active = 0 WHERE id = 1; DELETE FROM users", + ] { + assert!(is_dangerous_sql("postgresql", sql), "{sql}"); + } + for sql in [ + "SELECT * FROM users", + "INSERT INTO users (name) VALUES ('drop table')", + // Regression: a WHERE on its own line or after a tab used to be missed. + "UPDATE users SET active = 0 WHERE id = 1", + "UPDATE users\nSET active = 0\nWHERE id = 1", + "DELETE FROM users\tWHERE id = 1", + "DELETE FROM users\nwhere id = 1;", + "WITH stale AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM stale)", + "SELECT * FROM users FOR UPDATE", + "CREATE TABLE posts (author_id INT REFERENCES users(id) ON DELETE CASCADE)", + "INSERT INTO users (id) VALUES (1) ON DUPLICATE KEY UPDATE id = id", + "GRANT UPDATE, DELETE ON users TO app", + "SELECT TRUNCATE(1.25, 1)", + "SELECT $$DELETE FROM users$$", + "SELECT $body$ DROP TABLE users $body$", + "SELECT * FROM users WHERE id = $1", + "/* outer /* nested */ DROP TABLE users */ SELECT 1", + "", + "-- only a comment", + ] { + assert!(!is_dangerous_sql("postgresql", sql), "{sql}"); + } + } + + #[test] + fn write_protection_check_matches_execute_guard_without_connecting() { + let request = |production_protection: bool, sql: &str| { + json!({ + "connection": {"kind": "postgresql", "productionProtection": production_protection}, + "sql": sql, + }) + }; + let requires = |value: Value| { + check_write_protection(value).unwrap()["requiresConfirmation"] + .as_bool() + .unwrap() + }; + assert!(requires(request(true, "UPDATE users SET active = 0"))); + assert!(!requires(request( + true, + "UPDATE users SET active = 0\nWHERE id = 1" + ))); + assert!(!requires(request(false, "DROP TABLE users"))); + } + + #[test] + fn write_protection_lexes_quotes_per_dialect() { + // MySQL backslash-escapes quotes, so the WHERE stays inside the literal. + let sql = r"UPDATE users SET note = 'it\' WHERE 1 = 1 --'"; + assert!(is_dangerous_sql("mysql", sql)); + assert!(is_dangerous_sql("mariadb", sql)); + // Standard strings end at the first quote; E'' strings escape like MySQL. assert!(!is_dangerous_sql( - "UPDATE users SET active = 0 WHERE id = 1" + "postgresql", + r"UPDATE users SET path = 'C:\' WHERE id = 1" + )); + assert!(is_dangerous_sql( + "postgresql", + r"UPDATE users SET note = E'it\' WHERE 1 = 1 --'" )); + // SQL Server bracket identifiers can contain keywords. + assert!(is_dangerous_sql("sqlserver", "DELETE FROM [where]")); assert!(!is_dangerous_sql( - "INSERT INTO users (name) VALUES ('drop table')" + "sqlserver", + "DELETE FROM [users] WHERE [id] = 1" )); } diff --git a/shared/platform-feature-matrix/features/database-query-history.json b/shared/platform-feature-matrix/features/database-query-history.json index f029df9d86..70f7d7a25f 100644 --- a/shared/platform-feature-matrix/features/database-query-history.json +++ b/shared/platform-feature-matrix/features/database-query-history.json @@ -5,7 +5,10 @@ "capability": "SQL 查询、历史与分页", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Database" + "macos/Sources/Lithe/Views/Database", + "macos/Sources/LitheDatabaseModule/Application/DatabaseFeatureModel.swift", + "rust/lithe-db-sidecar/src/main.rs", + "macos/Tests/LitheTests/LitheCoreLogicTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -18,5 +21,5 @@ "verificationStatus": "pending" }, "owner": "Database", - "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。" + "verification": "执行查询、取消查询、查看 SQL 历史和分页结果,确认长结果不阻塞界面。 macOS 生产保护连接:带 WHERE 的 UPDATE/DELETE(WHERE 换行或缩进)直接执行不弹窗;无 WHERE、WHERE 只在子查询/注释/字符串里、WITH … DELETE、DROP/TRUNCATE(含 ALTER … DROP)在第一条语句执行前弹出确认框,确认后整批执行一次;SELECT … FOR UPDATE 不弹窗。危险判定以数据库 helper(lithe-db-sidecar)为准,执行 cargo test -p lithe-db-sidecar write_protection 与 LitheCoreLogicTests/databaseSQLConfirmation。" }