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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SQL Server connections set to Required (skip verify) not encrypted past the login.
- Inline suggestions sending the query and table columns to the AI provider on Ask Each Time and Never connections.
- Stored MongoDB values and collection names that ran as shell code when a row was edited, duplicated or restored.
- Server-provided collection, database and index names that could break out of comments and strings in an MQL export. (#3132)

## [0.75.0] - 2026-09-18

Expand Down
11 changes: 11 additions & 0 deletions Packages/TableProCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ let package = Package(
.library(name: "TableProWeaviateCore", targets: ["TableProWeaviateCore"]),
.library(name: "TableProNumberFormatting", targets: ["TableProNumberFormatting"]),
.library(name: "TableProDocumentPath", targets: ["TableProDocumentPath"]),
.library(name: "TableProJavaScriptText", targets: ["TableProJavaScriptText"]),
.library(name: "TableProLogRedaction", targets: ["TableProLogRedaction"]),
.library(name: "TableProR2SQLCore", targets: ["TableProR2SQLCore"]),
.library(name: "TableProConnectionLibrary", targets: ["TableProConnectionLibrary"]),
Expand All @@ -48,6 +49,11 @@ let package = Package(
dependencies: [],
path: "Sources/TableProDocumentPath"
),
.target(
name: "TableProJavaScriptText",
dependencies: [],
path: "Sources/TableProJavaScriptText"
),
.target(
name: "TableProLogRedaction",
dependencies: [],
Expand Down Expand Up @@ -213,6 +219,11 @@ let package = Package(
dependencies: ["TableProDocumentPath"],
path: "Tests/TableProDocumentPathTests"
),
.testTarget(
name: "TableProJavaScriptTextTests",
dependencies: ["TableProJavaScriptText"],
path: "Tests/TableProJavaScriptTextTests"
),
.testTarget(
name: "TableProLogRedactionTests",
dependencies: ["TableProLogRedaction"],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
//
// JavaScriptText.swift
// TableProJavaScriptText
//

import Foundation

/// Shell text written around a name the server chose, each piece escaped for where it stands.
///
/// JSON only requires the C0 controls to be escaped inside a string. JavaScript also ends a line
/// at U+2028 and U+2029, and the editor's statement scanner ends one wherever
/// `Character.isNewline` does, which adds U+0085, VT and FF. Written raw, any of them ended the
/// comment or the string around a name early, and the rest of the name was read as script.
public enum JavaScriptText {
private static let quote = UInt8(ascii: "\"")
private static let backslash = UInt8(ascii: "\\")

/// The escape for a character that ends a line or cannot be seen, or nil for any other.
public static func lineBreakingEscape(_ scalar: Unicode.Scalar) -> String? {
switch scalar {
case "\n": return "\\n"
case "\r": return "\\r"
case "\t": return "\\t"
default:
let value = scalar.value
guard value < 0x20 || (0x7F ... 0x9F).contains(value) || value == 0x2028 || value == 0x2029 else {
return nil
}
return String(format: "\\u%04x", value)
}
}

/// A double-quoted string literal on one line, which JSON and JavaScript both read back as `value`.
public static func stringLiteral(_ value: String) -> String {
var output: [UInt8] = [quote]
output.reserveCapacity(value.utf8.count + 2)
let wasContiguous = value.utf8.withContiguousStorageIfAvailable { appendEscaped($0, to: &output) } != nil
if !wasContiguous {
Array(value.utf8).withUnsafeBufferPointer { appendEscaped($0, to: &output) }
}
output.append(quote)
return String(decoding: output, as: UTF8.self) // swiftlint:disable:this optional_data_string_conversion
}

/// A `//` comment that ends where its own line does.
public static func lineComment(_ text: String) -> String {
var line = "//"
guard !text.isEmpty else { return line }
line.append(" ")
for scalar in text.unicodeScalars {
if let escape = lineBreakingEscape(scalar) {
line.append(escape)
} else {
line.unicodeScalars.append(scalar)
}
}
return line
}

/// Whether `name` can follow a `.` as it is: ASCII letters, digits and `_`, not led by a digit.
///
/// Checked byte by byte, because a `Character` is a whole grapheme cluster, and a Unicode
/// Prepend letter joined to a `(` or `;` answers `isLetter` for the pair.
public static func isPlainIdentifier(_ name: String) -> Bool {
guard let first = name.utf8.first, !isASCIIDigit(first) else { return false }
return name.utf8.allSatisfy { isASCIILetter($0) || isASCIIDigit($0) || $0 == UInt8(ascii: "_") }
}

private static func isASCIILetter(_ byte: UInt8) -> Bool {
(UInt8(ascii: "a") ... UInt8(ascii: "z")).contains(byte) || (UInt8(ascii: "A") ... UInt8(ascii: "Z")).contains(byte)
}

private static func isASCIIDigit(_ byte: UInt8) -> Bool {
(UInt8(ascii: "0") ... UInt8(ascii: "9")).contains(byte)
}

/// Runs on the UTF-8 bytes, because an export calls it once per value and a loop over
/// `unicodeScalars` measured twice as slow. Every other byte is copied as it is.
private static func appendEscaped(_ bytes: UnsafeBufferPointer<UInt8>, to output: inout [UInt8]) {
var index = 0
while index < bytes.count {
let byte = bytes[index]
if byte == quote || byte == backslash {
output.append(backslash)
output.append(byte)
index += 1
} else if let sequence = escapableSequence(in: bytes, at: index),
let escape = lineBreakingEscape(sequence.scalar) {
output.append(contentsOf: escape.utf8)
index += sequence.width
} else {
output.append(byte)
index += 1
}
}
}

/// The character at `index` when its lead byte is one that can spell a character
/// `lineBreakingEscape` answers for: a C0 control or DEL, 0xC2 for U+0080 to U+00BF, or 0xE2
/// for U+2000 to U+2FFF. A Swift string is valid UTF-8, so the continuation bytes are there.
private static func escapableSequence(
in bytes: UnsafeBufferPointer<UInt8>,
at index: Int
) -> (scalar: Unicode.Scalar, width: Int)? {
let lead = bytes[index]
switch lead {
case 0x00 ..< 0x20, 0x7F:
return (Unicode.Scalar(lead), 1)
case 0xC2 where index + 1 < bytes.count:
return (Unicode.Scalar(bytes[index + 1]), 2)
case 0xE2 where index + 2 < bytes.count:
let value = 0x2000 | UInt32(bytes[index + 1] & 0x3F) << 6 | UInt32(bytes[index + 2] & 0x3F)
return Unicode.Scalar(value).map { (scalar: $0, width: 3) }
default:
return nil
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//
// JavaScriptTextTests.swift
// TableProJavaScriptTextTests
//

import Foundation
import JavaScriptCore
import Testing

@testable import TableProJavaScriptText

struct JavaScriptTextTests {
private static let lineTerminators: [Unicode.Scalar] = [
"\n", "\r", "\u{0B}", "\u{0C}", "\u{85}", "\u{2028}", "\u{2029}"
]

private static let mixedName = "a\u{2028}b\u{2029}c\u{85}d\u{0B}e\u{0C}f\"g\\h\ni\rj\tk\u{7F}l\u{9F}m\u{00}n😀o"

private static func containsRawLineTerminator(_ text: String) -> Bool {
text.unicodeScalars.contains { lineTerminators.contains($0) }
}

@Test("Each line-breaking character has the escape the MongoDB driver's statements use")
func escapeSpelling() {
let expected: [(Unicode.Scalar, String)] = [
("\n", "\\n"), ("\r", "\\r"), ("\t", "\\t"), ("\u{00}", "\\u0000"), ("\u{0B}", "\\u000b"),
("\u{0C}", "\\u000c"), ("\u{1F}", "\\u001f"), ("\u{7F}", "\\u007f"), ("\u{85}", "\\u0085"),
("\u{9F}", "\\u009f"), ("\u{2028}", "\\u2028"), ("\u{2029}", "\\u2029")
]
for (scalar, escape) in expected {
#expect(JavaScriptText.lineBreakingEscape(scalar) == escape, "U+\(String(scalar.value, radix: 16))")
}
for scalar: Unicode.Scalar in ["a", " ", "\"", "\\", "\u{A0}", "\u{E9}", "\u{2027}", "\u{202A}", "\u{1F600}"] {
#expect(JavaScriptText.lineBreakingEscape(scalar) == nil, "U+\(String(scalar.value, radix: 16))")
}
}

/// The literal is built from UTF-8 bytes for speed, so it has to agree with the scalar rule
/// for every character, not only the ones picked for a test.
@Test("A string literal escapes exactly what the scalar rule escapes, for every BMP character")
func literalAgreesWithScalarRuleEverywhere() {
var mismatches: [UInt32] = []
let scalars = (0 ... 0xFFFF).compactMap(Unicode.Scalar.init) + ["\u{10000}", "\u{1F600}", "\u{10FFFF}"]
for scalar in scalars {
let body: String
switch scalar {
case "\"": body = "\\\""
case "\\": body = "\\\\"
default: body = JavaScriptText.lineBreakingEscape(scalar) ?? String(scalar)
}
if JavaScriptText.stringLiteral("x\(String(scalar))y") != "\"x\(body)y\"" {
mismatches.append(scalar.value)
}
}
#expect(mismatches.isEmpty, "first mismatches: \(mismatches.prefix(5))")
}

@Test("A string literal holds no raw line terminator and reads back as the name through JSON")
func literalRoundTripsThroughJSON() throws {
let literal = JavaScriptText.stringLiteral(Self.mixedName)
#expect(!Self.containsRawLineTerminator(literal))
let decoded = try JSONDecoder().decode(String.self, from: Data(literal.utf8))
#expect(decoded.unicodeScalars.elementsEqual(Self.mixedName.unicodeScalars))
}

@Test("A string literal reads back as the name through JavaScriptCore's JSON.parse")
func literalRoundTripsThroughJavaScriptCore() throws {
let context = try #require(JSContext())
let parse = try #require(context.evaluateScript("JSON.parse"))
for name in [Self.mixedName, "a\u{2028}b", "a\u{2029}b", "a\u{85}b", "plain"] {
let literal = JavaScriptText.stringLiteral(name)
let parsed = parse.call(withArguments: [literal])
#expect(context.exception == nil)
#expect(parsed?.toString().unicodeScalars.elementsEqual(name.unicodeScalars) == true)
}
}

@Test("A bridged string takes the same path as a native one")
func bridgedStringMatchesNative() {
let native = "a\u{2028}b\"c"
let bridged = NSString(string: native) as String
#expect(JavaScriptText.stringLiteral(bridged) == JavaScriptText.stringLiteral(native))
#expect(JavaScriptText.stringLiteral(native) == "\"a\\u2028b\\\"c\"")
}

@Test("A comment stays on one line whatever line terminator the text holds")
func commentHoldsNoLineTerminator() {
for terminator in Self.lineTerminators {
let comment = JavaScriptText.lineComment("Collection: a\(String(terminator))b")
#expect(!Self.containsRawLineTerminator(comment), "U+\(String(terminator.value, radix: 16))")
#expect(comment.hasPrefix("// Collection: a\\"))
}
}

@Test("A comment keeps printable text as it is, quotes and a block-comment end included")
func commentKeepsPrintableText() {
#expect(JavaScriptText.lineComment("a */ \"b\" 'c' \\d tên") == "// a */ \"b\" 'c' \\d tên")
#expect(JavaScriptText.lineComment("") == "//")
}

@Test("Only an ASCII identifier is plain")
func plainIdentifiers() {
for name in ["orders", "order_2", "_x", "A"] {
#expect(JavaScriptText.isPlainIdentifier(name), "\(name)")
}
for name in ["", "2025", "tên", "a b", "a;b", "a.b", "a$b", "a\u{0D4E}(\u{0D4E})", "a\u{2028}b"] {
#expect(!JavaScriptText.isPlainIdentifier(name), "\(name)")
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// PluginExportUtilitiesTests.swift
// TableProPluginKitTests
//

import Foundation
import JavaScriptCore
import Testing

@testable import TableProPluginKit

struct PluginExportUtilitiesTests {
private static let separators = "a\u{85}b\u{2028}c\u{2029}d"

@Test("NEL, the line separator and the paragraph separator are escaped")
func escapesLineSeparators() {
#expect(PluginExportUtilities.escapeJSONString(Self.separators) == "a\\u0085b\\u2028c\\u2029d")
}

@Test("The escapes JSON export already wrote are unchanged")
func existingEscapesUnchanged() {
#expect(PluginExportUtilities.escapeJSONString("q\"b\\n\nr\rt\tb\u{08}f\u{0C}c\u{01}u\u{1F}")
== "q\\\"b\\\\n\\nr\\rt\\tb\\bf\\fc\\u0001u\\u001F")
}

@Test("Characters that share a lead byte with a separator pass through as they are")
func neighboursOfSeparatorsUnchanged() {
let text = "\u{84}\u{86}\u{A0}\u{C0}\u{2027}\u{202A}\u{2030}\u{20AC}tên 東京 😀"
#expect(PluginExportUtilities.escapeJSONString(text) == text)
}

@Test("A bridged string escapes the same way as a native one")
func bridgedStringMatchesNative() {
let bridged = NSString(string: Self.separators) as String
#expect(PluginExportUtilities.escapeJSONString(bridged) == "a\\u0085b\\u2028c\\u2029d")
}

@Test("The escaped text reads back as the original through JSON and JavaScriptCore")
func roundTripsThroughJSONAndJavaScriptCore() throws {
let name = "x\"y\\z\n\u{0B}\u{0C}\(Self.separators)😀"
let literal = "\"\(PluginExportUtilities.escapeJSONString(name))\""
let decoded = try JSONDecoder().decode(String.self, from: Data(literal.utf8))
#expect(decoded.unicodeScalars.elementsEqual(name.unicodeScalars))

let context = try #require(JSContext())
let parsed = context.evaluateScript("JSON.parse")?.call(withArguments: [literal])
#expect(context.exception == nil)
#expect(parsed?.toString().unicodeScalars.elementsEqual(name.unicodeScalars) == true)
}

@Test("A collection reached through getCollection has its separators escaped")
func accessorEscapesSeparators() {
#expect(MongoCollectionAccessor.expression(for: "a\u{2028}b") == "db.getCollection(\"a\\u2028b\")")
#expect(MongoCollectionAccessor.unescape("a\\u2028b") == "a\u{2028}b")
}
}
Loading
Loading