new tests

This commit is contained in:
Mark Gregory
2026-10-19 10:40:04 +01:00
parent f46f598baf
commit 3307bd90e4
4 changed files with 128 additions and 52 deletions
+32
View File
@@ -9,6 +9,32 @@ struct ConnectionProfile: Identifiable, Codable, Hashable {
var port = 3306
var username = "root"
var database = ""
// Optional SSH transport. Existing saved profiles decode with these defaults.
var useSSHTunnel = false
var sshHost = ""
var sshPort = 22
var sshUser = ""
var sshIdentityFile = ""
enum CodingKeys: String, CodingKey { case id, name, host, port, username, database, useSSHTunnel, 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, 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.sshHost = sshHost; self.sshPort = sshPort; self.sshUser = sshUser; self.sshIdentityFile = sshIdentityFile
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
host = try c.decode(String.self, forKey: .host)
port = try c.decode(Int.self, forKey: .port)
username = try c.decode(String.self, forKey: .username)
database = try c.decode(String.self, forKey: .database)
useSSHTunnel = try c.decodeIfPresent(Bool.self, forKey: .useSSHTunnel) ?? false
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) ?? ""
sshIdentityFile = try c.decodeIfPresent(String.self, forKey: .sshIdentityFile) ?? ""
}
}
struct QueryResult {
var columns: [String] = []
@@ -651,7 +677,10 @@ final class DatabaseModel: ObservableObject {
func selectConnection(_ id: UUID) {
guard let profile = profiles.first(where: { $0.id == id }) else { return }
guard selectedID != id else { return }
saveActiveQuery()
if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) }
result = QueryResult()
selectedID = id
UserDefaults.standard.set(id.uuidString, forKey: "DBSyn.selectedConnectionID")
selectedDatabase = profile.database
@@ -673,10 +702,12 @@ final class DatabaseModel: ObservableObject {
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
NativeMySQLRunner.disconnect(profileID: profile.id)
selectedID = profile.id
selectedDatabase = profile.database
UserDefaults.standard.set(profile.id.uuidString, forKey: "DBSyn.selectedConnectionID")
persist()
loadDatabases()
}
private func persist() {
if let data = try? JSONEncoder().encode(profiles) { UserDefaults.standard.set(data, forKey: storageKey) }
@@ -854,6 +885,7 @@ final class DatabaseModel: ObservableObject {
introspect(database: usedSchema)
}
}
guard selectedID == profile.id else { isRunning = false; return }
if listingDatabases {
databases = lastResult.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
+39 -50
View File
@@ -2483,81 +2483,70 @@ private struct WorkbenchExplainView: View {
struct ConnectionEditor: View {
@Binding var profile: ConnectionProfile
@Binding var password: String
let save: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var testMessage = ""
@State private var testing = false
var body: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("Connection Settings").font(.title2.bold())
Label("Connection Settings", systemImage: "server.rack").font(.title2.bold())
Spacer()
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.borderless)
.help("Close")
.buttonStyle(.plain).help("Close")
}
Form {
TextField(
"Connection Name",
text: $profile.name
)
TextField(
"Hostname",
text: $profile.host
)
TextField("Port", value: $profile.port, format: .number.grouping(.never))
TextField(
"Username",
text: $profile.username
)
SecureField(
"Password (macOS Keychain)",
text: $password
)
TextField("Default Schema", text: $profile.database)
Text("Remote MySQL requires a trusted SSH tunnel until native TLS support is available.")
.font(.caption).foregroundStyle(.secondary)
Section("MySQL") {
TextField("Connection Name", text: $profile.name)
TextField("Hostname", text: $profile.host)
TextField("Port", value: $profile.port, format: .number.grouping(.never))
TextField("Username", text: $profile.username)
SecureField("Password (Keychain)", text: $password)
TextField("Default Schema", text: $profile.database)
}
Section("Secure Transport") {
Toggle("Connect through SSH tunnel", isOn: $profile.useSSHTunnel)
if profile.useSSHTunnel {
TextField("SSH Host", text: $profile.sshHost)
TextField("SSH Port", value: $profile.sshPort, format: .number.grouping(.never))
TextField("SSH Username", text: $profile.sshUser)
TextField("SSH Private Key (optional)", text: $profile.sshIdentityFile)
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 requires TLS, which DBSyn does not yet implement. Enable SSH for remote servers.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
if !testMessage.isEmpty {
Text(testMessage).font(.caption).foregroundStyle(.secondary)
Text(testMessage).font(.callout).foregroundStyle(testMessage.hasPrefix("Connected") ? .green : .red)
.textSelection(.enabled)
}
HStack {
Button("Test Connection") {
Button(testing ? "Testing…" : "Test Connection") {
testing = true
testMessage = ""
let candidate = profile
let secret = password
Task {
defer { testing = false }
do {
let result = try await NativeMySQLRunner.run(
profile: candidate, database: candidate.database,
password: secret, sql: "SELECT VERSION()")
testMessage = "Connection successful: " + (result.rows.first?.first ?? "MySQL")
let result = try await NativeMySQLRunner.run(profile: candidate, database: candidate.database, password: secret, sql: "SELECT VERSION()")
testMessage = "Connected: " + (result.rows.first?.first ?? "MySQL")
} catch { testMessage = error.localizedDescription }
NativeMySQLRunner.disconnect(profileID: candidate.id)
}
}
}.disabled(testing)
Spacer()
Button("Cancel") {
dismiss()
}
Button("Save") {
save()
}
.buttonStyle(.borderedProminent)
Button("Cancel") { dismiss() }
Button("Save") { save() }.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(width: 520)
.frame(minWidth: 720, idealWidth: 780, minHeight: 660)
}
}
+47 -2
View File
@@ -10,6 +10,49 @@ struct MySQLWireError: LocalizedError {
var errorDescription: String? { message }
}
// SSH transport uses the system OpenSSH client and key-based authentication.
// No remote MySQL password is passed to ssh. SSH host-key verification remains enabled.
private final class DBSynSSHTunnels {
static let shared = DBSynSSHTunnels()
private let lock = NSLock()
private var running: [UUID: (Process, ConnectionProfile, Int)] = [:]
func endpoint(for profile: ConnectionProfile) throws -> (String, Int) {
guard profile.useSSHTunnel else { return (profile.host, profile.port) }
lock.lock(); defer { lock.unlock() }
if let current = running[profile.id] {
if current.0.isRunning && current.1 == profile { return ("127.0.0.1", current.2) }
if current.0.isRunning { current.0.terminate() }
running.removeValue(forKey: profile.id)
}
guard !profile.sshHost.isEmpty, !profile.sshUser.isEmpty else {
throw MySQLWireError(message: "Configure SSH hostname and SSH username in Connection Settings")
}
// A stable local port per connection; ExitOnForwardFailure rejects conflicts.
let bytes = Array(profile.id.uuidString.utf8)
let checksum = bytes.reduce(0) { ($0 &* 31 &+ Int($1)) % 10000 }
let localPort = 45000 + checksum
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
var args = ["-N", "-T", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=yes", "-p", String(profile.sshPort)]
if !profile.sshIdentityFile.isEmpty { args += ["-i", (profile.sshIdentityFile as NSString).expandingTildeInPath] }
args += ["-L", "127.0.0.1:\(localPort):\(profile.host):\(profile.port)", "\(profile.sshUser)@\(profile.sshHost)"]
process.arguments = args
process.standardInput = FileHandle.nullDevice
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do { try process.run() } catch { throw MySQLWireError(message: "Cannot launch SSH: \(error.localizedDescription)") }
Thread.sleep(forTimeInterval: 0.6)
guard process.isRunning else { throw MySQLWireError(message: "SSH tunnel failed. Check SSH key, known_hosts, target and port \(localPort). Try ssh manually for details.") }
running[profile.id] = (process, profile, localPort)
return ("127.0.0.1", localPort)
}
func stop(_ id: UUID) {
lock.lock(); defer { lock.unlock() }
if let existing = running.removeValue(forKey: id), existing.0.isRunning { existing.0.terminate() }
}
}
private struct ByteCursor {
let bytes: [UInt8]
var offset = 0
@@ -371,8 +414,9 @@ private final class PersistentMySQLSessions {
}
if connections[profile.id] == nil {
let connection = try MySQLWireConnection(host: profile.host, port: profile.port)
connection.markHost(profile.host)
let endpoint = try DBSynSSHTunnels.shared.endpoint(for: profile)
let connection = try MySQLWireConnection(host: endpoint.0, port: endpoint.1)
connection.markHost(endpoint.0)
try connection.authenticate(user: profile.username,
password: password, database: database)
connections[profile.id] = Entry(profile: profile,
@@ -415,6 +459,7 @@ private final class PersistentMySQLSessions {
func disconnect(profileID: UUID) {
queue.sync { _ = connections.removeValue(forKey: profileID) }
DBSynSSHTunnels.shared.stop(profileID)
}
}
+10
View File
@@ -0,0 +1,10 @@
# DBSyn Phase 74 — Connection repair
- Existing connection profiles decode without losing saved credentials.
- Selecting another connection closes the previous MySQL session, clears stale results, and reloads schemas.
- Editing and saving a profile disconnects its existing session and reloads databases.
- Connection editor now includes integrated SSH forwarding using system OpenSSH and verified known_hosts.
- Enable SSH and enter SSH host, SSH user, and optionally an identity file. MySQL host/port refer to the endpoint accessible *from the SSH server* (usually 127.0.0.1:3306).
- SSH is key-based / agent-based (BatchMode=yes); password-based SSH authentication is not implemented.
- Native direct MySQL TLS remains unimplemented. Direct remote connections remain blocked intentionally.
- Tests and Xcode compilation must be run on macOS; Linux Swift syntax parse is not a macOS build.