Author SHA1 Message Date
Mark Gregory c27210f4f4 DBSyn 1.1.0 Build 82 - MySQL connection improvements 2026-10-21 09:45:20 +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
10 changed files with 195 additions and 63 deletions
+4 -4
View File
@@ -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.0;
CURRENT_PROJECT_VERSION = 73; CURRENT_PROJECT_VERSION = 82;
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.0;
CURRENT_PROJECT_VERSION = 73; CURRENT_PROJECT_VERSION = 82;
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.";
+5 -3
View File
@@ -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) ?? ""
+123 -51
View File
@@ -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 {
+8 -5
View File
@@ -205,11 +205,13 @@ final class MySQLWireConnection {
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>? var read: Unmanaged<CFReadStream>?
var write: Unmanaged<CFWriteStream>? var write: Unmanaged<CFWriteStream>?
@@ -271,8 +273,8 @@ final class MySQLWireConnection {
private func upgradeToTLS() throws { private func upgradeToTLS() throws {
let settings: [String: Any] = [ let settings: [String: Any] = [
kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelNegotiatedSSL, kCFStreamSSLLevel as String: kCFStreamSocketSecurityLevelNegotiatedSSL,
kCFStreamSSLValidatesCertificateChain as String: true, kCFStreamSSLValidatesCertificateChain as String: verifyTLS,
kCFStreamSSLPeerName as String: serverHost kCFStreamSSLPeerName as String: verifyTLS ? serverHost : kCFNull as Any
] ]
let key = Stream.PropertyKey(rawValue: kCFStreamPropertySSLSettings as String) let key = Stream.PropertyKey(rawValue: kCFStreamPropertySSLSettings as String)
guard input.setProperty(settings, forKey: key), guard input.setProperty(settings, forKey: key),
@@ -509,8 +511,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.
+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.