Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions macos/Sources/Lithe/Views/Database/DatabaseSQLWorkspaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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")

Expand Down Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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]

Expand Down
55 changes: 55 additions & 0 deletions macos/Tests/LitheTests/LitheCoreLogicTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading