Initial commit
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct WorkbenchReplacementApp: App {
|
||||
@StateObject private var model = DatabaseModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup("Workbench Replacement") {
|
||||
MainView()
|
||||
.environmentObject(model)
|
||||
.frame(minWidth: 1000, minHeight: 650)
|
||||
}
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var schemaTables: [String: [String]] = [:]
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
|
||||
func formatSQL() {
|
||||
sql = SQLFormatter.uppercaseKeywords(sql)
|
||||
}
|
||||
|
||||
func loadTables(database: String) {
|
||||
guard let profile = selected else { return }
|
||||
|
||||
let password = PasswordStore.read(profile.id)
|
||||
let escaped = database.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
database: "",
|
||||
password: password,
|
||||
sql: "SHOW TABLES FROM `\\(escaped)`"
|
||||
)
|
||||
|
||||
schemaTables[database] = parsed.rows.compactMap { $0.first }
|
||||
} catch {
|
||||
status = "Unable to load tables: \\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectTable(database: String, table: String) {
|
||||
let db = database.replacingOccurrences(of: "`", with: "``")
|
||||
let tbl = table.replacingOccurrences(of: "`", with: "``")
|
||||
|
||||
sql = "SELECT * FROM `\\(db)`.`\\(tbl)` LIMIT 100;"
|
||||
runSQL()
|
||||
}
|
||||
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
|
||||
func runSQL() {
|
||||
formatSQL()
|
||||
execute(sql, listingDatabases: false)
|
||||
}
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - SQL Keyword Formatter
|
||||
|
||||
enum SQLFormatter {
|
||||
|
||||
private static let keywords: Set<String> = [
|
||||
"SELECT", "FROM", "WHERE", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE",
|
||||
"DATABASE", "SCHEMA", "USE", "SHOW", "DESCRIBE", "EXPLAIN",
|
||||
"JOIN", "INNER", "LEFT", "RIGHT", "FULL", "OUTER",
|
||||
"ON", "AS", "AND", "OR", "NOT", "NULL", "IS", "IN",
|
||||
"BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING",
|
||||
"LIMIT", "OFFSET", "ASC", "DESC", "DISTINCT", "ALL",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "INDEX",
|
||||
"UNIQUE", "DEFAULT", "AUTO_INCREMENT", "IF", "EXISTS",
|
||||
"CASCADE", "RESTRICT", "SET", "BEGIN", "COMMIT",
|
||||
"ROLLBACK", "TRANSACTION", "UNION", "CASE", "WHEN",
|
||||
"THEN", "ELSE", "END", "TRUE", "FALSE", "COUNT",
|
||||
"SUM", "AVG", "MIN", "MAX", "HAVING", "WITH",
|
||||
"RECURSIVE", "TRUNCATE", "REPLACE", "VIEW",
|
||||
"PROCEDURE", "FUNCTION", "TRIGGER", "GRANT",
|
||||
"REVOKE", "DISTINCT", "CURRENT_TIMESTAMP"
|
||||
]
|
||||
|
||||
static func uppercaseKeywords(_ sql: String) -> String {
|
||||
let chars = Array(sql)
|
||||
var output = ""
|
||||
var index = 0
|
||||
|
||||
enum State {
|
||||
case normal
|
||||
case singleQuote
|
||||
case doubleQuote
|
||||
case backtick
|
||||
case lineComment
|
||||
case blockComment
|
||||
}
|
||||
|
||||
var state = State.normal
|
||||
|
||||
while index < chars.count {
|
||||
let ch = chars[index]
|
||||
let next: Character? = index + 1 < chars.count
|
||||
? chars[index + 1] : nil
|
||||
|
||||
switch state {
|
||||
case .normal:
|
||||
if ch == "'" {
|
||||
state = .singleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
state = .doubleQuote
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "`" {
|
||||
state = .backtick
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "-" && next == "-" &&
|
||||
(index + 2 == chars.count ||
|
||||
chars[index + 2].isWhitespace) {
|
||||
state = .lineComment
|
||||
output += "--"
|
||||
index += 2
|
||||
} else if ch == "#" {
|
||||
state = .lineComment
|
||||
output.append(ch)
|
||||
index += 1
|
||||
} else if ch == "/" && next == "*" {
|
||||
state = .blockComment
|
||||
output += "/*"
|
||||
index += 2
|
||||
} else if ch.isLetter || ch == "_" {
|
||||
let start = index
|
||||
while index < chars.count &&
|
||||
(chars[index].isLetter ||
|
||||
chars[index].isNumber ||
|
||||
chars[index] == "_" ||
|
||||
chars[index] == "$") {
|
||||
index += 1
|
||||
}
|
||||
|
||||
let word = String(chars[start..<index])
|
||||
let upper = word.uppercased()
|
||||
|
||||
output += keywords.contains(upper) ? upper : word
|
||||
} else {
|
||||
output.append(ch)
|
||||
index += 1
|
||||
}
|
||||
|
||||
case .singleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "'" {
|
||||
if index < chars.count && chars[index] == "'" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .doubleQuote:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\\" && index < chars.count {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else if ch == "\"" {
|
||||
if index < chars.count && chars[index] == "\"" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .backtick:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "`" {
|
||||
if index < chars.count && chars[index] == "`" {
|
||||
output.append(chars[index])
|
||||
index += 1
|
||||
} else {
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
|
||||
case .lineComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "\n" {
|
||||
state = .normal
|
||||
}
|
||||
|
||||
case .blockComment:
|
||||
output.append(ch)
|
||||
index += 1
|
||||
if ch == "*" && index < chars.count &&
|
||||
chars[index] == "/" {
|
||||
output.append("/")
|
||||
index += 1
|
||||
state = .normal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct ConnectionProfile: Identifiable, Codable, Hashable {
|
||||
var id = UUID()
|
||||
var name = "Local MySQL"
|
||||
var host = "127.0.0.1"
|
||||
var port = 3306
|
||||
var username = "root"
|
||||
var database = ""
|
||||
}
|
||||
struct QueryResult {
|
||||
var columns: [String] = []
|
||||
var rows: [[String]] = []
|
||||
var message = ""
|
||||
}
|
||||
|
||||
enum PasswordStore {
|
||||
static func read(_ id: UUID) -> String {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne]
|
||||
var value: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess,
|
||||
let data = value as? Data else { return "" }
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
}
|
||||
static func save(_ password: String, id: UUID) {
|
||||
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: "WorkbenchReplacement.MySQL",
|
||||
kSecAttrAccount as String: id.uuidString]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
guard !password.isEmpty else { return }
|
||||
var attributes = query
|
||||
attributes[kSecValueData as String] = Data(password.utf8)
|
||||
SecItemAdd(attributes as CFDictionary, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DatabaseModel: ObservableObject {
|
||||
@Published var profiles: [ConnectionProfile] = []
|
||||
@Published var selectedID: UUID?
|
||||
@Published var sql = "SELECT VERSION() AS server_version;"
|
||||
@Published var result = QueryResult()
|
||||
@Published var databases: [String] = []
|
||||
@Published var selectedDatabase = ""
|
||||
@Published var isRunning = false
|
||||
@Published var status = "Not connected"
|
||||
private let storageKey = "WorkbenchReplacement.connectionProfiles.native.v1"
|
||||
|
||||
init() {
|
||||
if let data = UserDefaults.standard.data(forKey: storageKey),
|
||||
let decoded = try? JSONDecoder().decode([ConnectionProfile].self, from: data) { profiles = decoded }
|
||||
if profiles.isEmpty { profiles = [ConnectionProfile()] }
|
||||
selectedID = profiles.first?.id
|
||||
}
|
||||
var selected: ConnectionProfile? { profiles.first { $0.id == selectedID } }
|
||||
func save(_ profile: ConnectionProfile, password: String) {
|
||||
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
|
||||
else { profiles.append(profile) }
|
||||
PasswordStore.save(password, id: profile.id)
|
||||
selectedID = profile.id
|
||||
persist()
|
||||
}
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
|
||||
}
|
||||
func loadDatabases() { execute("SHOW DATABASES;", listingDatabases: true) }
|
||||
func runSQL() { execute(sql, listingDatabases: false) }
|
||||
private func execute(_ statement: String, listingDatabases: Bool) {
|
||||
guard let profile = selected else { status = "Select a connection"; return }
|
||||
guard !statement.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
isRunning = true
|
||||
status = "Connecting…"
|
||||
let activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
|
||||
let password = PasswordStore.read(profile.id)
|
||||
Task {
|
||||
do {
|
||||
let parsed = try await NativeMySQLRunner.run(profile: profile, database: activeDB, password: password, sql: statement)
|
||||
if listingDatabases {
|
||||
databases = parsed.rows.compactMap { $0.first }
|
||||
status = "Connected to \(profile.name)"
|
||||
} else {
|
||||
result = parsed
|
||||
status = "Query completed (\(parsed.rows.count) rows)"
|
||||
}
|
||||
} catch {
|
||||
status = "Error"
|
||||
result = QueryResult(message: error.localizedDescription)
|
||||
}
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
|
||||
@EnvironmentObject private var model: DatabaseModel
|
||||
|
||||
@State private var showingEditor = false
|
||||
@State private var editing = ConnectionProfile()
|
||||
@State private var editingPassword = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
navigator
|
||||
.navigationSplitViewColumnWidth(
|
||||
min: 220,
|
||||
ideal: 270,
|
||||
max: 400
|
||||
)
|
||||
} detail: {
|
||||
editorPanel
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.frame(minWidth: 1000, minHeight: 650)
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
ConnectionEditor(
|
||||
profile: $editing,
|
||||
password: $editingPassword
|
||||
) {
|
||||
model.save(editing, password: editingPassword)
|
||||
showingEditor = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigator
|
||||
|
||||
private var navigator: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("NAVIGATOR")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.loadDatabases()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Refresh schemas")
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
|
||||
List(selection: $model.selectedID) {
|
||||
|
||||
Section("MYSQL CONNECTIONS") {
|
||||
ForEach(model.profiles) { profile in
|
||||
Label(
|
||||
profile.name,
|
||||
systemImage: "server.rack"
|
||||
)
|
||||
.tag(profile.id)
|
||||
.contextMenu {
|
||||
Button("Edit Connection") {
|
||||
edit(profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("SCHEMAS") {
|
||||
ForEach(model.databases, id: \.self) { db in
|
||||
DisclosureGroup {
|
||||
schemaContents(db)
|
||||
} label: {
|
||||
Label(
|
||||
db,
|
||||
systemImage: "cylinder.split.1x2"
|
||||
)
|
||||
.fontWeight(
|
||||
model.selectedDatabase == db
|
||||
? .semibold : .regular
|
||||
)
|
||||
.contextMenu {
|
||||
Button("Set as Default Schema") {
|
||||
model.selectedDatabase = db
|
||||
}
|
||||
|
||||
Button("Refresh Tables") {
|
||||
model.loadTables(database: db)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
model.selectedDatabase = db
|
||||
if model.schemaTables[db] == nil {
|
||||
model.loadTables(database: db)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
editing = ConnectionProfile(
|
||||
name: "New Connection"
|
||||
)
|
||||
editingPassword = ""
|
||||
showingEditor = true
|
||||
} label: {
|
||||
Label(
|
||||
"New Connection",
|
||||
systemImage: "plus"
|
||||
)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func schemaContents(_ database: String) -> some View {
|
||||
if let tables = model.schemaTables[database] {
|
||||
if tables.isEmpty {
|
||||
Text("No tables")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(tables, id: \.self) { table in
|
||||
Label(table, systemImage: "tablecells")
|
||||
.font(.system(size: 12))
|
||||
.onTapGesture(count: 2) {
|
||||
model.selectTable(
|
||||
database: database,
|
||||
table: table
|
||||
)
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Select First 100 Rows") {
|
||||
model.selectTable(
|
||||
database: database,
|
||||
table: table
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Button("Load Tables") {
|
||||
model.loadTables(database: database)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SQL Editor
|
||||
|
||||
private var editorPanel: some View {
|
||||
VStack(spacing: 0) {
|
||||
editorToolbar
|
||||
|
||||
Divider()
|
||||
|
||||
VSplitView {
|
||||
sqlEditor
|
||||
.frame(minHeight: 180)
|
||||
|
||||
resultsPanel
|
||||
.frame(minHeight: 180)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
statusBar
|
||||
}
|
||||
.navigationTitle("WorkbenchReplacement")
|
||||
}
|
||||
|
||||
private var editorToolbar: some View {
|
||||
HStack(spacing: 14) {
|
||||
|
||||
Label(
|
||||
"SQL Editor",
|
||||
systemImage:
|
||||
"chevron.left.forwardslash.chevron.right"
|
||||
)
|
||||
.font(.headline)
|
||||
|
||||
Divider()
|
||||
.frame(height: 20)
|
||||
|
||||
Button {
|
||||
model.runSQL()
|
||||
} label: {
|
||||
Label(
|
||||
"Execute",
|
||||
systemImage: "bolt.fill"
|
||||
)
|
||||
}
|
||||
.keyboardShortcut(
|
||||
.return,
|
||||
modifiers: [.command]
|
||||
)
|
||||
.disabled(model.isRunning)
|
||||
|
||||
Button {
|
||||
model.formatSQL()
|
||||
} label: {
|
||||
Label(
|
||||
"Format SQL",
|
||||
systemImage: "text.alignleft"
|
||||
)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if !model.selectedDatabase.isEmpty {
|
||||
Label(
|
||||
model.selectedDatabase,
|
||||
systemImage: "cylinder"
|
||||
)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Button {
|
||||
model.loadDatabases()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Refresh schemas")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
private var sqlEditor: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
Text("Query 1")
|
||||
.fontWeight(.medium)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("MySQL")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Divider()
|
||||
|
||||
TextEditor(text: $model.sql)
|
||||
.font(
|
||||
.system(
|
||||
size: 14,
|
||||
design: .monospaced
|
||||
)
|
||||
)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(12)
|
||||
.background(
|
||||
Color(nsColor: .textBackgroundColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results
|
||||
|
||||
private var resultsPanel: some View {
|
||||
VStack(spacing: 0) {
|
||||
|
||||
HStack {
|
||||
Label(
|
||||
"Result Grid",
|
||||
systemImage: "tablecells"
|
||||
)
|
||||
.font(.headline)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(model.result.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
Divider()
|
||||
|
||||
if model.result.columns.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Results",
|
||||
systemImage: "tablecells",
|
||||
description: Text(
|
||||
"Execute a SQL query to display results."
|
||||
)
|
||||
)
|
||||
} else {
|
||||
resultsGrid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resultsGrid: some View {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
Grid(
|
||||
alignment: .leading,
|
||||
horizontalSpacing: 0,
|
||||
verticalSpacing: 0
|
||||
) {
|
||||
GridRow {
|
||||
ForEach(
|
||||
model.result.columns.indices,
|
||||
id: \.self
|
||||
) { index in
|
||||
Text(model.result.columns[index])
|
||||
.fontWeight(.semibold)
|
||||
.frame(
|
||||
minWidth: 150,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(8)
|
||||
.background(
|
||||
Color(nsColor:
|
||||
.controlBackgroundColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(
|
||||
model.result.rows.indices,
|
||||
id: \.self
|
||||
) { rowIndex in
|
||||
resultRow(
|
||||
model.result.rows[rowIndex],
|
||||
index: rowIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resultRow(
|
||||
_ row: [String],
|
||||
index: Int
|
||||
) -> some View {
|
||||
GridRow {
|
||||
ForEach(
|
||||
model.result.columns.indices,
|
||||
id: \.self
|
||||
) { columnIndex in
|
||||
Text(
|
||||
columnIndex < row.count
|
||||
? row[columnIndex] : ""
|
||||
)
|
||||
.font(
|
||||
.system(
|
||||
size: 12,
|
||||
design: .monospaced
|
||||
)
|
||||
)
|
||||
.frame(
|
||||
minWidth: 150,
|
||||
alignment: .leading
|
||||
)
|
||||
.padding(8)
|
||||
.background(
|
||||
index.isMultiple(of: 2)
|
||||
? Color.clear
|
||||
: Color(nsColor:
|
||||
.alternatingContentBackgroundColors[0])
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Status
|
||||
|
||||
private var statusBar: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(
|
||||
model.status == "Error"
|
||||
? Color.red : Color.green
|
||||
)
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(model.status)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(model.selected?.name ?? "No connection")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(9)
|
||||
}
|
||||
|
||||
private func edit(_ profile: ConnectionProfile) {
|
||||
editing = profile
|
||||
editingPassword = PasswordStore.read(profile.id)
|
||||
showingEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Connection Editor
|
||||
|
||||
struct ConnectionEditor: View {
|
||||
|
||||
@Binding var profile: ConnectionProfile
|
||||
@Binding var password: String
|
||||
|
||||
let save: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Connection Settings")
|
||||
.font(.title2.bold())
|
||||
|
||||
Form {
|
||||
TextField(
|
||||
"Connection Name",
|
||||
text: $profile.name
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Hostname",
|
||||
text: $profile.host
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Port",
|
||||
value: $profile.port,
|
||||
format: .number
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Username",
|
||||
text: $profile.username
|
||||
)
|
||||
|
||||
SecureField(
|
||||
"Password (stored in macOS Keychain)",
|
||||
text: $password
|
||||
)
|
||||
|
||||
TextField(
|
||||
"Default Schema",
|
||||
text: $profile.database
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
"Native MySQL connection. Local TCP connections and SSH tunnels are supported."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
Button("Save") {
|
||||
save()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 520)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MainView: View {
|
||||
@EnvironmentObject private var model: DatabaseModel
|
||||
@State private var showingEditor = false
|
||||
@State private var editing = ConnectionProfile()
|
||||
@State private var editingPassword = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
navigator
|
||||
} detail: {
|
||||
editorPanel
|
||||
}
|
||||
.sheet(isPresented: $showingEditor) {
|
||||
ConnectionEditor(profile: $editing, password: $editingPassword) {
|
||||
model.save(editing, password: editingPassword)
|
||||
showingEditor = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var navigator: some View {
|
||||
List(selection: $model.selectedID) {
|
||||
Section("MYSQL CONNECTIONS") {
|
||||
ForEach(model.profiles) { profile in
|
||||
Label(profile.name, systemImage: "server.rack")
|
||||
.tag(profile.id)
|
||||
.contextMenu {
|
||||
Button("Edit Connection") { edit(profile) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("SCHEMAS") {
|
||||
ForEach(model.databases, id: \.self) { db in
|
||||
Button {
|
||||
model.selectedDatabase = db
|
||||
} label: {
|
||||
Label(db, systemImage: "cylinder.split.1x2")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle("Navigator")
|
||||
.toolbar {
|
||||
Button {
|
||||
editing = ConnectionProfile(name: "New Connection")
|
||||
editingPassword = ""
|
||||
showingEditor = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.help("New connection")
|
||||
Button {
|
||||
model.loadDatabases()
|
||||
} label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
.help("Connect / refresh schemas")
|
||||
}
|
||||
}
|
||||
|
||||
private var editorPanel: some View {
|
||||
VStack(spacing: 0) {
|
||||
editorToolbar
|
||||
Divider()
|
||||
TextEditor(text: $model.sql)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.frame(minHeight: 190)
|
||||
.padding(8)
|
||||
Divider()
|
||||
resultsPanel
|
||||
Divider()
|
||||
statusBar
|
||||
}
|
||||
.navigationTitle("Workbench Replacement")
|
||||
}
|
||||
|
||||
private var editorToolbar: some View {
|
||||
HStack {
|
||||
Label("SQL Editor", systemImage: "chevron.left.forwardslash.chevron.right")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button("Edit Connection") {
|
||||
if let profile = model.selected { edit(profile) }
|
||||
}
|
||||
Button("Execute") { model.runSQL() }
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.disabled(model.isRunning)
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
|
||||
private var resultsPanel: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Result Grid").font(.headline)
|
||||
Spacer()
|
||||
Text(model.result.message).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
if model.result.columns.isEmpty {
|
||||
ContentUnavailableView("No Results", systemImage: "tablecells",
|
||||
description: Text("Run a query to display results."))
|
||||
} else {
|
||||
resultsGrid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var resultsGrid: some View {
|
||||
ScrollView([.horizontal, .vertical]) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
ForEach(model.result.columns, id: \.self) { name in
|
||||
Text(name)
|
||||
.fontWeight(.semibold)
|
||||
.frame(minWidth: 130, alignment: .leading)
|
||||
}
|
||||
}
|
||||
ForEach(model.result.rows.indices, id: \.self) { rowIndex in
|
||||
resultRow(model.result.rows[rowIndex])
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
|
||||
private func resultRow(_ row: [String]) -> some View {
|
||||
GridRow {
|
||||
ForEach(model.result.columns.indices, id: \.self) { index in
|
||||
Text(index < row.count ? row[index] : "")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minWidth: 130, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBar: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(model.status == "Error" ? Color.red : Color.green)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(model.status).lineLimit(1)
|
||||
Spacer()
|
||||
Text(model.selected?.name ?? "No connection")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(9)
|
||||
}
|
||||
|
||||
private func edit(_ profile: ConnectionProfile) {
|
||||
editing = profile
|
||||
editingPassword = PasswordStore.read(profile.id)
|
||||
showingEditor = true
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectionEditor: View {
|
||||
@Binding var profile: ConnectionProfile
|
||||
@Binding var password: String
|
||||
let save: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Connection Settings").font(.title2.bold())
|
||||
Form {
|
||||
TextField("Connection Name", text: $profile.name)
|
||||
TextField("Hostname", text: $profile.host)
|
||||
TextField("Port", value: $profile.port, format: .number)
|
||||
TextField("Username", text: $profile.username)
|
||||
SecureField("Password (stored in macOS Keychain)", text: $password)
|
||||
TextField("Default Schema", text: $profile.database)
|
||||
}
|
||||
Text("Built-in MySQL protocol client. This first version supports loopback TCP connections and local SSH tunnels. Remote TLS and full caching_sha2 authentication are not implemented yet.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
Button("Save") { save() }.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 520)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import CoreFoundation
|
||||
import Security
|
||||
|
||||
// Native MySQL wire protocol client. No external mysql executable is used.
|
||||
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
|
||||
struct MySQLWireError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private struct ByteCursor {
|
||||
let bytes: [UInt8]
|
||||
var offset = 0
|
||||
mutating func take(_ n: Int) throws -> [UInt8] {
|
||||
guard n >= 0, offset + n <= bytes.count else { throw MySQLWireError(message: "Malformed MySQL packet") }
|
||||
defer { offset += n }
|
||||
return Array(bytes[offset..<(offset+n)])
|
||||
}
|
||||
mutating func u8() throws -> UInt8 { try take(1)[0] }
|
||||
mutating func u16() throws -> Int { let b = try take(2); return Int(b[0]) | (Int(b[1]) << 8) }
|
||||
mutating func u32() throws -> Int { let b = try take(4); return Int(b[0]) | (Int(b[1]) << 8) | (Int(b[2]) << 16) | (Int(b[3]) << 24) }
|
||||
mutating func nulString() throws -> String {
|
||||
let start = offset
|
||||
while offset < bytes.count && bytes[offset] != 0 { offset += 1 }
|
||||
guard offset < bytes.count else { throw MySQLWireError(message: "Unterminated server string") }
|
||||
let s = String(decoding: bytes[start..<offset], as: UTF8.self)
|
||||
offset += 1
|
||||
return s
|
||||
}
|
||||
mutating func lenInt() throws -> Int? {
|
||||
let first = try u8()
|
||||
switch first {
|
||||
case 0xfb: return nil
|
||||
case 0xfc: return try u16()
|
||||
case 0xfd:
|
||||
let b = try take(3); return Int(b[0]) | Int(b[1]) << 8 | Int(b[2]) << 16
|
||||
case 0xfe:
|
||||
let b = try take(8)
|
||||
var v: UInt64 = 0
|
||||
for (i, byte) in b.enumerated() { v |= UInt64(byte) << (8 * i) }
|
||||
guard v <= UInt64(Int.max) else { throw MySQLWireError(message: "Field too large") }
|
||||
return Int(v)
|
||||
default: return Int(first)
|
||||
}
|
||||
}
|
||||
mutating func lenString() throws -> String? {
|
||||
guard let n = try lenInt() else { return nil }
|
||||
return String(decoding: try take(n), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
private func le32(_ v: Int) -> [UInt8] { (0..<4).map { UInt8(truncatingIfNeeded: v >> ($0 * 8)) } }
|
||||
private func sha1(_ data: [UInt8]) -> [UInt8] {
|
||||
var result = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &result) }
|
||||
return result
|
||||
}
|
||||
private func sha256(_ data: [UInt8]) -> [UInt8] {
|
||||
var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &result) }
|
||||
return result
|
||||
}
|
||||
private func authResponse(password: String, salt: [UInt8], plugin: String) throws -> [UInt8] {
|
||||
if password.isEmpty { return [] }
|
||||
let pwd = Array(password.utf8)
|
||||
if plugin == "mysql_native_password" {
|
||||
let first = sha1(pwd), second = sha1(sha1(pwd))
|
||||
let last = sha1(salt + second)
|
||||
return zip(first, last).map { $0 ^ $1 }
|
||||
}
|
||||
if plugin == "caching_sha2_password" {
|
||||
let first = sha256(pwd), second = sha256(sha256(pwd))
|
||||
let last = sha256(second + salt)
|
||||
return zip(first, last).map { $0 ^ $1 }
|
||||
}
|
||||
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
|
||||
}
|
||||
|
||||
// Parse an RSA SubjectPublicKeyInfo PEM supplied by MySQL. Security.framework
|
||||
// expects the inner PKCS#1 RSAPublicKey DER for SecKeyCreateWithData.
|
||||
private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
|
||||
guard packet.first == 0x01 else {
|
||||
throw MySQLWireError(message: "Expected caching_sha2_password public-key response")
|
||||
}
|
||||
let pem = String(decoding: packet.dropFirst(), as: UTF8.self)
|
||||
let base64 = pem.components(separatedBy: .newlines)
|
||||
.filter { !$0.hasPrefix("-----") }.joined()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let spki = Data(base64Encoded: base64) else {
|
||||
throw MySQLWireError(message: "Invalid MySQL RSA public key")
|
||||
}
|
||||
let der = [UInt8](spki)
|
||||
func length(_ index: inout Int) throws -> Int {
|
||||
guard index < der.count else { throw MySQLWireError(message: "Invalid RSA DER length") }
|
||||
let first = Int(der[index]); index += 1
|
||||
if first < 128 { return first }
|
||||
let bytes = first & 0x7f
|
||||
guard bytes > 0, bytes <= 4, index + bytes <= der.count else {
|
||||
throw MySQLWireError(message: "Invalid RSA DER length")
|
||||
}
|
||||
var value = 0
|
||||
for _ in 0..<bytes { value = (value << 8) | Int(der[index]); index += 1 }
|
||||
return value
|
||||
}
|
||||
func element(_ index: inout Int, tag: UInt8) throws -> Range<Int> {
|
||||
guard index < der.count, der[index] == tag else {
|
||||
throw MySQLWireError(message: "Invalid RSA DER element")
|
||||
}
|
||||
index += 1
|
||||
let count = try length(&index)
|
||||
guard count <= der.count - index else { throw MySQLWireError(message: "Truncated RSA DER") }
|
||||
let range = index..<(index + count)
|
||||
index += count
|
||||
return range
|
||||
}
|
||||
var i = 0
|
||||
let outer = try element(&i, tag: 0x30)
|
||||
i = outer.lowerBound
|
||||
_ = try element(&i, tag: 0x30) // AlgorithmIdentifier
|
||||
let bits = try element(&i, tag: 0x03)
|
||||
guard bits.count > 1, der[bits.lowerBound] == 0 else {
|
||||
throw MySQLWireError(message: "Invalid RSA public key bit string")
|
||||
}
|
||||
let pkcs1 = Data(der[(bits.lowerBound + 1)..<bits.upperBound])
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(pkcs1 as CFData, [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
|
||||
] as CFDictionary, &error) else {
|
||||
throw MySQLWireError(message: "Cannot import MySQL RSA public key: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
final class MySQLWireConnection {
|
||||
private let input: InputStream
|
||||
private let output: OutputStream
|
||||
private var sequence: UInt8 = 0
|
||||
private let maxPacket = 16_777_215
|
||||
|
||||
init(host: String, port: Int) throws {
|
||||
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
|
||||
var read: Unmanaged<CFReadStream>?
|
||||
var write: Unmanaged<CFWriteStream>?
|
||||
CFStreamCreatePairWithSocketToHost(nil, host as CFString, UInt32(port), &read, &write)
|
||||
guard let r = read?.takeRetainedValue(), let w = write?.takeRetainedValue() else {
|
||||
throw MySQLWireError(message: "Cannot create connection")
|
||||
}
|
||||
input = unsafeBitCast(r, to: InputStream.self)
|
||||
output = unsafeBitCast(w, to: OutputStream.self)
|
||||
input.open(); output.open()
|
||||
// A CFStream may still be .opening immediately after open(). The
|
||||
// first blocking read below waits for the server greeting.
|
||||
if input.streamStatus == .error || output.streamStatus == .error {
|
||||
throw MySQLWireError(message: input.streamError?.localizedDescription ?? output.streamError?.localizedDescription ?? "Could not open MySQL connection")
|
||||
}
|
||||
}
|
||||
deinit { input.close(); output.close() }
|
||||
|
||||
private func readExact(_ count: Int) throws -> [UInt8] {
|
||||
var buffer = [UInt8](repeating: 0, count: count)
|
||||
var done = 0
|
||||
while done < count {
|
||||
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
|
||||
guard let base = ptr.baseAddress else { return -1 }
|
||||
return input.read(base.advanced(by: done), maxLength: count - done)
|
||||
}
|
||||
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
|
||||
done += n
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
private func readPacket() throws -> [UInt8] {
|
||||
let header = try readExact(4)
|
||||
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
|
||||
sequence = header[3] &+ 1
|
||||
return try readExact(count)
|
||||
}
|
||||
private func send(_ payload: [UInt8]) throws {
|
||||
guard payload.count < maxPacket else { throw MySQLWireError(message: "Query exceeds initial client packet limit") }
|
||||
let count = payload.count
|
||||
let packet = [UInt8(count & 255), UInt8((count >> 8) & 255), UInt8((count >> 16) & 255), sequence] + payload
|
||||
sequence = sequence &+ 1
|
||||
var written = 0
|
||||
while written < packet.count {
|
||||
let n = packet.withUnsafeBufferPointer { ptr -> Int in
|
||||
guard let base = ptr.baseAddress else { return -1 }
|
||||
return output.write(base.advanced(by: written), maxLength: packet.count - written)
|
||||
}
|
||||
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
|
||||
written += n
|
||||
}
|
||||
}
|
||||
private func checkError(_ p: [UInt8]) throws {
|
||||
guard p.first == 0xff else { return }
|
||||
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
|
||||
throw MySQLWireError(message: "MySQL error: \(details)")
|
||||
}
|
||||
func authenticate(user: String, password: String, database: String) throws {
|
||||
let hello = try readPacket()
|
||||
var c = ByteCursor(bytes: hello)
|
||||
let version = try c.u8()
|
||||
guard version == 10 else { throw MySQLWireError(message: "Unsupported MySQL handshake") }
|
||||
_ = try c.nulString()
|
||||
_ = try c.u32()
|
||||
let salt1 = try c.take(8)
|
||||
_ = try c.u8()
|
||||
let lower = try c.u16()
|
||||
_ = try c.u8() // character set
|
||||
_ = try c.u16() // status
|
||||
let upper = try c.u16()
|
||||
let capabilities = lower | (upper << 16)
|
||||
let authLen = Int(try c.u8())
|
||||
_ = try c.take(10)
|
||||
let salt2 = try c.take(max(13, authLen - 8))
|
||||
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
|
||||
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
||||
// This initial implementation deliberately refuses cleartext remote credentials.
|
||||
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
|
||||
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
|
||||
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
|
||||
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00002000 | 0x00008000 | 0x00080000
|
||||
let response = try authResponse(password: password, salt: Array(salt), plugin: plugin)
|
||||
var body = le32(clientFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
|
||||
body += Array(user.utf8) + [0, UInt8(response.count)] + response
|
||||
body += Array(plugin.utf8) + [0]
|
||||
try send(body)
|
||||
var reply = try readPacket()
|
||||
try checkError(reply)
|
||||
var currentPlugin = plugin
|
||||
var currentSalt = Array(salt)
|
||||
if reply.first == 0xfe {
|
||||
var switchCursor = ByteCursor(bytes: Array(reply.dropFirst()))
|
||||
let newPlugin = try switchCursor.nulString()
|
||||
let rawSalt = Array(reply.dropFirst(1 + newPlugin.utf8.count + 1))
|
||||
let newSalt = rawSalt.last == 0 ? Array(rawSalt.dropLast()) : rawSalt
|
||||
currentPlugin = newPlugin
|
||||
currentSalt = newSalt
|
||||
try send(authResponse(password: password, salt: newSalt, plugin: newPlugin))
|
||||
reply = try readPacket()
|
||||
try checkError(reply)
|
||||
}
|
||||
if reply.first == 0x01 {
|
||||
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
|
||||
throw MySQLWireError(message: "Unsupported authentication continuation")
|
||||
}
|
||||
switch reply[1] {
|
||||
case 0x03: // Fast authentication accepted; wait for OK.
|
||||
reply = try readPacket()
|
||||
case 0x04: // Full authentication required.
|
||||
guard hostIsLoopback else {
|
||||
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
|
||||
}
|
||||
if password.isEmpty {
|
||||
try send([0])
|
||||
} else {
|
||||
// Request the server's RSA public key (0x02). This is
|
||||
// intended ONLY for a trusted local loopback connection.
|
||||
try send([0x02])
|
||||
let keyPacket = try readPacket()
|
||||
try checkError(keyPacket)
|
||||
let key = try mysqlRSAPublicKey(keyPacket)
|
||||
guard !currentSalt.isEmpty else {
|
||||
throw MySQLWireError(message: "Missing MySQL authentication nonce")
|
||||
}
|
||||
var scrambled = Array(password.utf8) + [0]
|
||||
for i in scrambled.indices { scrambled[i] ^= currentSalt[i % currentSalt.count] }
|
||||
var rsaError: Unmanaged<CFError>?
|
||||
guard let encrypted = SecKeyCreateEncryptedData(
|
||||
key, .rsaEncryptionOAEPSHA1, Data(scrambled) as CFData, &rsaError
|
||||
) as Data? else {
|
||||
throw MySQLWireError(message: "MySQL RSA encryption failed: \(rsaError?.takeRetainedValue().localizedDescription ?? "unknown error")")
|
||||
}
|
||||
try send(Array(encrypted))
|
||||
}
|
||||
reply = try readPacket()
|
||||
default:
|
||||
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
|
||||
}
|
||||
}
|
||||
try checkError(reply)
|
||||
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
|
||||
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
|
||||
}
|
||||
private var hostIsLoopback = false
|
||||
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
|
||||
|
||||
func query(_ sql: String) throws -> QueryResult {
|
||||
sequence = 0
|
||||
try send([0x03] + Array(sql.utf8))
|
||||
let first = try readPacket()
|
||||
try checkError(first)
|
||||
if first.first == 0x00 { return QueryResult(message: "Statement executed successfully") }
|
||||
var c = ByteCursor(bytes: first)
|
||||
guard let columnCount = try c.lenInt(), columnCount <= 4096 else { throw MySQLWireError(message: "Invalid column count") }
|
||||
var names: [String] = []
|
||||
for _ in 0..<columnCount {
|
||||
var field = ByteCursor(bytes: try readPacket())
|
||||
for _ in 0..<4 { _ = try field.lenString() }
|
||||
names.append(try field.lenString() ?? "")
|
||||
}
|
||||
_ = try readPacket() // column EOF (initial protocol requires EOF)
|
||||
var rows: [[String]] = []
|
||||
while true {
|
||||
let packet = try readPacket()
|
||||
try checkError(packet)
|
||||
if packet.first == 0xfe && packet.count < 9 { break }
|
||||
var row = ByteCursor(bytes: packet)
|
||||
var values: [String] = []
|
||||
for _ in 0..<columnCount { values.append(try row.lenString() ?? "NULL") }
|
||||
rows.append(values)
|
||||
if rows.count > 100_000 { throw MySQLWireError(message: "Result exceeds 100,000 row safety limit") }
|
||||
}
|
||||
return QueryResult(columns: names, rows: rows, message: "\(rows.count) row(s)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A session owns one authenticated TCP connection. Access is serialized by the
|
||||
// queue below because MySQLWireConnection is not safe for concurrent queries.
|
||||
private final class PersistentMySQLSessions {
|
||||
static let shared = PersistentMySQLSessions()
|
||||
|
||||
private struct Entry {
|
||||
let profile: ConnectionProfile
|
||||
let password: String
|
||||
let connection: MySQLWireConnection
|
||||
}
|
||||
|
||||
private let queue = DispatchQueue(label: "WorkbenchReplacement.MySQLSessions")
|
||||
private var connections: [UUID: Entry] = [:]
|
||||
|
||||
func execute(profile: ConnectionProfile, database: String,
|
||||
password: String, sql: String) throws -> QueryResult {
|
||||
try queue.sync {
|
||||
// A changed connection profile or password requires a new session.
|
||||
if let previous = connections[profile.id],
|
||||
(previous.profile != profile || previous.password != password) {
|
||||
connections.removeValue(forKey: profile.id)
|
||||
}
|
||||
|
||||
if connections[profile.id] == nil {
|
||||
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
|
||||
connection.markHost(profile.host)
|
||||
try connection.authenticate(user: profile.username,
|
||||
password: password, database: database)
|
||||
connections[profile.id] = Entry(profile: profile,
|
||||
password: password,
|
||||
connection: connection)
|
||||
}
|
||||
|
||||
guard let entry = connections[profile.id] else {
|
||||
throw MySQLWireError(message: "MySQL session was not created")
|
||||
}
|
||||
do {
|
||||
// Crucially, no new authentication takes place here. USE,
|
||||
// session variables and transactions survive between queries.
|
||||
return try entry.connection.query(sql)
|
||||
} catch {
|
||||
// The wire protocol can be out of sync after a failed query.
|
||||
// Discard it rather than silently issuing commands on a bad socket.
|
||||
connections.removeValue(forKey: profile.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(profileID: UUID) {
|
||||
queue.sync { _ = connections.removeValue(forKey: profileID) }
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeMySQLRunner {
|
||||
static func run(profile: ConnectionProfile, database: String,
|
||||
password: String, sql: String) async throws -> QueryResult {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try PersistentMySQLSessions.shared.execute(
|
||||
profile: profile, database: database, password: password, sql: sql
|
||||
)
|
||||
}.value
|
||||
}
|
||||
|
||||
static func disconnect(profileID: UUID) {
|
||||
PersistentMySQLSessions.shared.disconnect(profileID: profileID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import CoreFoundation
|
||||
import Security
|
||||
|
||||
// Native MySQL wire protocol client. No external mysql executable is used.
|
||||
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
|
||||
struct MySQLWireError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private struct ByteCursor {
|
||||
let bytes: [UInt8]
|
||||
var offset = 0
|
||||
mutating func take(_ n: Int) throws -> [UInt8] {
|
||||
guard n >= 0, offset + n <= bytes.count else { throw MySQLWireError(message: "Malformed MySQL packet") }
|
||||
defer { offset += n }
|
||||
return Array(bytes[offset..<(offset+n)])
|
||||
}
|
||||
mutating func u8() throws -> UInt8 { try take(1)[0] }
|
||||
mutating func u16() throws -> Int { let b = try take(2); return Int(b[0]) | (Int(b[1]) << 8) }
|
||||
mutating func u32() throws -> Int { let b = try take(4); return Int(b[0]) | (Int(b[1]) << 8) | (Int(b[2]) << 16) | (Int(b[3]) << 24) }
|
||||
mutating func nulString() throws -> String {
|
||||
let start = offset
|
||||
while offset < bytes.count && bytes[offset] != 0 { offset += 1 }
|
||||
guard offset < bytes.count else { throw MySQLWireError(message: "Unterminated server string") }
|
||||
let s = String(decoding: bytes[start..<offset], as: UTF8.self)
|
||||
offset += 1
|
||||
return s
|
||||
}
|
||||
mutating func lenInt() throws -> Int? {
|
||||
let first = try u8()
|
||||
switch first {
|
||||
case 0xfb: return nil
|
||||
case 0xfc: return try u16()
|
||||
case 0xfd:
|
||||
let b = try take(3); return Int(b[0]) | Int(b[1]) << 8 | Int(b[2]) << 16
|
||||
case 0xfe:
|
||||
let b = try take(8)
|
||||
var v: UInt64 = 0
|
||||
for (i, byte) in b.enumerated() { v |= UInt64(byte) << (8 * i) }
|
||||
guard v <= UInt64(Int.max) else { throw MySQLWireError(message: "Field too large") }
|
||||
return Int(v)
|
||||
default: return Int(first)
|
||||
}
|
||||
}
|
||||
mutating func lenString() throws -> String? {
|
||||
guard let n = try lenInt() else { return nil }
|
||||
return String(decoding: try take(n), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
private func le32(_ v: Int) -> [UInt8] { (0..<4).map { UInt8(truncatingIfNeeded: v >> ($0 * 8)) } }
|
||||
private func sha1(_ data: [UInt8]) -> [UInt8] {
|
||||
var result = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes { ptr in _ = CC_SHA1(ptr.baseAddress, CC_LONG(data.count), &result) }
|
||||
return result
|
||||
}
|
||||
private func sha256(_ data: [UInt8]) -> [UInt8] {
|
||||
var result = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
data.withUnsafeBytes { ptr in _ = CC_SHA256(ptr.baseAddress, CC_LONG(data.count), &result) }
|
||||
return result
|
||||
}
|
||||
private func authResponse(password: String, salt: [UInt8], plugin: String) throws -> [UInt8] {
|
||||
if password.isEmpty { return [] }
|
||||
let pwd = Array(password.utf8)
|
||||
if plugin == "mysql_native_password" {
|
||||
let first = sha1(pwd), second = sha1(sha1(pwd))
|
||||
let last = sha1(salt + second)
|
||||
return zip(first, last).map { $0 ^ $1 }
|
||||
}
|
||||
if plugin == "caching_sha2_password" {
|
||||
let first = sha256(pwd), second = sha256(sha256(pwd))
|
||||
let last = sha256(second + salt)
|
||||
return zip(first, last).map { $0 ^ $1 }
|
||||
}
|
||||
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
|
||||
}
|
||||
|
||||
// Parse an RSA SubjectPublicKeyInfo PEM supplied by MySQL. Security.framework
|
||||
// expects the inner PKCS#1 RSAPublicKey DER for SecKeyCreateWithData.
|
||||
private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
|
||||
guard packet.first == 0x01 else {
|
||||
throw MySQLWireError(message: "Expected caching_sha2_password public-key response")
|
||||
}
|
||||
let pem = String(decoding: packet.dropFirst(), as: UTF8.self)
|
||||
let base64 = pem.components(separatedBy: .newlines)
|
||||
.filter { !$0.hasPrefix("-----") }.joined()
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let spki = Data(base64Encoded: base64) else {
|
||||
throw MySQLWireError(message: "Invalid MySQL RSA public key")
|
||||
}
|
||||
let der = [UInt8](spki)
|
||||
func length(_ index: inout Int) throws -> Int {
|
||||
guard index < der.count else { throw MySQLWireError(message: "Invalid RSA DER length") }
|
||||
let first = Int(der[index]); index += 1
|
||||
if first < 128 { return first }
|
||||
let bytes = first & 0x7f
|
||||
guard bytes > 0, bytes <= 4, index + bytes <= der.count else {
|
||||
throw MySQLWireError(message: "Invalid RSA DER length")
|
||||
}
|
||||
var value = 0
|
||||
for _ in 0..<bytes { value = (value << 8) | Int(der[index]); index += 1 }
|
||||
return value
|
||||
}
|
||||
func element(_ index: inout Int, tag: UInt8) throws -> Range<Int> {
|
||||
guard index < der.count, der[index] == tag else {
|
||||
throw MySQLWireError(message: "Invalid RSA DER element")
|
||||
}
|
||||
index += 1
|
||||
let count = try length(&index)
|
||||
guard count <= der.count - index else { throw MySQLWireError(message: "Truncated RSA DER") }
|
||||
let range = index..<(index + count)
|
||||
index += count
|
||||
return range
|
||||
}
|
||||
var i = 0
|
||||
let outer = try element(&i, tag: 0x30)
|
||||
i = outer.lowerBound
|
||||
_ = try element(&i, tag: 0x30) // AlgorithmIdentifier
|
||||
let bits = try element(&i, tag: 0x03)
|
||||
guard bits.count > 1, der[bits.lowerBound] == 0 else {
|
||||
throw MySQLWireError(message: "Invalid RSA public key bit string")
|
||||
}
|
||||
let pkcs1 = Data(der[(bits.lowerBound + 1)..<bits.upperBound])
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(pkcs1 as CFData, [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
|
||||
] as CFDictionary, &error) else {
|
||||
throw MySQLWireError(message: "Cannot import MySQL RSA public key: \(error?.takeRetainedValue().localizedDescription ?? "unknown error")")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
final class MySQLWireConnection {
|
||||
private let input: InputStream
|
||||
private let output: OutputStream
|
||||
private var sequence: UInt8 = 0
|
||||
private let maxPacket = 16_777_215
|
||||
|
||||
init(host: String, port: Int) throws {
|
||||
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
|
||||
var read: Unmanaged<CFReadStream>?
|
||||
var write: Unmanaged<CFWriteStream>?
|
||||
CFStreamCreatePairWithSocketToHost(nil, host as CFString, UInt32(port), &read, &write)
|
||||
guard let r = read?.takeRetainedValue(), let w = write?.takeRetainedValue() else {
|
||||
throw MySQLWireError(message: "Cannot create connection")
|
||||
}
|
||||
input = unsafeBitCast(r, to: InputStream.self)
|
||||
output = unsafeBitCast(w, to: OutputStream.self)
|
||||
input.open(); output.open()
|
||||
// A CFStream may still be .opening immediately after open(). The
|
||||
// first blocking read below waits for the server greeting.
|
||||
if input.streamStatus == .error || output.streamStatus == .error {
|
||||
throw MySQLWireError(message: input.streamError?.localizedDescription ?? output.streamError?.localizedDescription ?? "Could not open MySQL connection")
|
||||
}
|
||||
}
|
||||
deinit { input.close(); output.close() }
|
||||
|
||||
private func readExact(_ count: Int) throws -> [UInt8] {
|
||||
var buffer = [UInt8](repeating: 0, count: count)
|
||||
var done = 0
|
||||
while done < count {
|
||||
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
|
||||
guard let base = ptr.baseAddress else { return -1 }
|
||||
return input.read(base.advanced(by: done), maxLength: count - done)
|
||||
}
|
||||
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
|
||||
done += n
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
private func readPacket() throws -> [UInt8] {
|
||||
let header = try readExact(4)
|
||||
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
|
||||
sequence = header[3] &+ 1
|
||||
return try readExact(count)
|
||||
}
|
||||
private func send(_ payload: [UInt8]) throws {
|
||||
guard payload.count < maxPacket else { throw MySQLWireError(message: "Query exceeds initial client packet limit") }
|
||||
let count = payload.count
|
||||
let packet = [UInt8(count & 255), UInt8((count >> 8) & 255), UInt8((count >> 16) & 255), sequence] + payload
|
||||
sequence = sequence &+ 1
|
||||
var written = 0
|
||||
while written < packet.count {
|
||||
let n = packet.withUnsafeBufferPointer { ptr -> Int in
|
||||
guard let base = ptr.baseAddress else { return -1 }
|
||||
return output.write(base.advanced(by: written), maxLength: packet.count - written)
|
||||
}
|
||||
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
|
||||
written += n
|
||||
}
|
||||
}
|
||||
private func checkError(_ p: [UInt8]) throws {
|
||||
guard p.first == 0xff else { return }
|
||||
let details = String(decoding: p.dropFirst(3), as: UTF8.self)
|
||||
throw MySQLWireError(message: "MySQL error: \(details)")
|
||||
}
|
||||
func authenticate(user: String, password: String, database: String) throws {
|
||||
let hello = try readPacket()
|
||||
var c = ByteCursor(bytes: hello)
|
||||
let version = try c.u8()
|
||||
guard version == 10 else { throw MySQLWireError(message: "Unsupported MySQL handshake") }
|
||||
_ = try c.nulString()
|
||||
_ = try c.u32()
|
||||
let salt1 = try c.take(8)
|
||||
_ = try c.u8()
|
||||
let lower = try c.u16()
|
||||
_ = try c.u8() // character set
|
||||
_ = try c.u16() // status
|
||||
let upper = try c.u16()
|
||||
let capabilities = lower | (upper << 16)
|
||||
let authLen = Int(try c.u8())
|
||||
_ = try c.take(10)
|
||||
let salt2 = try c.take(max(13, authLen - 8))
|
||||
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
|
||||
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
||||
// This initial implementation deliberately refuses cleartext remote credentials.
|
||||
guard hostIsLoopback else { throw MySQLWireError(message: "Direct remote connections require TLS, which is not yet implemented. Use a local SSH tunnel.") }
|
||||
// CLIENT_LONG_PASSWORD, LONG_FLAG, PROTOCOL_41, TRANSACTIONS,
|
||||
// SECURE_CONNECTION and PLUGIN_AUTH. Do not set DEPRECATE_EOF.
|
||||
let clientFlags = 0x00000001 | 0x00000004 | 0x00000200 | 0x00002000 | 0x00008000 | 0x00080000
|
||||
let response = try authResponse(password: password, salt: Array(salt), plugin: plugin)
|
||||
var body = le32(clientFlags & capabilities) + le32(16_777_216) + [UInt8(45)] + [UInt8](repeating: 0, count: 23)
|
||||
body += Array(user.utf8) + [0, UInt8(response.count)] + response
|
||||
body += Array(plugin.utf8) + [0]
|
||||
try send(body)
|
||||
var reply = try readPacket()
|
||||
try checkError(reply)
|
||||
var currentPlugin = plugin
|
||||
var currentSalt = Array(salt)
|
||||
if reply.first == 0xfe {
|
||||
var switchCursor = ByteCursor(bytes: Array(reply.dropFirst()))
|
||||
let newPlugin = try switchCursor.nulString()
|
||||
let rawSalt = Array(reply.dropFirst(1 + newPlugin.utf8.count + 1))
|
||||
let newSalt = rawSalt.last == 0 ? Array(rawSalt.dropLast()) : rawSalt
|
||||
currentPlugin = newPlugin
|
||||
currentSalt = newSalt
|
||||
try send(authResponse(password: password, salt: newSalt, plugin: newPlugin))
|
||||
reply = try readPacket()
|
||||
try checkError(reply)
|
||||
}
|
||||
if reply.first == 0x01 {
|
||||
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
|
||||
throw MySQLWireError(message: "Unsupported authentication continuation")
|
||||
}
|
||||
switch reply[1] {
|
||||
case 0x03: // Fast authentication accepted; wait for OK.
|
||||
reply = try readPacket()
|
||||
case 0x04: // Full authentication required.
|
||||
guard hostIsLoopback else {
|
||||
throw MySQLWireError(message: "Remote full authentication requires TLS (not implemented). Use a trusted local SSH tunnel.")
|
||||
}
|
||||
if password.isEmpty {
|
||||
try send([0])
|
||||
} else {
|
||||
// Request the server's RSA public key (0x02). This is
|
||||
// intended ONLY for a trusted local loopback connection.
|
||||
try send([0x02])
|
||||
let keyPacket = try readPacket()
|
||||
try checkError(keyPacket)
|
||||
let key = try mysqlRSAPublicKey(keyPacket)
|
||||
guard !currentSalt.isEmpty else {
|
||||
throw MySQLWireError(message: "Missing MySQL authentication nonce")
|
||||
}
|
||||
var scrambled = Array(password.utf8) + [0]
|
||||
for i in scrambled.indices { scrambled[i] ^= currentSalt[i % currentSalt.count] }
|
||||
var rsaError: Unmanaged<CFError>?
|
||||
guard let encrypted = SecKeyCreateEncryptedData(
|
||||
key, .rsaEncryptionOAEPSHA1, Data(scrambled) as CFData, &rsaError
|
||||
) as Data? else {
|
||||
throw MySQLWireError(message: "MySQL RSA encryption failed: \(rsaError?.takeRetainedValue().localizedDescription ?? "unknown error")")
|
||||
}
|
||||
try send(Array(encrypted))
|
||||
}
|
||||
reply = try readPacket()
|
||||
default:
|
||||
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
|
||||
}
|
||||
}
|
||||
try checkError(reply)
|
||||
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
|
||||
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
|
||||
}
|
||||
private var hostIsLoopback = false
|
||||
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
|
||||
|
||||
func query(_ sql: String) throws -> QueryResult {
|
||||
sequence = 0
|
||||
try send([0x03] + Array(sql.utf8))
|
||||
let first = try readPacket()
|
||||
try checkError(first)
|
||||
if first.first == 0x00 { return QueryResult(message: "Statement executed successfully") }
|
||||
var c = ByteCursor(bytes: first)
|
||||
guard let columnCount = try c.lenInt(), columnCount <= 4096 else { throw MySQLWireError(message: "Invalid column count") }
|
||||
var names: [String] = []
|
||||
for _ in 0..<columnCount {
|
||||
var field = ByteCursor(bytes: try readPacket())
|
||||
for _ in 0..<4 { _ = try field.lenString() }
|
||||
names.append(try field.lenString() ?? "")
|
||||
}
|
||||
_ = try readPacket() // column EOF (initial protocol requires EOF)
|
||||
var rows: [[String]] = []
|
||||
while true {
|
||||
let packet = try readPacket()
|
||||
try checkError(packet)
|
||||
if packet.first == 0xfe && packet.count < 9 { break }
|
||||
var row = ByteCursor(bytes: packet)
|
||||
var values: [String] = []
|
||||
for _ in 0..<columnCount { values.append(try row.lenString() ?? "NULL") }
|
||||
rows.append(values)
|
||||
if rows.count > 100_000 { throw MySQLWireError(message: "Result exceeds 100,000 row safety limit") }
|
||||
}
|
||||
return QueryResult(columns: names, rows: rows, message: "\(rows.count) row(s)")
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeMySQLRunner {
|
||||
static func run(profile: ConnectionProfile, database: String, password: String, sql: String) async throws -> QueryResult {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
|
||||
connection.markHost(profile.host)
|
||||
try connection.authenticate(user: profile.username, password: password, database: database)
|
||||
return try connection.query(sql)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user