Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SSH private keys pasted or picked on iPhone and iPad saved in plain text in the connections file.
- Test Connection on iPhone and iPad saving its credentials to the Keychain, synced with Sync Passwords on.
- Oracle and Dameng metadata reads and the Oracle server-side export captured by an object shadowing a `SYS` dictionary name or package in the current schema.
- Statements hidden behind a backslash in a string skipped Safe Mode on PostgreSQL, DuckDB, SQL Server, SQLite and Dameng.
- Statements hidden inside a nested block comment skipped Safe Mode on PostgreSQL, DuckDB and SQL Server.
- Statements hidden behind a bracketed identifier skipped Safe Mode on SQL Server and SQLite.
- Statements hidden in a dollar-quoted string skipped Safe Mode on DuckDB, Snowflake and Cassandra, or PostgreSQL with a non-ASCII tag.
- Statements hidden behind an engine's own literal or comment forms, such as `E'\''`, `'''` or `--1`, skipped Safe Mode.
- Statements hidden the same ways passed the one-statement check on MCP and AI chat queries.
- Writes hidden in a dollar-quoted string, a nested comment or a bracketed identifier skipped Safe Mode on iPhone and iPad.

## [0.75.0] - 2026-09-18

Expand Down
15 changes: 13 additions & 2 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ let package = Package(
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]),
.library(name: "TableProDocumentPath", targets: ["TableProDocumentPath"]),
.library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"]),
.library(name: "TableProConnectionLibrary", targets: ["TableProConnectionLibrary"])
.library(name: "TableProConnectionLibrary", targets: ["TableProConnectionLibrary"]),
.library(name: "TableProSQLGrammar", targets: ["TableProSQLGrammar"])
],
targets: [
.target(
Expand Down Expand Up @@ -73,7 +74,7 @@ let package = Package(
),
.target(
name: "TableProQuery",
dependencies: ["TableProModels", "TableProPluginKit", "TableProCoreTypes"],
dependencies: ["TableProModels", "TableProPluginKit", "TableProCoreTypes", "TableProSQLGrammar"],
path: "Sources/TableProQuery"
),
.target(
Expand Down Expand Up @@ -131,6 +132,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProConnectionLibrary"
),
.target(
name: "TableProSQLGrammar",
dependencies: [],
path: "Sources/TableProSQLGrammar"
),
.testTarget(
name: "TableProConnectionLibraryTests",
dependencies: ["TableProConnectionLibrary"],
Expand Down Expand Up @@ -171,6 +177,11 @@ let package = Package(
dependencies: ["TableProQuery", "TableProModels", "TableProPluginKit"],
path: "Tests/TableProQueryTests"
),
.testTarget(
name: "TableProSQLGrammarTests",
dependencies: ["TableProSQLGrammar"],
path: "Tests/TableProSQLGrammarTests"
),
.testTarget(
name: "TableProAnalyticsTests",
dependencies: ["TableProAnalytics"],
Expand Down
132 changes: 22 additions & 110 deletions Packages/TableProCore/Sources/TableProQuery/SQLWriteClassifier.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import Foundation
import TableProModels
import TableProSQLGrammar

/// Decides whether a statement batch writes, so Safe Mode can block or confirm it.
///
/// The rule is fail-closed: a statement counts as a read only when its leading keyword is one of a
/// short, closed set of read verbs. Everything else writes, including a keyword this classifier has
/// never heard of. A write-keyword allowlist cannot be safe, because anything it has not been
/// taught, or anything hidden behind a leading comment, runs unguarded.
///
/// The batch is split into statements the way its engine lexes it, by the grammar the Mac reads
/// too, and under every reading the engine could be using: iOS drivers send the whole text in one
/// call, so a `DELETE` hidden behind a quote only one reading closes still runs.
public enum SQLWriteClassifier {
/// `EXPLAIN` and `PRAGMA` are deliberately absent: `EXPLAIN ANALYZE DELETE …` runs the delete on
/// PostgreSQL, and `PRAGMA journal_mode = WAL` writes on SQLite and DuckDB.
Expand All @@ -25,18 +30,23 @@ public enum SQLWriteClassifier {

public static func isWriteQuery(_ sql: String, databaseType: DatabaseType) -> Bool {
if databaseType == .redis { return redisWrites(sql) }
let statements = splitStatements(sql)
guard !statements.isEmpty else { return false }
return statements.contains(where: statementWrites)
let readings = SQLLexicalReadings.resolve(databaseTypeId: databaseType.rawValue, declared: nil, session: nil)
return readings.distinct(for: sql).contains { grammar in
SQLStatementScanner.executableStatements(in: sql, grammar: grammar).contains { statement in
statementWrites(statement.sql, grammar: grammar)
}
}
}

private static func statementWrites(_ statement: String) -> Bool {
let body = strippingLeadingTrivia(statement)
// Content this cannot name is content it cannot vouch for.
/// Reads the statement's code with every literal and comment blanked by its engine's own rules. A MySQL
/// `/*! ... */` is kept as the code it is, so a keyword the server runs from one is never mistaken for a read.
private static func statementWrites(_ statement: String, grammar: SQLLexicalGrammar) -> Bool {
let code = SQLCodeProjection.code(of: statement, grammar: grammar, revealingExecutableComments: true)
let body = String(code.drop { $0.isWhitespace })
guard let keyword = leadingKeyword(of: body) else { return true }
if keyword == "WITH" { return commonTableExpressionWrites(body) }
if keyword == "WITH" { return commonTableExpressionWrites(code) }
if readUnlessIntoKeywords.contains(keyword) {
return containsWord("INTO", in: maskingLiteralsAndComments(body).uppercased())
return containsWord("INTO", in: code.uppercased())
}
return !readKeywords.contains(keyword)
}
Expand Down Expand Up @@ -76,10 +86,10 @@ public enum SQLWriteClassifier {
"TS.RANGE", "TS.REVRANGE", "TS.GET", "TS.MGET", "TS.INFO", "FT.SEARCH", "FT.INFO"
]

/// A CTE's leading keyword says nothing about what the statement finally does, so the body is
/// searched for a write verb with its literals and comments blanked out first.
private static func commonTableExpressionWrites(_ statement: String) -> Bool {
let masked = maskingLiteralsAndComments(statement).uppercased()
/// A CTE's leading keyword says nothing about what the statement finally does, so its code is
/// searched for a write verb.
private static func commonTableExpressionWrites(_ code: String) -> Bool {
let masked = code.uppercased()
return writeKeywordsInsideCTE.contains { keyword in
containsWord(keyword, in: masked)
}
Expand Down Expand Up @@ -131,102 +141,4 @@ public enum SQLWriteClassifier {
}
return String(rest)
}

/// Splits on semicolons that are not inside a string, an identifier quote, or a comment.
private static func splitStatements(_ sql: String) -> [String] {
let characters = Array(sql)
let quoted = quotedOrCommentMask(characters)
var statements: [String] = []
var current = ""

for (index, character) in characters.enumerated() {
if character == ";", !quoted[index] {
appendIfMeaningful(current, to: &statements)
current = ""
continue
}
current.append(character)
}
appendIfMeaningful(current, to: &statements)
return statements
}

private static func appendIfMeaningful(_ statement: String, to statements: inout [String]) {
let body = strippingLeadingTrivia(statement).trimmingCharacters(in: .whitespacesAndNewlines)
guard !body.isEmpty else { return }
statements.append(statement)
}

private static func maskingLiteralsAndComments(_ sql: String) -> String {
let characters = Array(sql)
let quoted = quotedOrCommentMask(characters)
return String(characters.enumerated().map { quoted[$0.offset] ? " " : $0.element })
}

/// One pass marking every position that sits inside a string literal, a quoted identifier, or a
/// comment. Doubled and backslash-escaped quotes do not end a literal. Splitting and blanking
/// both read this rather than re-deriving the state, so neither can drift from the other.
private static func quotedOrCommentMask(_ characters: [Character]) -> [Bool] {
var mask = [Bool](repeating: false, count: characters.count)
var index = 0
var quote: Character?

while index < characters.count {
let character = characters[index]
let following = index + 1 < characters.count ? characters[index + 1] : nil

if let open = quote {
mask[index] = true
// A backslash is not an escape under PostgreSQL's standard_conforming_strings, which
// PostgreSQLDriver sets on. Treating it as one would swallow the terminating quote
// and hide the rest of the batch, so it is left alone: ending a literal early splits
// more statements, and more statements can only classify toward write.
if character == open {
if following == open {
mask[index + 1] = true
index += 2
continue
}
quote = nil
}
index += 1
continue
}

if character == "-", following == "-" {
while index < characters.count, !characters[index].isNewline {
mask[index] = true
index += 1
}
continue
}

if character == "/", following == "*" {
mask[index] = true
mask[index + 1] = true
index += 2
while index < characters.count {
if characters[index] == "*", index + 1 < characters.count, characters[index + 1] == "/" {
mask[index] = true
mask[index + 1] = true
index += 2
break
}
mask[index] = true
index += 1
}
continue
}

if character == "'" || character == "\"" || character == "`" {
quote = character
mask[index] = true
index += 1
continue
}

index += 1
}
return mask
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
//
// PLSQLUnitTracker.swift
// TablePro
//

import Foundation

/// Statement boundaries in Oracle's language, where a `;` inside a PL/SQL unit separates the unit's own statements.
Expand All @@ -27,7 +22,7 @@ import Foundation
/// The final `;` of a unit belongs to it, except after a trigger whose body is a `CALL`: measured on Oracle 23ai,
/// `CREATE TRIGGER ... CALL p(:NEW.a);` is stored INVALID and the same text without the `;` compiles.
/// `scripts/check-oracle-plsql-terminators.sh` re-measures both rules against a live server.
struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
public struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
private enum Lead {
case start
case afterCreate
Expand Down Expand Up @@ -76,11 +71,13 @@ struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
private var expectsDeclaration = false
private var inlineQueryStarted = false

var needsWords: Bool {
public init() {}

public var needsWords: Bool {
lead != .decided || tracksConstructs
}

var terminator: SQLStatementTerminator {
public var terminator: SQLStatementTerminator {
switch kind {
case .anonymousBlock, .opaque:
return .partOfStatement
Expand All @@ -91,7 +88,7 @@ struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
}
}

var acceptsBindParameters: Bool {
public var acceptsBindParameters: Bool {
switch kind {
case .storedUnit, .embeddedSourceHeader, .opaque:
return false
Expand All @@ -100,7 +97,7 @@ struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
}
}

mutating func observeWord(_ word: String) {
public mutating func observeWord(_ word: String) {
previousSymbol = nil
guard !inLabel else { return }
let isMember = followsPeriod
Expand Down Expand Up @@ -155,7 +152,7 @@ struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
observeBodyWord(word)
}

mutating func observeSymbol(_ symbol: UInt16) {
public mutating func observeSymbol(_ symbol: UInt16) {
if symbol == Self.lessThan, previousSymbol == Self.lessThan {
inLabel = true
previousSymbol = nil
Expand All @@ -180,14 +177,14 @@ struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
followsPeriod = symbol == Self.period
}

mutating func observeOpaqueToken() {
public mutating func observeOpaqueToken() {
previousSymbol = nil
followsPeriod = false
guard tracksConstructs else { return }
settlePendingBeforeNonWord()
}

mutating func observeSemicolon() -> Bool {
public mutating func observeSemicolon() -> Bool {
previousSymbol = nil
followsPeriod = false
guard lead == .decided else { return true }
Expand All @@ -212,7 +209,7 @@ struct PLSQLUnitTracker: SQLStatementBoundaryTracking {
}
}

mutating func reset() {
public mutating func reset() {
self = PLSQLUnitTracker()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import Foundation

/// SQL text with every comment, literal and quoted identifier blanked, read by one grammar.
///
/// A classifier looks for keywords in what this returns, so a `DROP` inside a string never counts and a `DROP` after
/// a string that ended where the engine ends it always does. Each blanked UTF-16 unit becomes a space and each line
/// feed stays, so an offset into the projection is an offset into the original text.
public enum SQLCodeProjection {
/// - Parameter revealingExecutableComments: whether a MySQL `/*! ... */` body is kept as code. A gate reveals it
/// whatever the grammar says, because the only cost of reading an ignored comment as code is a stricter tier.
public static func code(
of text: String,
grammar: SQLLexicalGrammar,
revealingExecutableComments: Bool = false
) -> String {
let source = text as NSString
let length = source.length
guard length > 0 else { return "" }
var units = [UInt16](repeating: 0, count: length)
source.getCharacters(&units, range: NSRange(location: 0, length: length))

var index = 0
while index < length {
if revealingExecutableComments,
let opener = SqlLexer.executableCommentOpenerLength(source, at: index, length: length) {
index += opener
continue
}
guard let span = SQLNonCodeSpan.span(at: index, in: source, grammar: grammar) else {
index += 1
continue
}
blank(&units, from: span.start, to: span.end)
index = max(span.end, index + 1)
}
return String(utf16CodeUnits: units, count: length)
}

private static func blank(_ units: inout [UInt16], from start: Int, to end: Int) {
for offset in start..<min(end, units.count) where units[offset] != SqlLexer.newline {
units[offset] = SqlLexer.space
}
}
}
Loading
Loading