Compare commits
11
Commits
445f674367
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
face625f23 | ||
|
|
1d8e8702a8 | ||
|
|
2aaeff3774 | ||
|
|
3f9f572434 | ||
|
|
2229238e52 | ||
|
|
60cbf790db | ||
|
|
0a4cca2b41 | ||
|
|
233ff2bf64 | ||
|
|
86d7ae49fb | ||
|
|
6768ef5fff | ||
|
|
499dd49ec2 |
@@ -221,8 +221,8 @@
|
|||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
|
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.1.1;
|
||||||
CURRENT_PROJECT_VERSION = 73;
|
CURRENT_PROJECT_VERSION = 83;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
|
||||||
@@ -244,8 +244,8 @@
|
|||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
|
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.1.1;
|
||||||
CURRENT_PROJECT_VERSION = 73;
|
CURRENT_PROJECT_VERSION = 83;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
|
||||||
|
|||||||
@@ -12,15 +12,16 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
|
|||||||
// Optional SSH transport. Existing saved profiles decode with these defaults.
|
// Optional SSH transport. Existing saved profiles decode with these defaults.
|
||||||
var useSSHTunnel = false
|
var useSSHTunnel = false
|
||||||
var useTLS = true
|
var useTLS = true
|
||||||
|
var verifyTLS = true
|
||||||
var sshHost = ""
|
var sshHost = ""
|
||||||
var sshPort = 22
|
var sshPort = 22
|
||||||
var sshUser = ""
|
var sshUser = ""
|
||||||
var sshIdentityFile = ""
|
var sshIdentityFile = ""
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, sshHost, sshPort, sshUser, sshIdentityFile }
|
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, verifyTLS, sshHost, sshPort, sshUser, sshIdentityFile }
|
||||||
init(id: UUID = UUID(), name: String = "Local MySQL", host: String = "127.0.0.1", port: Int = 3306, username: String = "root", database: String = "", useSSHTunnel: Bool = false, useTLS: Bool = true, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") {
|
init(id: UUID = UUID(), name: String = "Local MySQL", host: String = "127.0.0.1", port: Int = 3306, username: String = "root", database: String = "", useSSHTunnel: Bool = false, useTLS: Bool = true, verifyTLS: Bool = true, sshHost: String = "", sshPort: Int = 22, sshUser: String = "", sshIdentityFile: String = "") {
|
||||||
self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database
|
self.id = id; self.name = name; self.host = host; self.port = port; self.username = username; self.database = database
|
||||||
self.useSSHTunnel = useSSHTunnel; self.useTLS = useTLS; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
|
self.useSSHTunnel = useSSHTunnel; self.useTLS = useTLS; self.verifyTLS = verifyTLS; self.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
|
||||||
}
|
}
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
@@ -32,6 +33,7 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
|
|||||||
database = try c.decode(String.self, forKey: .database)
|
database = try c.decode(String.self, forKey: .database)
|
||||||
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
|
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
|
||||||
useTLS = try c.decodeIfPresent(Bool.self, forKey: .useTLS) ?? true
|
useTLS = try c.decodeIfPresent(Bool.self, forKey: .useTLS) ?? true
|
||||||
|
verifyTLS = try c.decodeIfPresent(Bool.self, forKey: .verifyTLS) ?? true
|
||||||
sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? ""
|
sshHost = try c.decodeIfPresent(String.self, forKey: .sshHost) ?? ""
|
||||||
sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22
|
sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22
|
||||||
sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
|
sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
|
||||||
@@ -72,17 +74,53 @@ enum PasswordStore {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
static func save(_ password: String, id: UUID) {
|
@discardableResult
|
||||||
|
static func save(_ password: String, id: UUID) -> Bool {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: service,
|
kSecAttrService as String: service,
|
||||||
kSecAttrAccount as String: id.uuidString
|
kSecAttrAccount as String: id.uuidString
|
||||||
]
|
]
|
||||||
SecItemDelete(query as CFDictionary)
|
|
||||||
guard !password.isEmpty else { return }
|
if password.isEmpty {
|
||||||
var attributes = query
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
attributes[kSecValueData as String] = Data(password.utf8)
|
return status == errSecSuccess || status == errSecItemNotFound
|
||||||
SecItemAdd(attributes as CFDictionary, nil)
|
}
|
||||||
|
|
||||||
|
let data = Data(password.utf8)
|
||||||
|
|
||||||
|
let update: [String: Any] = [
|
||||||
|
kSecValueData as String: data
|
||||||
|
]
|
||||||
|
|
||||||
|
let updateStatus = SecItemUpdate(
|
||||||
|
query as CFDictionary,
|
||||||
|
update as CFDictionary
|
||||||
|
)
|
||||||
|
|
||||||
|
if updateStatus == errSecSuccess {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if updateStatus == errSecItemNotFound {
|
||||||
|
var attributes = query
|
||||||
|
attributes[kSecValueData as String] = data
|
||||||
|
|
||||||
|
let addStatus = SecItemAdd(
|
||||||
|
attributes as CFDictionary,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if addStatus == errSecSuccess {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("DBSyn Keychain add failed: %d", addStatus)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
NSLog("DBSyn Keychain update failed: %d", updateStatus)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
static func delete(_ id: UUID) {
|
static func delete(_ id: UUID) {
|
||||||
|
|||||||
+123
-51
@@ -1119,13 +1119,13 @@ struct MainView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
editResultRow = selectedResultRow
|
if let row = selectedResultRow { openResultRowEditor(row) }
|
||||||
} label: {
|
} label: {
|
||||||
Label("Edit Row…", systemImage: "pencil")
|
Label("Edit Row…", systemImage: "pencil")
|
||||||
}
|
}
|
||||||
.disabled(selectedResultRow == nil || editableResultTarget == nil)
|
.disabled(selectedResultRow == nil || parsedResultTarget == nil)
|
||||||
.help(editableResultTarget == nil
|
.help(parsedResultTarget == nil
|
||||||
? "Editing requires a SELECT * from a single base table with a primary key and loaded introspection metadata."
|
? "Editing requires a single-table SELECT containing its primary key."
|
||||||
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
|
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
|
||||||
|
|
||||||
if !model.result.columns.isEmpty {
|
if !model.result.columns.isEmpty {
|
||||||
@@ -1197,9 +1197,9 @@ struct MainView: View {
|
|||||||
}
|
}
|
||||||
.sheet(isPresented: Binding(
|
.sheet(isPresented: Binding(
|
||||||
get: { editResultRow != nil },
|
get: { editResultRow != nil },
|
||||||
set: { if !$0 { editResultRow = nil } }
|
set: { if !$0 { editResultRow = nil; editingResultTarget = nil } }
|
||||||
)) {
|
)) {
|
||||||
if let row = editResultRow, let target = editableResultTarget {
|
if let row = editResultRow, let target = editingResultTarget {
|
||||||
DBSynInlineRowEditor(model: model, database: target.database,
|
DBSynInlineRowEditor(model: model, database: target.database,
|
||||||
table: target.table, columns: model.result.columns, original: row)
|
table: target.table, columns: model.result.columns, original: row)
|
||||||
}
|
}
|
||||||
@@ -1220,39 +1220,57 @@ struct MainView: View {
|
|||||||
let end = min(start + resultPageSize, rows.count)
|
let end = min(start + resultPageSize, rows.count)
|
||||||
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns, onSelect: { selectedResultRow = $0 }, onEdit: { row in
|
return NativeResultGrid(columns: model.result.columns, rows: Array(rows[start..<end]), hiddenColumnIndices: hiddenResultColumns, onSelect: { selectedResultRow = $0 }, onEdit: { row in
|
||||||
selectedResultRow = row
|
selectedResultRow = row
|
||||||
if editableResultTarget != nil { editResultRow = row }
|
openResultRowEditor(row)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Editing is restricted to a simple SELECT * from a single base table.
|
@State private var editingResultTarget: (database: String, table: String)?
|
||||||
// Arbitrary joins, expressions and aliases cannot be mapped safely to rows.
|
|
||||||
private var editableResultTarget: (database: String, table: String)? {
|
private func openResultRowEditor(_ row: [String]) {
|
||||||
|
guard let target = parsedResultTarget else {
|
||||||
|
model.status = "Row editing requires a simple SELECT from one table."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
editingResultTarget = target
|
||||||
|
// Fetch primary-key metadata when the user first edits; do not silently
|
||||||
|
// disable the editor because the schema browser has not been opened.
|
||||||
|
if model.introspection(for: target.database) == nil {
|
||||||
|
model.introspect(database: target.database)
|
||||||
|
}
|
||||||
|
editResultRow = row
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the result query independently of the asynchronous schema cache.
|
||||||
|
// The editor validates actual primary keys before allowing an UPDATE.
|
||||||
|
private var parsedResultTarget: (database: String, table: String)? {
|
||||||
let statements = model.sql.components(separatedBy: ";")
|
let statements = model.sql.components(separatedBy: ";")
|
||||||
guard let select = statements.last(where: { $0.range(of: #"(?i)\bSELECT\b"#, options: .regularExpression) != nil }) else { return nil }
|
guard let select = statements.last(where: {
|
||||||
// Permit ordinary filtered/sorted single-table SELECT * results. The
|
$0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil
|
||||||
// primary-key checks in DBSynInlineRowEditor still guard every UPDATE.
|
}) else { return nil }
|
||||||
// Joins, grouping, unions and computed result sets remain read-only.
|
let pattern = #"(?is)^\s*SELECT\s+(.+?)\s+FROM\s+((?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*))?)(?=\s|$)(.*)$"#
|
||||||
let pattern = #"(?is)^\s*SELECT\s+\*\s+FROM\s+((?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*)(?:\.(?:`[^`]+`|[A-Za-z_][A-Za-z0-9_]*))?)(?=\s|$)(.*)$"#
|
|
||||||
guard let regex = try? NSRegularExpression(pattern: pattern),
|
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||||
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
|
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
|
||||||
let range = Range(match.range(at: 1), in: select) else { return nil }
|
let projectionRange = Range(match.range(at: 1), in: select),
|
||||||
guard let tailRange = Range(match.range(at: 2), in: select) else { return nil }
|
let tableRange = Range(match.range(at: 2), in: select),
|
||||||
|
let tailRange = Range(match.range(at: 3), in: select) else { return nil }
|
||||||
|
let projection = String(select[projectionRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let tail = String(select[tailRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
let tail = String(select[tailRange]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// Only permit WHERE / ORDER BY / LIMIT clauses; do not treat joins,
|
|
||||||
// unions, grouped rows, or derived expressions as directly editable.
|
|
||||||
if !tail.isEmpty {
|
if !tail.isEmpty {
|
||||||
guard tail.range(of: #"(?i)^(WHERE|ORDER\s+BY|LIMIT)\b"#, options: .regularExpression) != nil,
|
guard tail.range(of: #"(?i)^(WHERE|ORDER\s+BY|LIMIT)\b"#, options: .regularExpression) != nil,
|
||||||
tail.range(of: #"(?i)\b(JOIN|UNION|GROUP\s+BY|HAVING|INTO|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE)\b"#, options: .regularExpression) == nil,
|
tail.range(of: #"(?i)\b(JOIN|UNION|GROUP\s+BY|HAVING|INTO|FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE)\b"#, options: .regularExpression) == nil else { return nil }
|
||||||
!tail.contains(";") else { return nil }
|
|
||||||
}
|
}
|
||||||
let parts = String(select[range]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".")
|
let parts = String(select[tableRange]).replacingOccurrences(of: "`", with: "").components(separatedBy: ".")
|
||||||
let database = parts.count == 2 ? parts[0] : model.completionDatabase
|
let database = parts.count == 2 ? parts[0] : model.completionDatabase
|
||||||
let table = parts.last ?? ""
|
guard !database.isEmpty, let table = parts.last, !model.result.columns.isEmpty else { return nil }
|
||||||
guard !database.isEmpty, let snapshot = model.introspection(for: database),
|
let names = model.result.columns.map { $0.lowercased() }
|
||||||
snapshot.tables.contains(where: { $0.caseInsensitiveCompare(table) == .orderedSame }),
|
guard Set(names).count == names.count else { return nil }
|
||||||
let fields = snapshot.columnsByTable[table],
|
if projection != "*" {
|
||||||
fields.map(\.name).map({ $0.lowercased() }) == model.result.columns.map({ $0.lowercased() }),
|
let projected = projection.components(separatedBy: ",").map {
|
||||||
fields.contains(where: { $0.key == "PRI" }) else { return nil }
|
$0.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "`", with: "")
|
||||||
|
}
|
||||||
|
guard projected.count == names.count,
|
||||||
|
zip(projected, names).allSatisfy({ $0.0.lowercased() == $0.1 }) else { return nil }
|
||||||
|
}
|
||||||
return (database, table)
|
return (database, table)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2532,8 +2550,12 @@ struct ConnectionEditor: View {
|
|||||||
TextField("Default Schema", text: $profile.database)
|
TextField("Default Schema", text: $profile.database)
|
||||||
}
|
}
|
||||||
Section("Secure Transport") {
|
Section("Secure Transport") {
|
||||||
Toggle("Require verified TLS for direct remote connections", isOn: $profile.useTLS)
|
Toggle("Verify server identity (recommended)", isOn: $profile.verifyTLS)
|
||||||
.disabled(profile.useSSHTunnel)
|
.disabled(profile.useSSHTunnel)
|
||||||
|
if !profile.verifyTLS && !profile.useSSHTunnel {
|
||||||
|
Text("Encrypted TLS (MySQL REQUIRED): traffic is encrypted, but the server identity is NOT verified. Only use with servers you trust.")
|
||||||
|
.font(.caption).foregroundStyle(.orange)
|
||||||
|
}
|
||||||
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
|
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
|
||||||
if profile.useSSHTunnel {
|
if profile.useSSHTunnel {
|
||||||
TextField("SSH Host", text: $profile.sshHost)
|
TextField("SSH Host", text: $profile.sshHost)
|
||||||
@@ -2543,7 +2565,7 @@ struct ConnectionEditor: View {
|
|||||||
Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.")
|
Text("Uses your system SSH keys and known_hosts. MySQL hostname is resolved from the SSH server.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Direct remote MySQL uses certificate-verified TLS. The server certificate must match its hostname and be trusted by macOS.")
|
Text(profile.verifyTLS ? "Direct remote MySQL uses verified TLS." : "Direct remote MySQL uses encrypted TLS without server identity verification.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2637,6 +2659,8 @@ struct NativeResultGrid: NSViewRepresentable {
|
|||||||
table.menu = menu
|
table.menu = menu
|
||||||
|
|
||||||
table.delegate = context.coordinator
|
table.delegate = context.coordinator
|
||||||
|
table.target = context.coordinator
|
||||||
|
table.doubleAction = #selector(Coordinator.doubleClickedRow(_:))
|
||||||
table.dataSource = context.coordinator
|
table.dataSource = context.coordinator
|
||||||
|
|
||||||
configureColumns(table, hiddenIndices: hiddenColumnIndices)
|
configureColumns(table, hiddenIndices: hiddenColumnIndices)
|
||||||
@@ -2713,6 +2737,11 @@ struct NativeResultGrid: NSViewRepresentable {
|
|||||||
var onEdit: (([String]) -> Void)?
|
var onEdit: (([String]) -> Void)?
|
||||||
weak var tableView: NSTableView?
|
weak var tableView: NSTableView?
|
||||||
|
|
||||||
|
@objc func doubleClickedRow(_ sender: Any?) {
|
||||||
|
guard let table = tableView, rows.indices.contains(table.clickedRow) else { return }
|
||||||
|
onEdit?(rows[table.clickedRow])
|
||||||
|
}
|
||||||
|
|
||||||
@objc func editClickedRow(_ sender: NSMenuItem) {
|
@objc func editClickedRow(_ sender: NSMenuItem) {
|
||||||
guard let table = tableView else { return }
|
guard let table = tableView else { return }
|
||||||
let index = table.clickedRow >= 0 ? table.clickedRow : table.selectedRow
|
let index = table.clickedRow >= 0 ? table.clickedRow : table.selectedRow
|
||||||
@@ -7678,6 +7707,7 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
@State private var executing = false
|
@State private var executing = false
|
||||||
@State private var errorText: String?
|
@State private var errorText: String?
|
||||||
@State private var passwordColumnIndex: Int?
|
@State private var passwordColumnIndex: Int?
|
||||||
|
@State private var generatedPasswordProof: (index: Int, hash: String)?
|
||||||
|
|
||||||
init(model: DatabaseModel, database: String, table: String,
|
init(model: DatabaseModel, database: String, table: String,
|
||||||
columns: [String], original: [String]) {
|
columns: [String], original: [String]) {
|
||||||
@@ -7690,7 +7720,7 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var metadata: [DatabaseModel.IntrospectedColumn] {
|
private var metadata: [DatabaseModel.IntrospectedColumn] {
|
||||||
model.introspection(for: database)?.columnsByTable[table] ?? []
|
model.introspection(for: database)?.columnsByTable.first(where: { $0.key.caseInsensitiveCompare(table) == .orderedSame })?.value ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
private var primaryIndices: [Int] {
|
private var primaryIndices: [Int] {
|
||||||
@@ -7708,14 +7738,17 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func literal(_ value: String) -> String {
|
private func literal(_ value: String) -> String {
|
||||||
// MySQL backslash escapes are SQL-mode-dependent. Hexadecimal UTF-8
|
// Human-readable SQL for ordinary values. Fall back to UTF-8 hex
|
||||||
// literals avoid SQL injection and preserve quote/backslash characters.
|
// for control characters or backslashes (SQL mode dependent).
|
||||||
"CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)"
|
if value.unicodeScalars.contains(where: { $0.value < 32 || $0.value == 127 }) || value.contains("\\") {
|
||||||
|
return "CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)"
|
||||||
|
}
|
||||||
|
return "'" + value.replacingOccurrences(of: "'", with: "''") + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
private var validationError: String? {
|
private var validationError: String? {
|
||||||
guard columns.count == original.count, values.count == columns.count,
|
guard columns.count == original.count, values.count == columns.count,
|
||||||
metadata.count == columns.count else { return "Result columns do not match introspection metadata. Refresh introspection." }
|
columns.allSatisfy({ column in metadata.contains(where: { $0.name.caseInsensitiveCompare(column) == .orderedSame }) }) else { return "Result columns are missing from introspection metadata. Refresh introspection." }
|
||||||
guard !primaryIndices.isEmpty else { return "A primary key is required for safe row updates." }
|
guard !primaryIndices.isEmpty else { return "A primary key is required for safe row updates." }
|
||||||
guard !changedIndices.isEmpty else { return "Edit at least one non-key value to generate SQL." }
|
guard !changedIndices.isEmpty else { return "Edit at least one non-key value to generate SQL." }
|
||||||
guard !changedIndices.contains(where: { primaryIndices.contains($0) }) else { return "Primary-key editing is not supported." }
|
guard !changedIndices.contains(where: { primaryIndices.contains($0) }) else { return "Primary-key editing is not supported." }
|
||||||
@@ -7723,7 +7756,7 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
// "NULL". Never generate SQL when this representation is ambiguous.
|
// "NULL". Never generate SQL when this representation is ambiguous.
|
||||||
let checked = Set(primaryIndices + changedIndices)
|
let checked = Set(primaryIndices + changedIndices)
|
||||||
for i in checked {
|
for i in checked {
|
||||||
if metadata[i].nullable && original[i] == "NULL" {
|
if metadata.contains(where: { $0.name.caseInsensitiveCompare(columns[i]) == .orderedSame && $0.nullable }) && original[i] == "NULL" {
|
||||||
return "Column \(columns[i]) may contain SQL NULL. Editing ambiguous NULL values is disabled."
|
return "Column \(columns[i]) may contain SQL NULL. Editing ambiguous NULL values is disabled."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7735,10 +7768,11 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
let assignments = changedIndices.map { i in
|
let assignments = changedIndices.map { i in
|
||||||
"\(identifier(columns[i])) = \(literal(values[i]))"
|
"\(identifier(columns[i])) = \(literal(values[i]))"
|
||||||
}.joined(separator: ",\n ")
|
}.joined(separator: ",\n ")
|
||||||
// Optimistic checks prevent overwriting a value modified since the
|
// Match only the original primary key. Comparing a previous text
|
||||||
// result grid was loaded. LIMIT 1 prevents accidental mass updates.
|
// value (particularly password hashes) can trigger collation errors.
|
||||||
let checks = (primaryIndices + changedIndices).map { i in
|
// Restrict to one row even if the database metadata is stale.
|
||||||
"\(identifier(columns[i])) <=> \(literal(original[i]))"
|
let checks = primaryIndices.map { i in
|
||||||
|
"\(identifier(columns[i])) = \(literal(original[i]))"
|
||||||
}.joined(separator: " AND\n ")
|
}.joined(separator: " AND\n ")
|
||||||
return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;"
|
return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;"
|
||||||
}
|
}
|
||||||
@@ -7808,6 +7842,7 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
DBSynLaravelPasswordGenerator { hash in
|
DBSynLaravelPasswordGenerator { hash in
|
||||||
guard values.indices.contains(selection.index) else { return }
|
guard values.indices.contains(selection.index) else { return }
|
||||||
values[selection.index] = hash
|
values[selection.index] = hash
|
||||||
|
generatedPasswordProof = (selection.index, hash)
|
||||||
passwordColumnIndex = nil
|
passwordColumnIndex = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7822,6 +7857,11 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
private func apply() {
|
private func apply() {
|
||||||
guard validationError == nil, let profile = model.selected else { return }
|
guard validationError == nil, let profile = model.selected else { return }
|
||||||
let statement = generatedSQL
|
let statement = generatedSQL
|
||||||
|
let verifiedPassword = generatedPasswordProof.flatMap { proof -> (column: String, hash: String)? in
|
||||||
|
guard values.indices.contains(proof.index), values[proof.index] == proof.hash else { return nil }
|
||||||
|
return (columns[proof.index], proof.hash)
|
||||||
|
}
|
||||||
|
let checks = primaryIndices.map { i in "\(identifier(columns[i])) = \(literal(original[i]))" }.joined(separator: " AND ")
|
||||||
let password = PasswordStore.read(profile.id)
|
let password = PasswordStore.read(profile.id)
|
||||||
executing = true
|
executing = true
|
||||||
errorText = nil
|
errorText = nil
|
||||||
@@ -7829,9 +7869,19 @@ private struct DBSynInlineRowEditor: View {
|
|||||||
do {
|
do {
|
||||||
let response = try await NativeMySQLRunner.run(
|
let response = try await NativeMySQLRunner.run(
|
||||||
profile: profile, database: database, password: password, sql: statement)
|
profile: profile, database: database, password: password, sql: statement)
|
||||||
// A successful UPDATE may affect zero rows when a concurrent
|
if let verifiedPassword {
|
||||||
// change invalidates the optimistic predicate; do not claim a save.
|
let checkSQL = "SELECT \(identifier(verifiedPassword.column)) FROM \(identifier(database)).\(identifier(table)) WHERE \(checks) LIMIT 1"
|
||||||
model.status = "UPDATE completed: \(response.message). Refresh results to verify the row."
|
let saved = try await NativeMySQLRunner.run(profile: profile, database: database, password: password, sql: checkSQL)
|
||||||
|
guard let stored = saved.rows.first?.first else {
|
||||||
|
throw LaravelBcryptService.HashError.failed("Password update could not be verified: row not found.")
|
||||||
|
}
|
||||||
|
guard stored == verifiedPassword.hash else {
|
||||||
|
throw LaravelBcryptService.HashError.failed("Password update could not be verified: the stored hash differs from the generated hash.")
|
||||||
|
}
|
||||||
|
model.status = "Password hash saved and read-back verified."
|
||||||
|
} else {
|
||||||
|
model.status = "UPDATE completed: \(response.message). Refresh results to verify the row."
|
||||||
|
}
|
||||||
dismiss()
|
dismiss()
|
||||||
model.runSQL()
|
model.runSQL()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -7857,7 +7907,7 @@ private enum LaravelBcryptService {
|
|||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .noPHP:
|
case .noPHP:
|
||||||
return "PHP CLI was not found. Install PHP or specify its full executable path. Laravel bcrypt requires PHP's password_hash()."
|
return "PHP CLI was not found. Check the configured path or install ServBay/Homebrew PHP."
|
||||||
case .failed(let message): return message
|
case .failed(let message): return message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7884,16 +7934,36 @@ private enum LaravelBcryptService {
|
|||||||
return String(result.prefix(length))
|
return String(result.prefix(length))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func detectedPHPPath(manualPath: String = "") -> String? {
|
||||||
|
let fm = FileManager.default
|
||||||
|
let manual = manualPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !manual.isEmpty { return fm.isExecutableFile(atPath: manual) ? manual : nil }
|
||||||
|
var candidates: [String] = []
|
||||||
|
let base = "/Applications/ServBay/package/php"
|
||||||
|
if let versions = try? fm.contentsOfDirectory(atPath: base) {
|
||||||
|
for major in versions {
|
||||||
|
let folder = base + "/" + major
|
||||||
|
if let builds = try? fm.contentsOfDirectory(atPath: folder) {
|
||||||
|
for build in builds { candidates.append(folder + "/" + build + "/bin/php") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.sort { left, right in
|
||||||
|
let a = left.split(separator: "/").compactMap { Int($0) }
|
||||||
|
let b = right.split(separator: "/").compactMap { Int($0) }
|
||||||
|
return a.lexicographicallyPrecedes(b) == false && a != b
|
||||||
|
}
|
||||||
|
candidates += ["/Applications/ServBay/script/alias/php", "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"]
|
||||||
|
return candidates.first(where: { fm.isExecutableFile(atPath: $0) })
|
||||||
|
}
|
||||||
|
|
||||||
static func hash(_ plaintext: String, phpPath: String) throws -> String {
|
static func hash(_ plaintext: String, phpPath: String) throws -> String {
|
||||||
let candidates = [phpPath, "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"]
|
guard let executable = detectedPHPPath(manualPath: phpPath) else { throw HashError.noPHP }
|
||||||
guard let executable = candidates.first(where: {
|
|
||||||
!$0.isEmpty && $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0)
|
|
||||||
}) else { throw HashError.noPHP }
|
|
||||||
|
|
||||||
let process = Process()
|
let process = Process()
|
||||||
process.executableURL = URL(fileURLWithPath: executable)
|
process.executableURL = URL(fileURLWithPath: executable)
|
||||||
// Read the secret from stdin, never expose it in command-line arguments.
|
// Read the secret from stdin, never expose it in command-line arguments.
|
||||||
process.arguments = ["-r", "$p = stream_get_contents(STDIN); $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h) { fwrite(STDERR, 'bcrypt failed'); exit(1); } echo $h;"]
|
process.arguments = ["-r", "$p = stream_get_contents(STDIN); if ($p === false || $p === '') { fwrite(STDERR, 'Missing password'); exit(1); } $h = password_hash($p, PASSWORD_BCRYPT, ['cost' => 12]); if (!$h || !password_verify($p, $h)) { fwrite(STDERR, 'bcrypt self-verification failed'); exit(1); } echo $h;"]
|
||||||
let input = Pipe()
|
let input = Pipe()
|
||||||
let output = Pipe()
|
let output = Pipe()
|
||||||
let errors = Pipe()
|
let errors = Pipe()
|
||||||
@@ -7921,7 +7991,7 @@ private struct DBSynLaravelPasswordGenerator: View {
|
|||||||
let onUseHash: (String) -> Void
|
let onUseHash: (String) -> Void
|
||||||
@State private var plaintext = ""
|
@State private var plaintext = ""
|
||||||
@State private var hashed = ""
|
@State private var hashed = ""
|
||||||
@State private var phpPath = ""
|
@AppStorage("DBSyn.laravelPHPExecutable") private var phpPath = ""
|
||||||
@State private var showPassword = false
|
@State private var showPassword = false
|
||||||
@State private var busy = false
|
@State private var busy = false
|
||||||
@State private var errorText: String?
|
@State private var errorText: String?
|
||||||
@@ -7949,7 +8019,7 @@ private struct DBSynLaravelPasswordGenerator: View {
|
|||||||
HStack {
|
HStack {
|
||||||
TextField("PHP executable path (optional)", text: $phpPath)
|
TextField("PHP executable path (optional)", text: $phpPath)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
Text("Uses Homebrew PHP automatically when available")
|
Text(LaravelBcryptService.detectedPHPPath(manualPath: phpPath).map { "PHP: " + $0 } ?? "PHP not detected — specify an executable")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
HStack {
|
HStack {
|
||||||
@@ -7965,6 +8035,8 @@ private struct DBSynLaravelPasswordGenerator: View {
|
|||||||
if let errorText {
|
if let errorText {
|
||||||
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
|
Text(errorText).foregroundStyle(.red).textSelection(.enabled)
|
||||||
}
|
}
|
||||||
|
Text("The generated hash is verified with PHP before it can be used. After saving, DBSyn checks the stored hash matches exactly.")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Text("Copy the plaintext password before closing. It cannot be recovered from its hash.")
|
Text("Copy the plaintext password before closing. It cannot be recovered from its hash.")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
+189
-48
@@ -3,6 +3,7 @@ import CommonCrypto
|
|||||||
import CoreFoundation
|
import CoreFoundation
|
||||||
import CFNetwork
|
import CFNetwork
|
||||||
import Security
|
import Security
|
||||||
|
import Darwin
|
||||||
|
|
||||||
// Native MySQL wire protocol client. No external mysql executable is used.
|
// Native MySQL wire protocol client. No external mysql executable is used.
|
||||||
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
|
// Initial scope: TCP, mysql_native_password and caching_sha2_password fast authentication.
|
||||||
@@ -135,8 +136,14 @@ private func authResponse(password: String, salt: [UInt8], plugin: String) throw
|
|||||||
return zip(first, last).map { $0 ^ $1 }
|
return zip(first, last).map { $0 ^ $1 }
|
||||||
}
|
}
|
||||||
if plugin == "caching_sha2_password" {
|
if plugin == "caching_sha2_password" {
|
||||||
let first = sha256(pwd), second = sha256(sha256(pwd))
|
// MySQL caching_sha2_password authentication:
|
||||||
let last = sha256(second + salt)
|
// XOR(SHA256(password),
|
||||||
|
// SHA256(SHA256(SHA256(password)) + challenge))
|
||||||
|
let first = sha256(pwd)
|
||||||
|
let second = sha256(first)
|
||||||
|
let third = sha256(second)
|
||||||
|
let last = sha256(third + salt)
|
||||||
|
|
||||||
return zip(first, last).map { $0 ^ $1 }
|
return zip(first, last).map { $0 ^ $1 }
|
||||||
}
|
}
|
||||||
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
|
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
|
||||||
@@ -198,45 +205,163 @@ private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SecureTransport operates on the same socket before and after MySQL SSLRequest.
|
||||||
|
// CFStream SSL properties cannot reliably retrofit TLS onto already-open streams.
|
||||||
|
private final class MySQLSocketTransport {
|
||||||
|
let fd: Int32
|
||||||
|
private var ssl: SSLContext?
|
||||||
|
|
||||||
|
init(host: String, port: Int) throws {
|
||||||
|
var hints = addrinfo()
|
||||||
|
hints.ai_family = AF_UNSPEC
|
||||||
|
hints.ai_socktype = Int32(SOCK_STREAM)
|
||||||
|
hints.ai_protocol = Int32(IPPROTO_TCP)
|
||||||
|
var addresses: UnsafeMutablePointer<addrinfo>?
|
||||||
|
let result = getaddrinfo(host, String(port), &hints, &addresses)
|
||||||
|
guard result == 0 else { throw MySQLWireError(message: "Cannot resolve MySQL host: \(String(cString: gai_strerror(result)))") }
|
||||||
|
defer { freeaddrinfo(addresses) }
|
||||||
|
var candidate = addresses
|
||||||
|
var connected: Int32 = -1
|
||||||
|
while let item = candidate {
|
||||||
|
let info = item.pointee
|
||||||
|
let sock = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol)
|
||||||
|
if sock >= 0 {
|
||||||
|
if Darwin.connect(sock, info.ai_addr, info.ai_addrlen) == 0 {
|
||||||
|
connected = sock
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = Darwin.close(sock)
|
||||||
|
}
|
||||||
|
candidate = info.ai_next
|
||||||
|
}
|
||||||
|
guard connected >= 0 else { throw MySQLWireError(message: "Cannot connect to MySQL at \(host):\(port): \(String(cString: strerror(errno)))") }
|
||||||
|
fd = connected
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let ssl { _ = SSLClose(ssl) }
|
||||||
|
_ = Darwin.close(fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func read(_ buffer: UnsafeMutablePointer<UInt8>, count: Int) throws -> Int {
|
||||||
|
if let ssl {
|
||||||
|
var processed = 0
|
||||||
|
let status = SSLRead(ssl, buffer, count, &processed)
|
||||||
|
if status == errSecSuccess || (status == errSSLWouldBlock && processed > 0) { return processed }
|
||||||
|
throw MySQLWireError(message: "MySQL TLS read failed (OSStatus \(status))")
|
||||||
|
}
|
||||||
|
let n = Darwin.recv(fd, buffer, count, 0)
|
||||||
|
guard n > 0 else { throw MySQLWireError(message: n == 0 ? "MySQL connection closed" : "MySQL read failed: \(String(cString: strerror(errno)))") }
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(_ buffer: UnsafePointer<UInt8>, count: Int) throws -> Int {
|
||||||
|
if let ssl {
|
||||||
|
var processed = 0
|
||||||
|
let status = SSLWrite(ssl, buffer, count, &processed)
|
||||||
|
if status == errSecSuccess || (status == errSSLWouldBlock && processed > 0) { return processed }
|
||||||
|
throw MySQLWireError(message: "MySQL TLS write failed (OSStatus \(status))")
|
||||||
|
}
|
||||||
|
let n = Darwin.send(fd, buffer, count, 0)
|
||||||
|
guard n > 0 else { throw MySQLWireError(message: "MySQL write failed: \(String(cString: strerror(errno)))") }
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func startTLS(host: String, verify: Bool) throws {
|
||||||
|
NSLog("DBSyn TLS: host=%@ verify=%@", host, verify ? "ON" : "OFF")
|
||||||
|
guard let context = SSLCreateContext(nil, .clientSide, .streamType) else {
|
||||||
|
throw MySQLWireError(message: "Unable to create MySQL TLS context")
|
||||||
|
}
|
||||||
|
// SecureTransport callback connections refer to this retained transport.
|
||||||
|
let connection = Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
var status = SSLSetIOFuncs(context, { connection, data, length in
|
||||||
|
|
||||||
|
let transport = Unmanaged<MySQLSocketTransport>.fromOpaque(connection).takeUnretainedValue()
|
||||||
|
let requested = length.pointee
|
||||||
|
let n = Darwin.recv(transport.fd, data, requested, 0)
|
||||||
|
if n > 0 { length.pointee = n; return errSecSuccess }
|
||||||
|
length.pointee = 0
|
||||||
|
return n == 0 ? errSSLClosedGraceful : errSecIO
|
||||||
|
}, { connection, data, length in
|
||||||
|
|
||||||
|
let transport = Unmanaged<MySQLSocketTransport>.fromOpaque(connection).takeUnretainedValue()
|
||||||
|
let n = Darwin.send(transport.fd, data, length.pointee, 0)
|
||||||
|
if n > 0 { length.pointee = n; return errSecSuccess }
|
||||||
|
length.pointee = 0
|
||||||
|
return errSecIO
|
||||||
|
})
|
||||||
|
guard status == errSecSuccess else { throw MySQLWireError(message: "TLS I/O setup failed (\(status))") }
|
||||||
|
status = SSLSetConnection(context, connection)
|
||||||
|
guard status == errSecSuccess else { throw MySQLWireError(message: "TLS socket setup failed (\(status))") }
|
||||||
|
status = host.withCString { SSLSetPeerDomainName(context, $0, host.utf8.count) }
|
||||||
|
guard status == errSecSuccess else { throw MySQLWireError(message: "TLS peer setup failed (\(status))") }
|
||||||
|
// REQUIRED encrypts but does not authenticate the server. Keep verification
|
||||||
|
// enabled for the verified mode; never silently fall back to REQUIRED.
|
||||||
|
// Certificate verification is handled after the TLS handshake.
|
||||||
|
status = SSLSetSessionOption(context, .breakOnServerAuth, true)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw MySQLWireError(message: "TLS authentication setup failed (\(status))")
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat {
|
||||||
|
status = SSLHandshake(context)
|
||||||
|
} while status == errSSLWouldBlock
|
||||||
|
|
||||||
|
if status == OSStatus(-9841) {
|
||||||
|
if verify {
|
||||||
|
var trust: SecTrust?
|
||||||
|
let trustStatus = SSLCopyPeerTrust(context, &trust)
|
||||||
|
|
||||||
|
guard trustStatus == errSecSuccess, let trust else {
|
||||||
|
throw MySQLWireError(message: "Unable to obtain MySQL server trust")
|
||||||
|
}
|
||||||
|
|
||||||
|
var trustError: CFError?
|
||||||
|
|
||||||
|
guard SecTrustEvaluateWithError(trust, &trustError) else {
|
||||||
|
throw MySQLWireError(
|
||||||
|
message: "MySQL server verification failed (verifyTLS=\(verify)): \(trustError?.localizedDescription ?? "untrusted server")"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repeat {
|
||||||
|
status = SSLHandshake(context)
|
||||||
|
} while status == errSSLWouldBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw MySQLWireError(message: "MySQL TLS handshake failed (OSStatus \(status))")
|
||||||
|
}
|
||||||
|
|
||||||
|
ssl = context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class MySQLWireConnection {
|
final class MySQLWireConnection {
|
||||||
private let input: InputStream
|
private let transport: MySQLSocketTransport
|
||||||
private let output: OutputStream
|
|
||||||
private var sequence: UInt8 = 0
|
private var sequence: UInt8 = 0
|
||||||
private let maxPacket = 16_777_215
|
private let maxPacket = 16_777_215
|
||||||
private let serverHost: String
|
private let serverHost: String
|
||||||
private let requireTLS: Bool
|
private let requireTLS: Bool
|
||||||
|
private let verifyTLS: Bool
|
||||||
private var tlsActive = false
|
private var tlsActive = false
|
||||||
|
|
||||||
init(host: String, port: Int, requireTLS: Bool = false) throws {
|
init(host: String, port: Int, requireTLS: Bool = false, verifyTLS: Bool = true) throws {
|
||||||
self.serverHost = host
|
self.serverHost = host
|
||||||
self.requireTLS = requireTLS
|
self.requireTLS = requireTLS
|
||||||
|
self.verifyTLS = verifyTLS
|
||||||
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
|
guard (1...65535).contains(port) else { throw MySQLWireError(message: "Invalid port") }
|
||||||
var read: Unmanaged<CFReadStream>?
|
transport = try MySQLSocketTransport(host: host, port: port)
|
||||||
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] {
|
private func readExact(_ count: Int) throws -> [UInt8] {
|
||||||
var buffer = [UInt8](repeating: 0, count: count)
|
var buffer = [UInt8](repeating: 0, count: count)
|
||||||
var done = 0
|
var done = 0
|
||||||
while done < count {
|
while done < count {
|
||||||
let n = buffer.withUnsafeMutableBufferPointer { ptr -> Int in
|
let n = try buffer.withUnsafeMutableBufferPointer { ptr -> Int in
|
||||||
guard let base = ptr.baseAddress else { return -1 }
|
try transport.read(ptr.baseAddress!.advanced(by: done), count: count - done)
|
||||||
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
|
done += n
|
||||||
}
|
}
|
||||||
return buffer
|
return buffer
|
||||||
@@ -245,9 +370,7 @@ final class MySQLWireConnection {
|
|||||||
let header = try readExact(4)
|
let header = try readExact(4)
|
||||||
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
|
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
|
||||||
sequence = header[3] &+ 1
|
sequence = header[3] &+ 1
|
||||||
guard count <= maxPacket else {
|
guard count <= maxPacket else { throw MySQLWireError(message: "MySQL packet exceeds protocol limit") }
|
||||||
throw MySQLWireError(message: "MySQL packet exceeds protocol limit")
|
|
||||||
}
|
|
||||||
return try readExact(count)
|
return try readExact(count)
|
||||||
}
|
}
|
||||||
private func send(_ payload: [UInt8]) throws {
|
private func send(_ payload: [UInt8]) throws {
|
||||||
@@ -257,30 +380,14 @@ final class MySQLWireConnection {
|
|||||||
sequence = sequence &+ 1
|
sequence = sequence &+ 1
|
||||||
var written = 0
|
var written = 0
|
||||||
while written < packet.count {
|
while written < packet.count {
|
||||||
let n = packet.withUnsafeBufferPointer { ptr -> Int in
|
let n = try packet.withUnsafeBufferPointer { ptr -> Int in
|
||||||
guard let base = ptr.baseAddress else { return -1 }
|
try transport.write(ptr.baseAddress!.advanced(by: written), count: packet.count - written)
|
||||||
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
|
written += n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// MySQL TLS is negotiated AFTER the server greeting: first send CLIENT_SSL,
|
|
||||||
// then upgrade the existing TCP streams, then send the authentication packet.
|
|
||||||
// Never send credentials if TLS negotiation or certificate verification fails.
|
|
||||||
private func upgradeToTLS() throws {
|
private func upgradeToTLS() throws {
|
||||||
let settings: [String: Any] = [
|
try transport.startTLS(host: serverHost, verify: verifyTLS)
|
||||||
kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelNegotiatedSSL,
|
|
||||||
kCFStreamSSLValidatesCertificateChain as String: true,
|
|
||||||
kCFStreamSSLPeerName as String: serverHost
|
|
||||||
]
|
|
||||||
let key = Stream.PropertyKey(rawValue: kCFStreamPropertySSLSettings as String)
|
|
||||||
guard input.setProperty(settings, forKey: key),
|
|
||||||
output.setProperty(settings, forKey: key) else {
|
|
||||||
throw MySQLWireError(message: "TLS stream upgrade unavailable; no credentials sent. Use SSH tunnelling for this server.")
|
|
||||||
}
|
|
||||||
// CFStream performs the TLS handshake before delivering encrypted data.
|
|
||||||
// Writing authentication triggers the handshake; any failure aborts it.
|
|
||||||
tlsActive = true
|
tlsActive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +414,21 @@ final class MySQLWireConnection {
|
|||||||
_ = try c.take(10)
|
_ = try c.take(10)
|
||||||
let salt2 = try c.take(max(13, authLen - 8))
|
let salt2 = try c.take(max(13, authLen - 8))
|
||||||
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
|
let salt = salt1 + salt2.prefix(max(0, authLen - 9))
|
||||||
|
|
||||||
|
// MySQL authentication uses the 20-byte server challenge.
|
||||||
|
guard salt.count == 20 else {
|
||||||
|
throw MySQLWireError(
|
||||||
|
message: "Invalid MySQL authentication challenge length: \(salt.count)"
|
||||||
|
)
|
||||||
|
}
|
||||||
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
let plugin = (try? c.nulString()) ?? "mysql_native_password"
|
||||||
|
|
||||||
|
NSLog(
|
||||||
|
"DBSyn MySQL authentication: plugin=%@ saltLength=%d tlsRequired=%@",
|
||||||
|
plugin,
|
||||||
|
salt.count,
|
||||||
|
requireTLS ? "YES" : "NO"
|
||||||
|
)
|
||||||
if requireTLS {
|
if requireTLS {
|
||||||
let clientSSL = 0x00000800
|
let clientSSL = 0x00000800
|
||||||
guard capabilities & clientSSL != 0 else {
|
guard capabilities & clientSSL != 0 else {
|
||||||
@@ -329,6 +450,13 @@ final class MySQLWireConnection {
|
|||||||
body += Array(plugin.utf8) + [0]
|
body += Array(plugin.utf8) + [0]
|
||||||
try send(body)
|
try send(body)
|
||||||
var reply = try readPacket()
|
var reply = try readPacket()
|
||||||
|
|
||||||
|
NSLog(
|
||||||
|
"DBSyn MySQL initial authentication response: type=0x%02X length=%d",
|
||||||
|
Int(reply.first ?? 0),
|
||||||
|
reply.count
|
||||||
|
)
|
||||||
|
|
||||||
try checkError(reply)
|
try checkError(reply)
|
||||||
var currentPlugin = plugin
|
var currentPlugin = plugin
|
||||||
var currentSalt = Array(salt)
|
var currentSalt = Array(salt)
|
||||||
@@ -347,6 +475,12 @@ final class MySQLWireConnection {
|
|||||||
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
|
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
|
||||||
throw MySQLWireError(message: "Unsupported authentication continuation")
|
throw MySQLWireError(message: "Unsupported authentication continuation")
|
||||||
}
|
}
|
||||||
|
NSLog(
|
||||||
|
"DBSyn MySQL authentication continuation: code=0x%02X TLS=%@",
|
||||||
|
Int(reply[1]),
|
||||||
|
tlsActive ? "YES" : "NO"
|
||||||
|
)
|
||||||
|
|
||||||
switch reply[1] {
|
switch reply[1] {
|
||||||
case 0x03: // Fast authentication accepted; wait for OK.
|
case 0x03: // Fast authentication accepted; wait for OK.
|
||||||
reply = try readPacket()
|
reply = try readPacket()
|
||||||
@@ -382,6 +516,12 @@ final class MySQLWireConnection {
|
|||||||
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
|
throw MySQLWireError(message: "Unsupported caching_sha2_password response: \(reply[1])")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
NSLog(
|
||||||
|
"DBSyn MySQL final authentication response: type=0x%02X length=%d",
|
||||||
|
Int(reply.first ?? 0),
|
||||||
|
reply.count
|
||||||
|
)
|
||||||
|
|
||||||
try checkError(reply)
|
try checkError(reply)
|
||||||
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
|
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
|
||||||
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
|
if !database.isEmpty { _ = try query("USE `\(database.replacingOccurrences(of: "`", with: "``"))`") }
|
||||||
@@ -509,8 +649,9 @@ private final class PersistentMySQLSessions {
|
|||||||
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
|
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
|
||||||
let connection = try MySQLWireConnection(
|
let connection = try MySQLWireConnection(
|
||||||
host: endpoint.0, port: endpoint.1,
|
host: endpoint.0, port: endpoint.1,
|
||||||
requireTLS: !profile.useSSHTunnel && profile.useTLS &&
|
requireTLS: !profile.useSSHTunnel &&
|
||||||
!["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased())
|
!["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased()),
|
||||||
|
verifyTLS: profile.verifyTLS
|
||||||
)
|
)
|
||||||
connection.markHost(endpoint.0)
|
connection.markHost(endpoint.0)
|
||||||
try connection.authenticate(user: profile.username,
|
try connection.authenticate(user: profile.username,
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# DBSyn 1.0.4 (Build 77)
|
||||||
|
|
||||||
|
- Automatically discovers ServBay PHP installations, preferring the highest installed PHP version.
|
||||||
|
- Falls back to ServBay alias, Homebrew and standard PHP paths.
|
||||||
|
- Remembers an optional manually specified PHP executable between launches.
|
||||||
|
- Shows the resolved executable in the Laravel Password Generator.
|
||||||
|
- Retains stdin-based bcrypt generation and the existing Use Hash in Row workflow.
|
||||||
|
|
||||||
|
Source patch only; Xcode build and macOS runtime validation have not been performed.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# DBSyn 1.0.5 — Build 78
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
- Fixed MySQL `Illegal mix of collations` when applying edits to text fields, including Laravel bcrypt password hashes.
|
||||||
|
- Row-update optimistic concurrency predicates now compare original values as binary strings to avoid connection/column collation conflicts.
|
||||||
|
- Preserved original primary-key matching, `LIMIT 1`, SQL preview, and the existing Laravel password generator.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Source patched and SQL predicate reviewed. macOS/Xcode compilation and live MySQL integration tests have not been performed here.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# DBSyn 1.0.6 (Build 79)
|
||||||
|
|
||||||
|
- Restore ServBay PHP auto-discovery (including versioned PHP binaries).
|
||||||
|
- Display detected PHP executable and remember manual override.
|
||||||
|
- Retain row editing and binary-safe optimistic comparisons from Build 78.
|
||||||
|
|
||||||
|
Source inspection performed; macOS Xcode build and database integration tests not run.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# DBSyn 1.0.7 — Build 80
|
||||||
|
|
||||||
|
- Row UPDATE SQL now matches rows using original primary-key columns only, removing the previous password comparison and its collation conflict.
|
||||||
|
- Ordinary text values are shown as readable quoted SQL literals with escaped single quotes; control characters and backslashes retain hexadecimal encoding for SQL-mode safety.
|
||||||
|
- Preserves Laravel bcrypt generator and ServBay PHP detection from Build 79.
|
||||||
|
|
||||||
|
**Caution:** Primary-key-only updates do not detect concurrent edits to non-key columns. Check the SQL preview before applying.
|
||||||
|
|
||||||
|
**Verification:** Static source inspection only. macOS Xcode compilation and live MySQL execution have not been performed.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# DBSyn 1.0.8 (Build 81)
|
||||||
|
|
||||||
|
- Generate Laravel-compatible PHP bcrypt (cost 12) using stdin and verify with `password_verify` before returning a hash.
|
||||||
|
- After saving a generated hash, query MySQL for the saved value and compare it byte-for-byte with the generated hash.
|
||||||
|
- Report errors if the row is missing or the stored hash differs.
|
||||||
|
- Manual changes to the password field after generation invalidate read-back verification for that value.
|
||||||
|
- Version/build metadata updated.
|
||||||
|
|
||||||
|
**Validation:** Static source review only; macOS Xcode compilation and database integration tests were not available in this environment.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# DBSyn 1.1.0 (Build 82)
|
||||||
|
|
||||||
|
## Connection security
|
||||||
|
- Added a per-connection **Verify server identity** option. Enabled by default, including for existing profiles.
|
||||||
|
- When disabled, direct remote connections still negotiate encrypted MySQL TLS, equivalent to `--ssl-mode=REQUIRED`, without validating the server identity.
|
||||||
|
- SSH tunnel behaviour is unchanged.
|
||||||
|
- The connection form warns when identity verification is disabled.
|
||||||
|
|
||||||
|
**Security:** Unverified TLS protects against passive interception but not an active impersonation attack. Use verified TLS whenever possible.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
Source changes inspected; macOS/Xcode build and live database connection testing must be performed on a Mac.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# DBSyn 1.1.1 (Build 83)
|
||||||
|
|
||||||
|
- Replace CFStream's post-open TLS property upgrade with a socket-backed SecureTransport handshake after MySQL SSLRequest.
|
||||||
|
- Preserve verified TLS and explicitly selected encrypted-only TLS modes.
|
||||||
|
- Do not change SSH tunnelling, saved connections, or database operations.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
This source patch has not been compiled on macOS or tested against Venus. Build and test in Xcode before release. SecureTransport is a deprecated Apple API and should be migrated to a maintained TLS implementation in a later release.
|
||||||
Reference in New Issue
Block a user