Skip to content

Commit ff220a7

Browse files
authored
fix(ios): six collateral defects found while fixing #2543 and #2544 (#2549)
* fix(ios): treat a write hidden behind a comment or a CTE as a write in Safe Mode * fix(ios): register the tablepro URL scheme so deep links reach the app * fix(ios): follow the server's schema instead of assuming public on PostgreSQL * fix(plugin-mssql): report computed columns and never write one * fix(ios): keep NULL, numbers and identifiers intact when exporting a row * fix(ios): name the active schema in every data statement the browser runs
1 parent 11ae3b3 commit ff220a7

21 files changed

Lines changed: 884 additions & 77 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8787
- Snowflake reporting no rows changed for an `UPDATE` or `MERGE`, and a `SELECT`'s own result for a column named `number of rows`.
8888
- Two saved Snowflake connections to one account sharing a session, so switching database in one window moved the other.
8989
- Stop on a Snowflake query cancelling every other query on the same connection, including a sidebar refresh or a save.
90+
- `tablepro://` deep links ignored on iPhone and iPad, from the widget, a Live Activity and the Open Connection shortcut.
91+
- iOS PostgreSQL and Redshift reading another schema's table of the same name in the columns, indexes and foreign keys it showed.
92+
- iOS SQL Server reading and writing the login's default schema rather than the one the toolbar showed, Truncate Table and Drop Table included.
93+
- Computed SQL Server columns offered as editable and written into the `INSERT`, on both Mac and iOS.
94+
- A CSV export writing a real NULL and the text `NULL` identically.
95+
- A JSON export emitting a leading-zero string such as `01234` as an unquoted number, which no JSON parser accepts.
96+
- A SQL `INSERT` export quoting identifiers for ANSI on every engine, which MySQL and MariaDB reject outright.
9097
- Insert Row on iPhone and iPad writing every column, so a `NOT NULL` column with a default could not be inserted. (#2543)
9198
- NULL offered on a `NOT NULL` column in Insert Row and the row editor on iPhone and iPad.
9299
- Insert Row on iPhone and iPad writing a generated column, which every engine refuses.
@@ -99,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99106
### Security
100107

101108
- SQL injection through a Snowflake schema or routine name containing a backslash.
109+
- Safe Mode on iPhone and iPad no longer lets a write run when it follows a comment or a CTE; an unrecognized statement is treated as a write.
102110

103111
## [0.68.1] - 2026-08-26
104112

Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ public enum MSSQLSchemaQueries {
9292
c.IS_NULLABLE,
9393
c.COLUMN_DEFAULT,
9494
COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') AS IS_IDENTITY,
95-
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK
95+
CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK,
96+
COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsComputed') AS IS_COMPUTED
9697
FROM INFORMATION_SCHEMA.COLUMNS c
9798
LEFT JOIN (
9899
SELECT kcu.COLUMN_NAME
@@ -171,6 +172,7 @@ public struct MSSQLColumnRow: Sendable, Equatable {
171172
public let defaultValue: String?
172173
public let isIdentity: Bool
173174
public let isPrimaryKey: Bool
175+
public let isComputed: Bool
174176

175177
public init(
176178
name: String,
@@ -181,7 +183,8 @@ public struct MSSQLColumnRow: Sendable, Equatable {
181183
isNullable: Bool,
182184
defaultValue: String?,
183185
isIdentity: Bool,
184-
isPrimaryKey: Bool
186+
isPrimaryKey: Bool,
187+
isComputed: Bool = false
185188
) {
186189
self.name = name
187190
self.dataType = dataType
@@ -192,6 +195,7 @@ public struct MSSQLColumnRow: Sendable, Equatable {
192195
self.defaultValue = defaultValue
193196
self.isIdentity = isIdentity
194197
self.isPrimaryKey = isPrimaryKey
198+
self.isComputed = isComputed
195199
}
196200

197201
public var displayType: String {
@@ -270,7 +274,8 @@ public extension MSSQLSchemaQueries {
270274
isNullable: (row[safe: 5] ?? nil) == "YES",
271275
defaultValue: row[safe: 6] ?? nil,
272276
isIdentity: (row[safe: 7] ?? nil) == "1",
273-
isPrimaryKey: (row[safe: 8] ?? nil) == "1"
277+
isPrimaryKey: (row[safe: 8] ?? nil) == "1",
278+
isComputed: (row[safe: 9] ?? nil) == "1"
274279
)
275280
}
276281

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import Foundation
2+
import TableProModels
3+
4+
/// Decides whether a statement batch writes, so Safe Mode can block or confirm it.
5+
///
6+
/// The rule is fail-closed: a statement counts as a read only when its leading keyword is one of a
7+
/// short, closed set of read verbs. Everything else writes, including a keyword this classifier has
8+
/// never heard of. A write-keyword allowlist cannot be safe, because anything it has not been
9+
/// taught, or anything hidden behind a leading comment, runs unguarded.
10+
public enum SQLWriteClassifier {
11+
/// `EXPLAIN` and `PRAGMA` are deliberately absent: `EXPLAIN ANALYZE DELETE …` runs the delete on
12+
/// PostgreSQL, and `PRAGMA journal_mode = WAL` writes on SQLite and DuckDB.
13+
private static let readKeywords: Set<String> = ["SHOW", "DESCRIBE", "DESC"]
14+
15+
/// A `SELECT` reads unless it materialises a table, which `SELECT … INTO` does on SQL Server and
16+
/// `SELECT … INTO OUTFILE` does on MySQL. Mirrors QueryClassifier.swift:288-291.
17+
private static let readUnlessIntoKeywords: Set<String> = ["SELECT", "TABLE", "VALUES"]
18+
19+
/// Statement verbs only. `REPLACE`, `INTO` and `COPY` are left out because they collide with
20+
/// ordinary function and column names, and a CTE that merely calls `replace()` is a read.
21+
/// Matches QueryClassifier.swift:304-312.
22+
private static let writeKeywordsInsideCTE: [String] = [
23+
"INSERT", "UPDATE", "DELETE", "MERGE", "UPSERT", "DROP", "TRUNCATE", "ALTER", "CREATE", "INTO"
24+
]
25+
26+
public static func isWriteQuery(_ sql: String, databaseType: DatabaseType) -> Bool {
27+
if databaseType == .redis { return redisWrites(sql) }
28+
let statements = splitStatements(sql)
29+
guard !statements.isEmpty else { return false }
30+
return statements.contains(where: statementWrites)
31+
}
32+
33+
private static func statementWrites(_ statement: String) -> Bool {
34+
let body = strippingLeadingTrivia(statement)
35+
// Content this cannot name is content it cannot vouch for.
36+
guard let keyword = leadingKeyword(of: body) else { return true }
37+
if keyword == "WITH" { return commonTableExpressionWrites(body) }
38+
if readUnlessIntoKeywords.contains(keyword) {
39+
return containsWord("INTO", in: maskingLiteralsAndComments(body).uppercased())
40+
}
41+
return !readKeywords.contains(keyword)
42+
}
43+
44+
/// Redis speaks commands, not SQL, so the SQL path would call every `GET` a write. The read set
45+
/// is the one QueryClassifier.swift:436-451 already curates for the Mac.
46+
private static func redisWrites(_ command: String) -> Bool {
47+
let verb = strippingLeadingTrivia(command)
48+
.prefix { !$0.isWhitespace }
49+
.uppercased()
50+
guard !verb.isEmpty else { return false }
51+
if verb == "CONFIG" {
52+
let rest = strippingLeadingTrivia(command)
53+
.dropFirst(verb.count)
54+
.trimmingCharacters(in: .whitespaces)
55+
.uppercased()
56+
return !rest.hasPrefix("GET")
57+
}
58+
return !redisReadCommands.contains(verb)
59+
}
60+
61+
private static let redisReadCommands: Set<String> = [
62+
"GET", "MGET", "STRLEN", "GETRANGE", "SUBSTR", "EXISTS", "TYPE", "TTL", "PTTL",
63+
"EXPIRETIME", "PEXPIRETIME", "KEYS", "SCAN", "RANDOMKEY", "DBSIZE", "DUMP",
64+
"HGET", "HMGET", "HGETALL", "HKEYS", "HVALS", "HLEN", "HEXISTS", "HRANDFIELD",
65+
"HSCAN", "HSTRLEN", "LRANGE", "LLEN", "LINDEX", "LPOS",
66+
"SMEMBERS", "SISMEMBER", "SMISMEMBER", "SCARD", "SRANDMEMBER", "SSCAN",
67+
"SDIFF", "SINTER", "SUNION", "SINTERCARD",
68+
"ZRANGE", "ZRANGEBYSCORE", "ZRANGEBYLEX", "ZREVRANGE", "ZREVRANGEBYSCORE",
69+
"ZREVRANGEBYLEX", "ZRANK", "ZREVRANK", "ZSCORE", "ZMSCORE", "ZCARD", "ZCOUNT",
70+
"ZLEXCOUNT", "ZSCAN", "ZRANDMEMBER", "ZDIFF", "ZINTER", "ZUNION", "ZINTERCARD",
71+
"XRANGE", "XREVRANGE", "XLEN", "XREAD", "XINFO", "XPENDING", "XAUTOCLAIM",
72+
"PFCOUNT", "BITCOUNT", "BITPOS", "GETBIT", "BITFIELD_RO",
73+
"GEOPOS", "GEODIST", "GEOHASH", "GEOSEARCH", "GEORADIUS_RO", "GEORADIUSBYMEMBER_RO",
74+
"SORT_RO", "OBJECT", "COMMAND", "INFO", "TIME", "LASTSAVE", "PING", "ECHO", "LOLWUT",
75+
"JSON.GET", "JSON.MGET", "JSON.TYPE", "JSON.OBJKEYS", "JSON.ARRLEN", "JSON.STRLEN",
76+
"TS.RANGE", "TS.REVRANGE", "TS.GET", "TS.MGET", "TS.INFO", "FT.SEARCH", "FT.INFO"
77+
]
78+
79+
/// A CTE's leading keyword says nothing about what the statement finally does, so the body is
80+
/// searched for a write verb with its literals and comments blanked out first.
81+
private static func commonTableExpressionWrites(_ statement: String) -> Bool {
82+
let masked = maskingLiteralsAndComments(statement).uppercased()
83+
return writeKeywordsInsideCTE.contains { keyword in
84+
containsWord(keyword, in: masked)
85+
}
86+
}
87+
88+
private static func containsWord(_ word: String, in haystack: String) -> Bool {
89+
let characters = Array(haystack)
90+
let needle = Array(word)
91+
guard characters.count >= needle.count else { return false }
92+
for start in 0...(characters.count - needle.count) {
93+
guard Array(characters[start ..< start + needle.count]) == needle else { continue }
94+
let before = start > 0 ? characters[start - 1] : " "
95+
let afterIndex = start + needle.count
96+
let after = afterIndex < characters.count ? characters[afterIndex] : " "
97+
if !isIdentifierCharacter(before) && !isIdentifierCharacter(after) { return true }
98+
}
99+
return false
100+
}
101+
102+
private static func isIdentifierCharacter(_ character: Character) -> Bool {
103+
character.isLetter || character.isNumber || character == "_" || character == "$"
104+
}
105+
106+
private static func leadingKeyword(of statement: String) -> String? {
107+
var keyword = ""
108+
for character in statement {
109+
if isIdentifierCharacter(character) {
110+
keyword.append(character)
111+
} else {
112+
break
113+
}
114+
}
115+
return keyword.isEmpty ? nil : keyword.uppercased()
116+
}
117+
118+
private static func strippingLeadingTrivia(_ statement: String) -> String {
119+
var rest = Substring(statement)
120+
while true {
121+
let beforeTrim = rest
122+
rest = rest.drop(while: { $0.isWhitespace })
123+
if rest.hasPrefix("--") {
124+
rest = rest.drop(while: { !$0.isNewline })
125+
} else if rest.hasPrefix("/*") {
126+
rest = rest.dropFirst(2)
127+
while !rest.isEmpty, !rest.hasPrefix("*/") { rest = rest.dropFirst() }
128+
rest = rest.hasPrefix("*/") ? rest.dropFirst(2) : rest
129+
}
130+
if rest == beforeTrim { break }
131+
}
132+
return String(rest)
133+
}
134+
135+
/// Splits on semicolons that are not inside a string, an identifier quote, or a comment.
136+
private static func splitStatements(_ sql: String) -> [String] {
137+
let characters = Array(sql)
138+
let quoted = quotedOrCommentMask(characters)
139+
var statements: [String] = []
140+
var current = ""
141+
142+
for (index, character) in characters.enumerated() {
143+
if character == ";", !quoted[index] {
144+
appendIfMeaningful(current, to: &statements)
145+
current = ""
146+
continue
147+
}
148+
current.append(character)
149+
}
150+
appendIfMeaningful(current, to: &statements)
151+
return statements
152+
}
153+
154+
private static func appendIfMeaningful(_ statement: String, to statements: inout [String]) {
155+
let body = strippingLeadingTrivia(statement).trimmingCharacters(in: .whitespacesAndNewlines)
156+
guard !body.isEmpty else { return }
157+
statements.append(statement)
158+
}
159+
160+
private static func maskingLiteralsAndComments(_ sql: String) -> String {
161+
let characters = Array(sql)
162+
let quoted = quotedOrCommentMask(characters)
163+
return String(characters.enumerated().map { quoted[$0.offset] ? " " : $0.element })
164+
}
165+
166+
/// One pass marking every position that sits inside a string literal, a quoted identifier, or a
167+
/// comment. Doubled and backslash-escaped quotes do not end a literal. Splitting and blanking
168+
/// both read this rather than re-deriving the state, so neither can drift from the other.
169+
private static func quotedOrCommentMask(_ characters: [Character]) -> [Bool] {
170+
var mask = [Bool](repeating: false, count: characters.count)
171+
var index = 0
172+
var quote: Character?
173+
174+
while index < characters.count {
175+
let character = characters[index]
176+
let following = index + 1 < characters.count ? characters[index + 1] : nil
177+
178+
if let open = quote {
179+
mask[index] = true
180+
// A backslash is not an escape under PostgreSQL's standard_conforming_strings, which
181+
// PostgreSQLDriver sets on. Treating it as one would swallow the terminating quote
182+
// and hide the rest of the batch, so it is left alone: ending a literal early splits
183+
// more statements, and more statements can only classify toward write.
184+
if character == open {
185+
if following == open {
186+
mask[index + 1] = true
187+
index += 2
188+
continue
189+
}
190+
quote = nil
191+
}
192+
index += 1
193+
continue
194+
}
195+
196+
if character == "-", following == "-" {
197+
while index < characters.count, !characters[index].isNewline {
198+
mask[index] = true
199+
index += 1
200+
}
201+
continue
202+
}
203+
204+
if character == "/", following == "*" {
205+
mask[index] = true
206+
mask[index + 1] = true
207+
index += 2
208+
while index < characters.count {
209+
if characters[index] == "*", index + 1 < characters.count, characters[index + 1] == "/" {
210+
mask[index] = true
211+
mask[index + 1] = true
212+
index += 2
213+
break
214+
}
215+
mask[index] = true
216+
index += 1
217+
}
218+
continue
219+
}
220+
221+
if character == "'" || character == "\"" || character == "`" {
222+
quote = character
223+
mask[index] = true
224+
index += 1
225+
continue
226+
}
227+
228+
index += 1
229+
}
230+
return mask
231+
}
232+
}

0 commit comments

Comments
 (0)