connection updates

This commit is contained in:
Mark Gregory
2026-10-19 12:17:32 +01:00
parent 7cf013a783
commit 69c8d2c4db
3 changed files with 78 additions and 6 deletions
+19 -6
View File
@@ -362,6 +362,8 @@ final class DatabaseModel: ObservableObject {
}
@Published var selectedID: UUID?
// Invalidate in-flight UI updates when a connection is switched or edited.
private var connectionEpoch: UInt64 = 0
@Published var queryTabs: [WorkbenchQueryTab] = [
WorkbenchQueryTab(
title: "Query 1",
@@ -641,7 +643,7 @@ final class DatabaseModel: ObservableObject {
activateQuery(tab.id)
recordRecentSQLScript(url)
NotificationCenter.default.post(name: .workbenchRevealSQLEditor, object: nil)
status = "Opened \\(url.lastPathComponent)"
status = "Opened \(url.lastPathComponent)"
}
func saveSQLScript(asNewFile: Bool = false) {
@@ -669,9 +671,9 @@ final class DatabaseModel: ObservableObject {
queryTabs[index].fileURL = url
queryTabs[index].title = url.lastPathComponent
recordRecentSQLScript(url)
status = "Saved \\(url.lastPathComponent)"
status = "Saved \(url.lastPathComponent)"
} catch {
status = "Save failed: \\(error.localizedDescription)"
status = "Save failed: \(error.localizedDescription)"
}
}
@@ -681,6 +683,7 @@ final class DatabaseModel: ObservableObject {
guard let profile = profiles.first(where: { $0.id == id }) else { return }
guard selectedID != id else { return }
saveActiveQuery()
connectionEpoch &+= 1
if let oldID = selectedID { NativeMySQLRunner.disconnect(profileID: oldID) }
result = QueryResult()
isRunning = false
@@ -702,6 +705,7 @@ final class DatabaseModel: ObservableObject {
}
func save(_ profile: ConnectionProfile, password: String) {
connectionEpoch &+= 1
if let i = profiles.firstIndex(where: { $0.id == profile.id }) { profiles[i] = profile }
else { profiles.append(profile) }
PasswordStore.save(password, id: profile.id)
@@ -734,6 +738,7 @@ final class DatabaseModel: ObservableObject {
)
status = "Loading tables from \(database)…"
let epoch = connectionEpoch
Task {
do {
@@ -743,6 +748,7 @@ final class DatabaseModel: ObservableObject {
password: password,
sql: "SHOW FULL TABLES FROM `\(escaped)`"
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
var tables: [String] = []
var views: [String] = []
@@ -776,6 +782,7 @@ final class DatabaseModel: ObservableObject {
profile: profile, database: database, password: password,
sql: "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = '\(quotedSchema)' ORDER BY ROUTINE_NAME"
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
schemaProcedures[database] = routineResult.rows.compactMap { row in
row.count >= 2 && row[1].uppercased() == "PROCEDURE" ? row[0] : nil
}
@@ -783,6 +790,7 @@ final class DatabaseModel: ObservableObject {
row.count >= 2 && row[1].uppercased() == "FUNCTION" ? row[0] : nil
}
} catch {
guard selectedID == profile.id, connectionEpoch == epoch else { return }
schemaProcedures[database] = []
schemaFunctions[database] = []
}
@@ -798,6 +806,7 @@ final class DatabaseModel: ObservableObject {
password: password,
sql: "SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = '\(quotedSchema)' ORDER BY TABLE_NAME, ORDINAL_POSITION"
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
var names = Set<String>()
for row in columnResult.rows where row.count >= 2 {
names.insert(row[1])
@@ -805,6 +814,7 @@ final class DatabaseModel: ObservableObject {
}
if introspection(for: database) == nil { schemaColumns[database] = names.sorted() }
} catch {
guard selectedID == profile.id, connectionEpoch == epoch else { return }
if introspection(for: database) == nil { schemaColumns[database] = [] }
}
@@ -812,6 +822,7 @@ final class DatabaseModel: ObservableObject {
// Revalidate column definitions as well as object names on reconnect/refresh.
if introspection(for: database) != nil { introspect(database: database) }
} catch {
guard selectedID == profile.id, connectionEpoch == epoch else { return }
status = "Unable to load tables: \(error.localizedDescription)"
result = QueryResult(message: error.localizedDescription)
}
@@ -869,6 +880,7 @@ final class DatabaseModel: ObservableObject {
let queryStarted = Date()
status = "Executing…"
let password = PasswordStore.read(profile.id)
let epoch = connectionEpoch
Task {
var activeDB = listingDatabases ? "" : (selectedDatabase.isEmpty ? profile.database : selectedDatabase)
do {
@@ -879,6 +891,7 @@ final class DatabaseModel: ObservableObject {
let parsed = try await NativeMySQLRunner.run(
profile: profile, database: activeDB, password: password, sql: item
)
guard selectedID == profile.id, connectionEpoch == epoch else { return }
lastResult = parsed
if let usedSchema = SQLStatementSplitter.usedDatabase(item) {
activeDB = usedSchema
@@ -888,7 +901,7 @@ final class DatabaseModel: ObservableObject {
introspect(database: usedSchema)
}
}
guard selectedID == profile.id else { return }
guard selectedID == profile.id, connectionEpoch == epoch else { return }
if listingDatabases {
databases = lastResult.rows.compactMap { $0.first }
status = "Connected to \(profile.name)"
@@ -905,7 +918,7 @@ final class DatabaseModel: ObservableObject {
} catch {
// An old connection must not overwrite the newly selected
// connection's state when its request finishes later.
guard selectedID == profile.id else { return }
guard selectedID == profile.id, connectionEpoch == epoch else { return }
status = "Error"
result = QueryResult(message: error.localizedDescription)
if !listingDatabases {
@@ -915,7 +928,7 @@ final class DatabaseModel: ObservableObject {
if !listingDatabases {
queryDurationText = String(format: "%.3f s", Date().timeIntervalSince(queryStarted))
}
if selectedID == profile.id { isRunning = false }
if selectedID == profile.id && connectionEpoch == epoch { isRunning = false }
}
}
+22
View File
@@ -52,6 +52,28 @@ final class DBSynTests: XCTestCase {
XCTAssertEqual(decoded, profile)
}
func testLegacyConnectionProfileDefaultsTLSAndSSH() throws {
let legacy = """
{"id":"00000000-0000-0000-0000-000000000001","name":"Legacy","host":"127.0.0.1","port":3306,"username":"root","database":""}
"""
let profile = try JSONDecoder().decode(ConnectionProfile.self, from: Data(legacy.utf8))
XCTAssertTrue(profile.useTLS)
XCTAssertFalse(profile.useSSHTunnel)
XCTAssertEqual(profile.sshPort, 22)
}
func testConnectionProfileRetainsSecurityPreferences() throws {
var profile = ConnectionProfile()
profile.useTLS = false
profile.useSSHTunnel = true
profile.sshHost = "gateway.example.test"
profile.sshPort = 2222
let decoded = try JSONDecoder().decode(ConnectionProfile.self, from: JSONEncoder().encode(profile))
XCTAssertEqual(decoded, profile)
XCTAssertFalse(decoded.useTLS)
XCTAssertTrue(decoded.useSSHTunnel)
}
func testIntrospectionCacheCodableRoundTrip() throws {
let connectionID = UUID()
let column = DatabaseModel.IntrospectedColumn(table: "ADCUSERS", name: "id", dataType: "bigint", nullable: false, key: "PRI", defaultValue: nil, extra: "auto_increment")
+37
View File
@@ -0,0 +1,37 @@
# DBSyn source audit — 19 October 2026
## Scope and evidence
Reviewed all eight Swift source files, Xcode project configuration, XCTest source, and test-runner syntax. Baseline: Phase 74.6.2 (newer than the user-uploaded 11:13 archive). Checked source syntax using Linux Swift parser, not the macOS SDK or Xcode compiler. No live database, SSH or TLS tests were possible. This is a **static audit**, not a production certification.
## Fixes in this revision
- Fixed three accidentally escaped Swift interpolations in SQL script open/save status messages.
- Added a connection-epoch guard to prevent responses from a previous connection selection or saved-profile edit from changing query/database UI state.
- Guarded table, routine and column metadata updates after awaited network calls against stale connections.
- Added tests for legacy connection decoding and security-option persistence.
## Findings requiring further engineering and macOS validation
| Priority | Area | Evidence and risk | Required action |
|---|---|---|---|
| Critical | Native TLS | `MySQLProtocol.swift` upgrades already-open `CFStream`s using negotiated SSL settings. This approach has not been demonstrated to enforce TLS 1.2+ or validate peer identity on real servers. | Replace with tested transport using modern TLS APIs, enforce TLS >= 1.2, validate hostname/certificate, test against valid/expired/wrong-host certs. |
| High | Offline connections | `readExact` uses synchronous stream reads without a clearly enforced deadline. Per-profile serial queues prevent cross-profile blocking but the offline profile can remain stuck. | Add connection/read/write deadlines and cancellation with tests for unreachable/blackholed endpoints. |
| High | SSH startup | SSH tunnel launch sleeps 0.6 s and assumes process still running means forwarding is ready; local port derives from UUID and may collide. | Use ephemeral bound local port, readiness probe, bounded retry and stderr diagnostics. |
| High | UI performance | `MainView.swift` is >350 KB; complex SwiftUI view and administration features in a monolithic file. | Split by feature, profile redraws and memory, implement lazy paging for large result sets. |
| High | SQL execution | Multi-statement scripts may partially execute before a later failure; row editing and schema modifications depend on SQL generation. | Add transaction-awareness, explicit non-atomic warning, tests for generated SQL and identifiers. |
| High | Secrets | PHP CLI hashing is invoked using a subprocess, with password delivered via stdin (not command line). Plaintext and hash are retained in view state until dismissed. | Test memory/clipboard lifecycle, add clipboard-clear option and executable validation. |
| Medium | Legacy storage | Some settings keys still use `WorkbenchReplacement.*`. | Plan a versioned migration with compatibility tests before renaming keys. |
| Medium | Admin UI | Account privileges, account limits and options editing have not been verified against actual MySQL 8 servers and least-privilege accounts. | End-to-end test create/edit/grant/revoke/reload, confirm privilege matrices and SQL previews. |
| Medium | Regression testing | Existing XCTest covers parsers, profiles and diagrams, but not live networking, SQL result editing, TLS or admin changes. | Add integration suite against disposable MySQL with TLS, SSH and offline fault injection. |
## Release gate
1. Xcode build + XCTest pass on macOS 15+ (arm64).
2. Working → offline → working connection switch recovers without beachball.
3. TLS valid/invalid certificate cases verified; no password sent in plaintext on remote connections.
4. Create/edit/drop account and grant/revoke/limits tested with restricted permissions.
5. Laravel bcrypt output validated with `password_verify` and Laravel `Hash::check`.
6. Large result sets, imports and exports profiled for memory and responsiveness.
**Do not treat this ZIP as fully verified or production-ready until these gates pass.**