From 69c8d2c4dbd3eb00d815e147b28c0692cb741398 Mon Sep 17 00:00:00 2001 From: Mark Gregory Date: Mon, 19 Oct 2026 12:17:32 +0100 Subject: [PATCH] connection updates --- DBSyn/DatabaseModel.swift | 25 ++++++++++++++++------ DBSynTests/DBSynTests.swift | 22 ++++++++++++++++++++ docs/SOURCE_AUDIT_2026-10-19.md | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 docs/SOURCE_AUDIT_2026-10-19.md diff --git a/DBSyn/DatabaseModel.swift b/DBSyn/DatabaseModel.swift index 45195d2..bbb27b1 100644 --- a/DBSyn/DatabaseModel.swift +++ b/DBSyn/DatabaseModel.swift @@ -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() 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 } } } diff --git a/DBSynTests/DBSynTests.swift b/DBSynTests/DBSynTests.swift index 177c9f5..b1fa899 100644 --- a/DBSynTests/DBSynTests.swift +++ b/DBSynTests/DBSynTests.swift @@ -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") diff --git a/docs/SOURCE_AUDIT_2026-10-19.md b/docs/SOURCE_AUDIT_2026-10-19.md new file mode 100644 index 0000000..942d946 --- /dev/null +++ b/docs/SOURCE_AUDIT_2026-10-19.md @@ -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.**