11 Commits
Author SHA1 Message Date
Mark Gregory face625f23 row editing 2026-10-21 11:43:43 +01:00
Mark Gregory 1d8e8702a8 row editing 2026-10-21 10:39:23 +01:00
Mark Gregory 2aaeff3774 row editing 2026-10-21 10:30:18 +01:00
Mark Gregory 3f9f572434 row editing 2026-10-21 10:21:27 +01:00
Mark Gregory 2229238e52 row editing 2026-10-21 10:15:06 +01:00
Mark Gregory 60cbf790db row editing 2026-10-21 09:47:53 +01:00
Mark Gregory 0a4cca2b41 row editing 2026-10-20 09:36:41 +01:00
Mark Gregory 233ff2bf64 row editing 2026-10-20 09:22:16 +01:00
Mark Gregory 86d7ae49fb row editing 2026-10-20 09:19:57 +01:00
Mark Gregory 6768ef5fff row editing 2026-10-20 09:14:25 +01:00
Mark Gregory 499dd49ec2 connection updates 2026-10-20 09:04:25 +01:00
11 changed files with 427 additions and 112 deletions
+4 -4
View File
@@ -221,8 +221,8 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.0;
CURRENT_PROJECT_VERSION = 73;
MARKETING_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 83;
ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
@@ -244,8 +244,8 @@
ENABLE_APP_SANDBOX = NO;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "DBSyn";
MARKETING_VERSION = 1.0.0;
CURRENT_PROJECT_VERSION = 73;
MARKETING_VERSION = 1.1.1;
CURRENT_PROJECT_VERSION = 83;
ENABLE_TESTABILITY = YES;
ONLY_ACTIVE_ARCH = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 ABSDEV. All rights reserved.";
+47 -9
View File
@@ -12,15 +12,16 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
// Optional SSH transport. Existing saved profiles decode with these defaults.
var useSSHTunnel = false
var useTLS = true
var verifyTLS = true
var sshHost = ""
var sshPort = 22
var sshUser = ""
var sshIdentityFile = ""
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, useTLS, 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 = "") {
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, 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.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 {
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)
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
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) ?? ""
sshPort = try c.decodeIfPresent(Int.self, forKey: .sshPort) ?? 22
sshUser = try c.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
@@ -72,17 +74,53 @@ enum PasswordStore {
return ""
}
static func save(_ password: String, id: UUID) {
@discardableResult
static func save(_ password: String, id: UUID) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
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)
if password.isEmpty {
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
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) {
+123 -51
View File
@@ -1119,13 +1119,13 @@ struct MainView: View {
Spacer()
Button {
editResultRow = selectedResultRow
if let row = selectedResultRow { openResultRowEditor(row) }
} label: {
Label("Edit Row…", systemImage: "pencil")
}
.disabled(selectedResultRow == nil || editableResultTarget == nil)
.help(editableResultTarget == nil
? "Editing requires a SELECT * from a single base table with a primary key and loaded introspection metadata."
.disabled(selectedResultRow == nil || parsedResultTarget == nil)
.help(parsedResultTarget == nil
? "Editing requires a single-table SELECT containing its primary key."
: selectedResultRow == nil ? "Select a result row first." : "Edit selected row and preview SQL")
if !model.result.columns.isEmpty {
@@ -1197,9 +1197,9 @@ struct MainView: View {
}
.sheet(isPresented: Binding(
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,
table: target.table, columns: model.result.columns, original: row)
}
@@ -1220,39 +1220,57 @@ struct MainView: View {
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
selectedResultRow = row
if editableResultTarget != nil { editResultRow = row }
openResultRowEditor(row)
})
}
// Editing is restricted to a simple SELECT * from a single base table.
// Arbitrary joins, expressions and aliases cannot be mapped safely to rows.
private var editableResultTarget: (database: String, table: String)? {
@State private var editingResultTarget: (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: ";")
guard let select = statements.last(where: { $0.range(of: #"(?i)\bSELECT\b"#, options: .regularExpression) != nil }) else { return nil }
// Permit ordinary filtered/sorted single-table SELECT * results. The
// primary-key checks in DBSynInlineRowEditor still guard every UPDATE.
// 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|$)(.*)$"#
guard let select = statements.last(where: {
$0.range(of: #"(?i)^\s*SELECT\b"#, options: .regularExpression) != nil
}) else { return nil }
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),
let match = regex.firstMatch(in: select, range: NSRange(select.startIndex..., in: select)),
let range = Range(match.range(at: 1), in: select) else { return nil }
guard let tailRange = Range(match.range(at: 2), in: select) else { return nil }
let projectionRange = Range(match.range(at: 1), in: select),
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)
// Only permit WHERE / ORDER BY / LIMIT clauses; do not treat joins,
// unions, grouped rows, or derived expressions as directly editable.
if !tail.isEmpty {
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.contains(";") else { return 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 }
}
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 table = parts.last ?? ""
guard !database.isEmpty, let snapshot = model.introspection(for: database),
snapshot.tables.contains(where: { $0.caseInsensitiveCompare(table) == .orderedSame }),
let fields = snapshot.columnsByTable[table],
fields.map(\.name).map({ $0.lowercased() }) == model.result.columns.map({ $0.lowercased() }),
fields.contains(where: { $0.key == "PRI" }) else { return nil }
guard !database.isEmpty, let table = parts.last, !model.result.columns.isEmpty else { return nil }
let names = model.result.columns.map { $0.lowercased() }
guard Set(names).count == names.count else { return nil }
if projection != "*" {
let projected = projection.components(separatedBy: ",").map {
$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)
}
@@ -2532,8 +2550,12 @@ struct ConnectionEditor: View {
TextField("Default Schema", text: $profile.database)
}
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)
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)
if profile.useSSHTunnel {
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.")
.font(.caption).foregroundStyle(.secondary)
} 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)
}
}
@@ -2637,6 +2659,8 @@ struct NativeResultGrid: NSViewRepresentable {
table.menu = menu
table.delegate = context.coordinator
table.target = context.coordinator
table.doubleAction = #selector(Coordinator.doubleClickedRow(_:))
table.dataSource = context.coordinator
configureColumns(table, hiddenIndices: hiddenColumnIndices)
@@ -2713,6 +2737,11 @@ struct NativeResultGrid: NSViewRepresentable {
var onEdit: (([String]) -> Void)?
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) {
guard let table = tableView else { return }
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 errorText: String?
@State private var passwordColumnIndex: Int?
@State private var generatedPasswordProof: (index: Int, hash: String)?
init(model: DatabaseModel, database: String, table: String,
columns: [String], original: [String]) {
@@ -7690,7 +7720,7 @@ private struct DBSynInlineRowEditor: View {
}
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] {
@@ -7708,14 +7738,17 @@ private struct DBSynInlineRowEditor: View {
}
private func literal(_ value: String) -> String {
// MySQL backslash escapes are SQL-mode-dependent. Hexadecimal UTF-8
// literals avoid SQL injection and preserve quote/backslash characters.
"CONVERT(X'" + Data(value.utf8).map { String(format: "%02X", $0) }.joined() + "' USING utf8mb4)"
// Human-readable SQL for ordinary values. Fall back to UTF-8 hex
// for control characters or backslashes (SQL mode dependent).
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? {
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 !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." }
@@ -7723,7 +7756,7 @@ private struct DBSynInlineRowEditor: View {
// "NULL". Never generate SQL when this representation is ambiguous.
let checked = Set(primaryIndices + changedIndices)
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."
}
}
@@ -7735,10 +7768,11 @@ private struct DBSynInlineRowEditor: View {
let assignments = changedIndices.map { i in
"\(identifier(columns[i])) = \(literal(values[i]))"
}.joined(separator: ",\n ")
// Optimistic checks prevent overwriting a value modified since the
// result grid was loaded. LIMIT 1 prevents accidental mass updates.
let checks = (primaryIndices + changedIndices).map { i in
"\(identifier(columns[i])) <=> \(literal(original[i]))"
// Match only the original primary key. Comparing a previous text
// value (particularly password hashes) can trigger collation errors.
// Restrict to one row even if the database metadata is stale.
let checks = primaryIndices.map { i in
"\(identifier(columns[i])) = \(literal(original[i]))"
}.joined(separator: " AND\n ")
return "UPDATE \(identifier(database)).\(identifier(table))\nSET \(assignments)\nWHERE \(checks)\nLIMIT 1;"
}
@@ -7808,6 +7842,7 @@ private struct DBSynInlineRowEditor: View {
DBSynLaravelPasswordGenerator { hash in
guard values.indices.contains(selection.index) else { return }
values[selection.index] = hash
generatedPasswordProof = (selection.index, hash)
passwordColumnIndex = nil
}
}
@@ -7822,6 +7857,11 @@ private struct DBSynInlineRowEditor: View {
private func apply() {
guard validationError == nil, let profile = model.selected else { return }
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)
executing = true
errorText = nil
@@ -7829,9 +7869,19 @@ private struct DBSynInlineRowEditor: View {
do {
let response = try await NativeMySQLRunner.run(
profile: profile, database: database, password: password, sql: statement)
// A successful UPDATE may affect zero rows when a concurrent
// change invalidates the optimistic predicate; do not claim a save.
model.status = "UPDATE completed: \(response.message). Refresh results to verify the row."
if let verifiedPassword {
let checkSQL = "SELECT \(identifier(verifiedPassword.column)) FROM \(identifier(database)).\(identifier(table)) WHERE \(checks) LIMIT 1"
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()
model.runSQL()
} catch {
@@ -7857,7 +7907,7 @@ private enum LaravelBcryptService {
var errorDescription: String? {
switch self {
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
}
}
@@ -7884,16 +7934,36 @@ private enum LaravelBcryptService {
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 {
let candidates = [phpPath, "/opt/homebrew/bin/php", "/usr/local/bin/php", "/usr/bin/php"]
guard let executable = candidates.first(where: {
!$0.isEmpty && $0.hasPrefix("/") && FileManager.default.isExecutableFile(atPath: $0)
}) else { throw HashError.noPHP }
guard let executable = detectedPHPPath(manualPath: phpPath) else { throw HashError.noPHP }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
// 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 output = Pipe()
let errors = Pipe()
@@ -7921,7 +7991,7 @@ private struct DBSynLaravelPasswordGenerator: View {
let onUseHash: (String) -> Void
@State private var plaintext = ""
@State private var hashed = ""
@State private var phpPath = ""
@AppStorage("DBSyn.laravelPHPExecutable") private var phpPath = ""
@State private var showPassword = false
@State private var busy = false
@State private var errorText: String?
@@ -7949,7 +8019,7 @@ private struct DBSynLaravelPasswordGenerator: View {
HStack {
TextField("PHP executable path (optional)", text: $phpPath)
.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)
}
HStack {
@@ -7965,6 +8035,8 @@ private struct DBSynLaravelPasswordGenerator: View {
if let errorText {
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.")
.font(.caption).foregroundStyle(.secondary)
HStack {
+189 -48
View File
@@ -3,6 +3,7 @@ import CommonCrypto
import CoreFoundation
import CFNetwork
import Security
import Darwin
// Native MySQL wire protocol client. No external mysql executable is used.
// 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 }
}
if plugin == "caching_sha2_password" {
let first = sha256(pwd), second = sha256(sha256(pwd))
let last = sha256(second + salt)
// MySQL caching_sha2_password authentication:
// 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 }
}
throw MySQLWireError(message: "Authentication plugin \(plugin) is not yet supported")
@@ -198,45 +205,163 @@ private func mysqlRSAPublicKey(_ packet: [UInt8]) throws -> SecKey {
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 {
private let input: InputStream
private let output: OutputStream
private let transport: MySQLSocketTransport
private var sequence: UInt8 = 0
private let maxPacket = 16_777_215
private let serverHost: String
private let requireTLS: Bool
private let verifyTLS: Bool
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.requireTLS = requireTLS
self.verifyTLS = verifyTLS
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")
}
transport = try MySQLSocketTransport(host: host, port: port)
}
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)
let n = try buffer.withUnsafeMutableBufferPointer { ptr -> Int in
try transport.read(ptr.baseAddress!.advanced(by: done), count: count - done)
}
guard n > 0 else { throw MySQLWireError(message: input.streamError?.localizedDescription ?? "MySQL connection closed") }
done += n
}
return buffer
@@ -245,9 +370,7 @@ final class MySQLWireConnection {
let header = try readExact(4)
let count = Int(header[0]) | Int(header[1]) << 8 | Int(header[2]) << 16
sequence = header[3] &+ 1
guard count <= maxPacket else {
throw MySQLWireError(message: "MySQL packet exceeds protocol limit")
}
guard count <= maxPacket else { throw MySQLWireError(message: "MySQL packet exceeds protocol limit") }
return try readExact(count)
}
private func send(_ payload: [UInt8]) throws {
@@ -257,30 +380,14 @@ final class MySQLWireConnection {
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)
let n = try packet.withUnsafeBufferPointer { ptr -> Int in
try transport.write(ptr.baseAddress!.advanced(by: written), count: packet.count - written)
}
guard n > 0 else { throw MySQLWireError(message: output.streamError?.localizedDescription ?? "MySQL write failed") }
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 {
let settings: [String: Any] = [
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.
try transport.startTLS(host: serverHost, verify: verifyTLS)
tlsActive = true
}
@@ -307,7 +414,21 @@ final class MySQLWireConnection {
_ = try c.take(10)
let salt2 = try c.take(max(13, authLen - 8))
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"
NSLog(
"DBSyn MySQL authentication: plugin=%@ saltLength=%d tlsRequired=%@",
plugin,
salt.count,
requireTLS ? "YES" : "NO"
)
if requireTLS {
let clientSSL = 0x00000800
guard capabilities & clientSSL != 0 else {
@@ -329,6 +450,13 @@ final class MySQLWireConnection {
body += Array(plugin.utf8) + [0]
try send(body)
var reply = try readPacket()
NSLog(
"DBSyn MySQL initial authentication response: type=0x%02X length=%d",
Int(reply.first ?? 0),
reply.count
)
try checkError(reply)
var currentPlugin = plugin
var currentSalt = Array(salt)
@@ -347,6 +475,12 @@ final class MySQLWireConnection {
guard currentPlugin == "caching_sha2_password", reply.count >= 2 else {
throw MySQLWireError(message: "Unsupported authentication continuation")
}
NSLog(
"DBSyn MySQL authentication continuation: code=0x%02X TLS=%@",
Int(reply[1]),
tlsActive ? "YES" : "NO"
)
switch reply[1] {
case 0x03: // Fast authentication accepted; wait for OK.
reply = try readPacket()
@@ -382,6 +516,12 @@ final class MySQLWireConnection {
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)
guard reply.first == 0x00 else { throw MySQLWireError(message: "Authentication did not complete") }
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 connection = try MySQLWireConnection(
host: endpoint.0, port: endpoint.1,
requireTLS: !profile.useSSHTunnel && profile.useTLS &&
!["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased())
requireTLS: !profile.useSSHTunnel &&
!["127.0.0.1", "localhost", "::1"].contains(endpoint.0.lowercased()),
verifyTLS: profile.verifyTLS
)
connection.markHost(endpoint.0)
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.
+12
View File
@@ -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.
+9
View File
@@ -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.