diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift index 93db218345..a3f252e54b 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -87,13 +87,13 @@ public struct FilterSQLGenerator: Sendable { let escapedSecond = escapeValue(filter.secondValue, kind: kind) return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)" case .contains: - let pattern = escapeLikePattern(filter.value) + let pattern = likePatternBody(filter.value) return likeCondition(operand, "'%\(pattern)%'", negated: false, folding: folding) case .startsWith: - let pattern = escapeLikePattern(filter.value) + let pattern = likePatternBody(filter.value) return likeCondition(operand, "'\(pattern)%'", negated: false, folding: folding) case .endsWith: - let pattern = escapeLikePattern(filter.value) + let pattern = likePatternBody(filter.value) return likeCondition(operand, "'%\(pattern)'", negated: false, folding: folding) } } @@ -159,7 +159,7 @@ public struct FilterSQLGenerator: Sendable { _ column: String, _ pattern: String, negated: Bool, folding: PluginSQLCaseFolding ) -> String { let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword - return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscape)" + return "\(folding.foldingLikeOperand(column)) \(keyword) \(folding.foldingLikeOperand(pattern))\(likeEscapeClause)" } private func listCondition( @@ -178,7 +178,7 @@ public struct FilterSQLGenerator: Sendable { return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))" } - private var likeEscape: String { + public var likeEscapeClause: String { switch dialect.likeEscapeStyle { case .explicit: return " ESCAPE '!'" @@ -187,6 +187,17 @@ public struct FilterSQLGenerator: Sendable { } } + // The LIKE layer first, with the escape `likeEscapeClause` names, then the literal rule over the whole pattern. + public func likePatternBody(_ value: String) -> String { + let escape = dialect.likeEscapeStyle == .implicit ? "\\" : "!" + // `[` opens a character class in a SQL Server LIKE pattern. + let metacharacters = [escape, "%", "_"] + (databaseType == .mssql ? ["["] : []) + let pattern = metacharacters.reduce(value) { pattern, metacharacter in + pattern.replacingOccurrences(of: metacharacter, with: escape + metacharacter, options: .literal) + } + return literalBody(pattern) + } + private func quoteIdentifier(_ name: String) -> String { if let identifierQuoter { return identifierQuoter(name) } let open = dialect.identifierQuote @@ -199,32 +210,15 @@ public struct FilterSQLGenerator: Sendable { if FilterColumnKind.isBareNumber(value, kind: kind) { return value } - var escaped = value.replacingOccurrences(of: "\0", with: "") - if dialect.requiresBackslashEscaping { - escaped = escaped.replacingOccurrences(of: "\\", with: "\\\\") - } - escaped = escaped.replacingOccurrences(of: "'", with: "''") - return "'\(escaped)'" + return "'\(literalBody(value))'" } - private func escapeLikePattern(_ value: String) -> String { - var result = value - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\0", with: "") - switch dialect.likeEscapeStyle { - case .explicit: - result = result - .replacingOccurrences(of: "!", with: "!!") - .replacingOccurrences(of: "%", with: "!%") - .replacingOccurrences(of: "_", with: "!_") - case .implicit: - if dialect.requiresBackslashEscaping { - result = result.replacingOccurrences(of: "\\", with: "\\\\") - } - result = result - .replacingOccurrences(of: "%", with: "\\%") - .replacingOccurrences(of: "_", with: "\\_") + // `.literal` compares code units: the default compares grapheme clusters and skips a quote a combining mark follows. + private func literalBody(_ value: String) -> String { + var body = value.replacingOccurrences(of: "\0", with: "", options: .literal) + if dialect.requiresBackslashEscaping { + body = body.replacingOccurrences(of: "\\", with: "\\\\", options: .literal) } - return result + return body.replacingOccurrences(of: "'", with: "''", options: .literal) } } diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 0e64785a6e..1e693abbaa 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -198,6 +198,42 @@ struct FilterSQLGeneratorTests { #expect(clause("code", "a\\' OR 1=1 -- ", dialect: mysql) == "WHERE `code` = 'a\\\\'' OR 1=1 -- '") } + // Snowflake takes an ESCAPE clause for LIKE and still reads `\'` inside a literal as a quote. + @Test("A LIKE pattern doubles a backslash where the engine reads it as an escape, whatever the LIKE style") + func likeDoublesBackslashForTheLiteral() { + let explicitBackslash = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + likeEscapeStyle: .explicit, requiresBackslashEscaping: true + ) + #expect( + clause("code", #"\' OR 1=1 -- "#, op: .contains, dialect: explicitBackslash) + == #"WHERE "code" LIKE '%\\'' OR 1=1 -- %' ESCAPE '!'"# + ) + } + + @Test("A MySQL LIKE keeps a backslash literal through the string and the pattern") + func mysqlLikeBackslashIsLiteral() { + let mysql = makeDialect(quote: "`", backslashEscaping: true) + #expect(clause("code", #"a\b%"#, op: .contains, dialect: mysql) == #"WHERE `code` LIKE '%a\\\\b\\%%'"#) + } + + @Test("A SQL Server LIKE escapes an opening bracket") + func mssqlLikeEscapesBracket() { + let generator = FilterSQLGenerator(dialect: makeDialect(quote: "["), databaseType: .mssql) + let filter = TableFilter(columnName: "code", filterOperator: .contains, value: "[a]") + #expect(generator.generateWhereClause(from: [filter], logicMode: .and) == "WHERE [code] LIKE '%![a]%' ESCAPE '!'") + } + + // A combining mark joins the quote into one grapheme cluster. + @Test("A quote fused with a combining mark is still escaped") + func fusedQuoteIsEscaped() { + #expect(clause("code", "x'\u{0301} OR 1=1 --") == "WHERE \"code\" = 'x''\u{0301} OR 1=1 --'") + #expect( + clause("code", "x'\u{0301})", op: .contains) + == "WHERE \"code\" LIKE '%x''\u{0301})%' ESCAPE '!'" + ) + } + @Test("A backslash stays single where the engine reads it as a character") func backslashStaysSingle() { #expect(clause("path", "C:\\temp") == "WHERE \"path\" = 'C:\\temp'") diff --git a/apple/TablePro/Core/Database/FilterSQLGenerator.swift b/apple/TablePro/Core/Database/FilterSQLGenerator.swift index 09a83f2bf3..e58a611258 100644 --- a/apple/TablePro/Core/Database/FilterSQLGenerator.swift +++ b/apple/TablePro/Core/Database/FilterSQLGenerator.swift @@ -154,7 +154,7 @@ struct FilterSQLGenerator { case .regex: let operand = patternOperand(quotedColumn, columnType: columnType) guard dialect.regexSyntax != .unsupported else { - let pattern = quotedLiteral("%\(escapeSQLQuote(filter.value))%") + let pattern = quotedLiteral("%\(escapeStringValue(filter.value))%") return "\(folding.foldingLikeOperand(operand)) \(folding.likeKeyword) " + folding.foldingLikeOperand(pattern) } @@ -311,9 +311,7 @@ struct FilterSQLGenerator { // MARK: - LIKE Conditions - /// Database-specific ESCAPE clause for LIKE patterns. - /// Implicit style (MySQL/MariaDB): backslash is the default LIKE escape, no clause needed. - /// Explicit style: requires an ESCAPE declaration. + // An implicit-style engine escapes LIKE with a backslash by default and takes no clause. private var likeEscapeClause: String { if dialect.likeEscapeStyle == .implicit { return "" } return " ESCAPE '!'" @@ -353,7 +351,7 @@ struct FilterSQLGenerator { negated: Bool, folding: PluginSQLCaseFolding ) -> String { - let quotedPattern = quotedLiteral(escapeSQLQuote(pattern)) + let quotedPattern = quotedLiteral(escapeStringValue(pattern)) let keyword = negated ? folding.notLikeKeyword : folding.likeKeyword let operand = folding.foldingLikeOperand(column) return "\(operand) \(keyword) \(folding.foldingLikeOperand(quotedPattern))\(likeEscapeClause)" @@ -512,50 +510,27 @@ struct FilterSQLGenerator { return isTrue ? "1" : "0" } - /// Escape only single quotes for SQL string literal context. - /// Used for LIKE patterns where wildcards are already escaped - /// by escapeLikeWildcards for the ESCAPE clause. - private func escapeSQLQuote(_ value: String) -> String { - guard value.contains("'") else { return value } - return value.replacingOccurrences(of: "'", with: "''") + // Snowflake takes `ESCAPE '!'` yet reads `\'` as a quote; BigQuery and Spanner read `\` without declaring it. + private var literalsReadBackslash: Bool { + dialect.requiresBackslashEscaping || dialect.likeEscapeStyle == .implicit } - /// Escape special characters in string values + // `.literal` compares code units: the default compares grapheme clusters and skips a quote a combining mark follows. private func escapeStringValue(_ value: String) -> String { - // Fast path: most values have no special chars - if dialect.likeEscapeStyle == .implicit { - // MySQL/MariaDB/ClickHouse: backslash is significant in string literals - guard value.contains("\\") || value.contains("'") else { return value } - return value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "'", with: "''") - } else { - // ANSI SQL: only single-quote needs escaping - guard value.contains("'") else { return value } - return value.replacingOccurrences(of: "'", with: "''") - } + let body = literalsReadBackslash + ? value.replacingOccurrences(of: "\\", with: "\\\\", options: .literal) + : value + return body.replacingOccurrences(of: "'", with: "''", options: .literal) } + // Only the LIKE layer: escapeStringValue then spells the pattern as a literal. private func escapeLikeWildcards(_ value: String) -> String { - if dialect.likeEscapeStyle == .implicit { - guard value.contains("\\") || value.contains("%") || value.contains("_") else { return value } - // MySQL uses \ as both string escape and default LIKE escape. - // Need double backslash in SQL string so string layer yields single \ - // which LIKE then uses as escape char. - return value - .replacingOccurrences(of: "\\", with: "\\\\\\\\") - .replacingOccurrences(of: "%", with: "\\\\%") - .replacingOccurrences(of: "_", with: "\\\\_") - } + let escape = dialect.likeEscapeStyle == .implicit ? "\\" : "!" // `[` opens a character class in a SQL Server LIKE pattern. - let escapesBracket = family == .mssql - guard value.contains("!") || value.contains("%") || value.contains("_") - || escapesBracket && value.contains("[") else { return value } - let escaped = value - .replacingOccurrences(of: "!", with: "!!") - .replacingOccurrences(of: "%", with: "!%") - .replacingOccurrences(of: "_", with: "!_") - return escapesBracket ? escaped.replacingOccurrences(of: "[", with: "![") : escaped + let metacharacters = [escape, "%", "_"] + (family == .mssql ? ["["] : []) + return metacharacters.reduce(value) { pattern, metacharacter in + pattern.replacingOccurrences(of: metacharacter, with: escape + metacharacter, options: .literal) + } } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index 1111957986..cc0065f44b 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -338,15 +338,14 @@ nonisolated enum SQLBuilder { let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, !columns.isEmpty else { return "" } - let dialect = dialectDescriptor(for: type) - let pattern = escapeLikePattern(trimmed, dialect: dialect) - let likeEscape: String = dialect.likeEscapeStyle == .explicit ? " ESCAPE '!'" : "" + let generator = filterGenerator(dialect: dialectDescriptor(for: type), type: type) + let pattern = generator.likePatternBody(trimmed) let conditions = columns.map { col -> String in let quotedCol = quoteIdentifier(col.name, for: type) let castExpr = searchCastExpression(quotedCol, for: type) let likeOp = (type == .postgresql || type == .redshift) ? "ILIKE" : "LIKE" - return "\(castExpr) \(likeOp) '%\(pattern)%'\(likeEscape)" + return "\(castExpr) \(likeOp) '%\(pattern)%'\(generator.likeEscapeClause)" } return "(\(conditions.joined(separator: " OR ")))" @@ -370,25 +369,6 @@ nonisolated enum SQLBuilder { } } - private static func escapeLikePattern(_ value: String, dialect: SQLDialectDescriptor) -> String { - var result = value - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\0", with: "") - if dialect.likeEscapeStyle == .explicit { - result = result - .replacingOccurrences(of: "!", with: "!!") - .replacingOccurrences(of: "%", with: "!%") - .replacingOccurrences(of: "_", with: "!_") - } else { - if dialect.requiresBackslashEscaping { - result = result.replacingOccurrences(of: "\\", with: "\\\\") - } - result = result.replacingOccurrences(of: "%", with: "\\%") - result = result.replacingOccurrences(of: "_", with: "\\_") - } - return result - } - private static func buildOrderByClause(_ sortState: SortState, for type: DatabaseType) -> String { guard sortState.isSorting else { return "" } let clauses = sortState.columns.map { col in diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift index dab720d898..bf48bbc4c6 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift @@ -70,6 +70,31 @@ struct SQLBuilderFilterLiteralTests { ) == "SELECT COUNT(*) FROM `files` WHERE `path` = 'C:\\\\temp\\\\'") } + private func search(_ type: DatabaseType, _ text: String) -> String { + SQLBuilder.buildSearchCount( + table: "t", schema: nil, type: type, searchText: text, searchColumns: [ColumnInfo(name: "name", typeName: "TEXT")] + ) + } + + @Test("A MySQL search keeps a backslash literal through the string and the pattern") + func mysqlSearchBackslashIsLiteral() { + #expect(search(.mysql, #"a\b"#) == #"SELECT COUNT(*) FROM `t` WHERE (CAST(`name` AS CHAR) LIKE '%a\\\\b%')"#) + } + + @Test("A SQL Server search escapes an opening bracket") + func mssqlSearchEscapesBracket() { + #expect(search(.mssql, "[a]") == "SELECT COUNT(*) FROM [t] WHERE (CAST([name] AS NVARCHAR(MAX)) LIKE '%![a]%' ESCAPE '!')") + } + + // A combining mark joins the quote into one grapheme cluster. + @Test("A search quote fused with a combining mark is still escaped") + func searchFusedQuoteIsEscaped() { + #expect( + search(.postgresql, "x'\u{0301})") + == "SELECT COUNT(*) FROM \"t\" WHERE (CAST(\"name\" AS TEXT) ILIKE '%x''\u{0301})%' ESCAPE '!')" + ) + } + // ClickHouse reads backslash escapes inside backticks, so a name ending in a backslash would leave the quote open. @Test("A ClickHouse filter quotes its column the way the table and the writes do") func clickHouseColumnEndingInABackslash() { diff --git a/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift b/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift index 9126b678f0..34f8a47c9f 100644 --- a/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift +++ b/apple/TableProTests/Core/Database/FilterSQLGeneratorTests.swift @@ -56,6 +56,13 @@ struct FilterSQLGeneratorTests { offsetFetchOrderBy: "" ) + private static let snowflakeDialect = SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + regexSyntax: .regexpLike, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, paginationStyle: .limit, + requiresBackslashEscaping: true + ) + // MARK: - Per-Operator Tests (MySQL) @Test("Equal operator generates correct condition") @@ -1038,4 +1045,64 @@ struct FilterSQLGeneratorTests { let result = generator.generateCondition(from: filter) #expect(result == "`status` IS NULL") } + + // MARK: - Literal Escaping + + private func condition( + _ dialect: SQLDialectDescriptor, _ filterOperator: FilterOperator, _ value: String, column: String = "code" + ) -> String? { + FilterSQLGenerator(dialect: dialect).generateCondition(from: TableFilter( + columnName: column, filterOperator: filterOperator, value: value, isCaseSensitive: true + )) + } + + // Snowflake takes an ESCAPE clause for LIKE and still reads `\'` inside a literal as a quote. + @Test("A Snowflake filter value doubles its backslash, whatever the operator", arguments: [ + (FilterOperator.equal, #""CODE" = '\\''; DROP TABLE AUDIT_LOG; --'"#), + (.inList, #""CODE" IN ('\\''; DROP TABLE AUDIT_LOG; --')"#), + (.contains, #""CODE" LIKE '%\\''; DROP TABLE AUDIT!_LOG; --%' ESCAPE '!'"#), + (.startsWith, #""CODE" LIKE '\\''; DROP TABLE AUDIT!_LOG; --%' ESCAPE '!'"#), + (.regex, #"REGEXP_LIKE("CODE", '\\''; DROP TABLE AUDIT_LOG; --')"#) + ]) + func snowflakeDoublesBackslash(filterOperator: FilterOperator, expected: String) { + #expect(condition(Self.snowflakeDialect, filterOperator, #"\'; DROP TABLE AUDIT_LOG; --"#, column: "CODE") == expected) + } + + @Test("A Snowflake LIKE pattern reads a backslash as a character, not as its escape") + func snowflakeLikeBackslashIsLiteral() { + #expect(condition(Self.snowflakeDialect, .endsWith, #"C:\temp\"#) == #""code" LIKE '%C:\\temp\\' ESCAPE '!'"#) + } + + @Test("The LIKE a regex falls back to doubles a backslash where the engine reads it as an escape") + func regexFallbackDoublesBackslash() { + let implicitWithoutRegex = SQLDialectDescriptor( + identifierQuote: "`", keywords: [], functions: [], dataTypes: [], + regexSyntax: .unsupported, booleanLiteralStyle: .truefalse, + likeEscapeStyle: .implicit, paginationStyle: .limit + ) + #expect(condition(implicitWithoutRegex, .regex, #"\' OR 1=1 -- "#) == #"`code` LIKE '%\\'' OR 1=1 -- %'"#) + #expect(condition(Self.sqliteDialect, .regex, #"\' OR 1=1 -- "#) == #"`code` LIKE '%\'' OR 1=1 -- %'"#) + } + + @Test("A MySQL LIKE keeps a backslash literal through the string and the pattern") + func mysqlLikeBackslash() { + #expect(condition(Self.mysqlDialect, .contains, #"a\b%"#) == #"`code` LIKE '%a\\\\b\\%%'"#) + } + + // A combining mark or a Prepend scalar joins the quote or backslash into one grapheme cluster. + @Test("A quote or backslash fused with its neighbour is still escaped", arguments: [ + ("x'\u{0301} OR 1=1 --", #"`code` = 'x''"# + "\u{0301}" + #" OR 1=1 --'"#), + ("\u{0600}\\' OR 1=1 --", "`code` = '\u{0600}" + #"\\'' OR 1=1 --'"#) + ]) + func fusedQuoteIsEscaped(value: String, expected: String) { + #expect(condition(Self.mysqlDialect, .equal, value) == expected) + } + + @Test("A quote fused with a combining mark is escaped in a LIKE pattern") + func fusedQuoteInLikeIsEscaped() { + #expect( + condition(Self.postgresqlDialect, .contains, "x'\u{0301})") + == "\"code\" LIKE '%x''\u{0301})%' ESCAPE '!'" + ) + } } diff --git a/apple/changelog.d/3906.fixed.md b/apple/changelog.d/3906.fixed.md new file mode 100644 index 0000000000..a8bc15a191 --- /dev/null +++ b/apple/changelog.d/3906.fixed.md @@ -0,0 +1 @@ +On iOS, a search or filter with a backslash on MySQL or ClickHouse, or with `[` on SQL Server, matched the wrong rows. diff --git a/apple/changelog.d/3906.security.md b/apple/changelog.d/3906.security.md new file mode 100644 index 0000000000..5ffab5534c --- /dev/null +++ b/apple/changelog.d/3906.security.md @@ -0,0 +1 @@ +A backslash in a Snowflake filter value could end its string literal and run the rest as SQL.