connection updates
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.**
|
||||
Reference in New Issue
Block a user